diff --git a/.coveragerc b/.coveragerc index 91f99fe84d4984..fe5242327dcf06 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,7 +6,6 @@ omit = homeassistant/helpers/signal.py homeassistant/helpers/typing.py homeassistant/scripts/*.py - homeassistant/util/async.py # omit pieces of code that rely on external devices being present homeassistant/components/abode/__init__.py @@ -32,7 +31,6 @@ omit = homeassistant/components/airly/const.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/cover.py - homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarmdecoder/* homeassistant/components/alarmdotcom/alarm_control_panel.py homeassistant/components/alpha_vantage/sensor.py @@ -60,7 +58,6 @@ omit = homeassistant/components/arwn/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* - homeassistant/components/asuswrt/device_tracker.py homeassistant/components/aten_pe/* homeassistant/components/atome/* homeassistant/components/august/* @@ -168,7 +165,6 @@ omit = homeassistant/components/dsmr_reader/* homeassistant/components/dte_energy_bridge/sensor.py homeassistant/components/dublin_bus_transport/sensor.py - homeassistant/components/duke_energy/sensor.py homeassistant/components/dunehd/media_player.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* @@ -221,6 +217,7 @@ omit = homeassistant/components/eufy/* homeassistant/components/everlights/light.py homeassistant/components/evohome/* + homeassistant/components/ezviz/* homeassistant/components/familyhub/camera.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py @@ -250,18 +247,19 @@ omit = homeassistant/components/fritzbox/* homeassistant/components/fritzbox_callmonitor/sensor.py homeassistant/components/fritzbox_netmonitor/sensor.py - homeassistant/components/fritzdect/switch.py homeassistant/components/fronius/sensor.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py + homeassistant/components/garmin_connect/__init__.py + homeassistant/components/garmin_connect/const.py + homeassistant/components/garmin_connect/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* homeassistant/components/gearbest/sensor.py homeassistant/components/geizhals/sensor.py homeassistant/components/gios/__init__.py homeassistant/components/gios/air_quality.py - homeassistant/components/gios/consts.py homeassistant/components/github/sensor.py homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py @@ -306,7 +304,6 @@ omit = homeassistant/components/homematic/notify.py homeassistant/components/homeworks/* homeassistant/components/honeywell/climate.py - homeassistant/components/hook/switch.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py homeassistant/components/htu21d/sensor.py @@ -323,6 +320,7 @@ omit = homeassistant/components/iaqualink/sensor.py homeassistant/components/iaqualink/switch.py homeassistant/components/icloud/__init__.py + homeassistant/components/icloud/account.py homeassistant/components/icloud/device_tracker.py homeassistant/components/icloud/sensor.py homeassistant/components/izone/climate.py @@ -387,7 +385,6 @@ omit = homeassistant/components/linode/* homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* - homeassistant/components/liveboxplaytv/media_player.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/lockitron/lock.py homeassistant/components/logi_circle/__init__.py @@ -412,17 +409,28 @@ omit = homeassistant/components/mcp23017/* homeassistant/components/media_extractor/* homeassistant/components/mediaroom/media_player.py + homeassistant/components/melcloud/__init__.py + homeassistant/components/melcloud/climate.py + homeassistant/components/melcloud/sensor.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py - homeassistant/components/meteo_france/* + homeassistant/components/meteo_france/__init__.py + homeassistant/components/meteo_france/const.py + homeassistant/components/meteo_france/sensor.py + homeassistant/components/meteo_france/weather.py homeassistant/components/meteoalarm/* homeassistant/components/metoffice/sensor.py homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/miflora/sensor.py - homeassistant/components/mikrotik/* + homeassistant/components/mikrotik/hub.py + homeassistant/components/mikrotik/device_tracker.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py + homeassistant/components/minecraft_server/__init__.py + homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/const.py + homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/* homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py @@ -472,6 +480,7 @@ omit = homeassistant/components/nissan_leaf/* homeassistant/components/nmap_tracker/device_tracker.py homeassistant/components/nmbs/sensor.py + homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py homeassistant/components/notion/sensor.py homeassistant/components/noaa_tides/sensor.py @@ -508,13 +517,13 @@ omit = homeassistant/components/openuv/sensor.py homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py + homeassistant/components/opnsense/* homeassistant/components/opple/light.py homeassistant/components/orangepi_gpio/* homeassistant/components/oru/* homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py - homeassistant/components/owlet/* homeassistant/components/panasonic_bluray/media_player.py homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py @@ -534,7 +543,6 @@ omit = homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py homeassistant/components/plex/server.py - homeassistant/components/plex/websockets.py homeassistant/components/plugwise/* homeassistant/components/plum_lightpad/* homeassistant/components/pocketcasts/sensor.py @@ -603,6 +611,7 @@ omit = homeassistant/components/russound_rnet/media_player.py homeassistant/components/sabnzbd/* homeassistant/components/saj/sensor.py + homeassistant/components/salt/device_tracker.py homeassistant/components/satel_integra/* homeassistant/components/scrape/sensor.py homeassistant/components/scsgate/* @@ -622,8 +631,6 @@ omit = homeassistant/components/shodan/sensor.py homeassistant/components/sht31/sensor.py homeassistant/components/sigfox/sensor.py - homeassistant/components/signal_messenger/__init__.py - homeassistant/components/signal_messenger/notify.py homeassistant/components/simplepush/notify.py homeassistant/components/simplisafe/__init__.py homeassistant/components/simplisafe/alarm_control_panel.py @@ -640,6 +647,7 @@ omit = homeassistant/components/smappee/* homeassistant/components/smarty/* homeassistant/components/smarthab/* + homeassistant/components/sms/* homeassistant/components/smtp/notify.py homeassistant/components/snapcast/media_player.py homeassistant/components/snmp/* @@ -662,6 +670,7 @@ omit = homeassistant/components/speedtestdotnet/* homeassistant/components/spider/* homeassistant/components/spotcrime/sensor.py + homeassistant/components/spotify/__init__.py homeassistant/components/spotify/media_player.py homeassistant/components/squeezebox/* homeassistant/components/starline/* @@ -727,7 +736,6 @@ omit = homeassistant/components/torque/sensor.py homeassistant/components/totalconnect/* homeassistant/components/touchline/climate.py - homeassistant/components/tplink/device_tracker.py homeassistant/components/tplink/switch.py homeassistant/components/tplink_lte/* homeassistant/components/traccar/device_tracker.py @@ -749,10 +757,8 @@ omit = homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py - homeassistant/components/twitch/sensor.py homeassistant/components/twitter/notify.py homeassistant/components/ubee/device_tracker.py - homeassistant/components/uber/sensor.py homeassistant/components/ubus/device_tracker.py homeassistant/components/ue_smart_radio/media_player.py homeassistant/components/unifiled/* @@ -781,10 +787,10 @@ omit = homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vicare/* + homeassistant/components/vilfo/__init__.py + homeassistant/components/vilfo/sensor.py + homeassistant/components/vilfo/const.py homeassistant/components/vivotek/camera.py - homeassistant/components/vizio/__init__.py - homeassistant/components/vizio/const.py - homeassistant/components/vizio/media_player.py homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py @@ -830,7 +836,6 @@ omit = homeassistant/components/zestimate/sensor.py homeassistant/components/zha/__init__.py homeassistant/components/zha/api.py - homeassistant/components/zha/const.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/const.py homeassistant/components/zha/core/device.py @@ -838,7 +843,7 @@ omit = homeassistant/components/zha/core/helpers.py homeassistant/components/zha/core/patches.py homeassistant/components/zha/core/registries.py - homeassistant/components/zha/device_entity.py + homeassistant/components/zha/core/typing.py homeassistant/components/zha/entity.py homeassistant/components/zha/light.py homeassistant/components/zha/sensor.py diff --git a/.github/stale.yml b/.github/stale.yml index 44cd95e1f5d710..e75d791a57c106 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -52,4 +52,14 @@ markComment: > limitPerRun: 30 # Limit to only `issues` or `pulls` -only: issues +# only: issues + +# Handle pull requests a little bit faster and with an adjusted comment. +pulls: + daysUntilStale: 30 + markComment: > + There hasn't been any activity on this pull request recently. This pull + request has been automatically marked as stale because of that and will + be closed if no further activity occurs within 7 days. + + Thank you for your contributions. diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index c5ab91614dc6d1..00000000000000 --- a/.hound.yml +++ /dev/null @@ -1,2 +0,0 @@ -python: - enabled: true diff --git a/.pre-commit-config-all.yaml b/.pre-commit-config-all.yaml deleted file mode 100644 index a6b882e617b95b..00000000000000 --- a/.pre-commit-config-all.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# This configuration includes the full set of hooks we use. In -# addition to the defaults (see .pre-commit-config.yaml), this -# includes hooks that require our development and test dependencies -# installed and the virtualenv containing them active by the time -# pre-commit runs to produce correct results. -# -# If this is not a problem for your workflow, using this config is -# recommended, install it with -# pre-commit install --config .pre-commit-config-all.yaml -# Otherwise, see the default .pre-commit-config.yaml for a lighter one. - -repos: -- repo: https://github.com/psf/black - rev: 19.10b0 - hooks: - - id: black - args: - - --safe - - --quiet - files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ -- repo: https://github.com/PyCQA/flake8 - rev: 3.7.9 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.5.0 - - pydocstyle==5.0.2 - files: ^(homeassistant|script|tests)/.+\.py$ -- repo: https://github.com/PyCQA/bandit - rev: 1.6.2 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=tests/bandit.yaml - files: ^(homeassistant|script|tests)/.+\.py$ -- repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 - hooks: - - id: isort -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 - hooks: - - id: check-json -# Using a local "system" mypy instead of the mypy hook, because its -# results depend on what is installed. And the mypy hook runs in a -# virtualenv of its own, meaning we'd need to install and maintain -# another set of our dependencies there... no. Use the "system" one -# and reuse the environment that is set up anyway already instead. -- repo: local - hooks: - - id: mypy - name: mypy - entry: mypy - language: system - types: [python] - require_serial: true - files: ^homeassistant/.+\.py$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f27e82b6d9ddc..7d55224c335ea6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,41 +1,61 @@ -# This configuration includes the default, minimal set of hooks to be -# run on all commits. It requires no specific setup and one can just -# start using pre-commit with it. -# -# See .pre-commit-config-all.yaml for a more complete one that comes -# with a better coverage at the cost of some specific setup needed. - repos: -- repo: https://github.com/psf/black + - repo: https://github.com/psf/black rev: 19.10b0 hooks: - - id: black + - id: black args: - --safe - --quiet files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ -- repo: https://gitlab.com/pycqa/flake8 + - repo: https://github.com/codespell-project/codespell + rev: v1.16.0 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing + - --skip="./.*,*.json" + - --quiet-level=2 + exclude_types: [json] + - repo: https://gitlab.com/pycqa/flake8 rev: 3.7.9 hooks: - - id: flake8 + - id: flake8 additional_dependencies: - flake8-docstrings==1.5.0 - pydocstyle==5.0.2 files: ^(homeassistant|script|tests)/.+\.py$ -- repo: https://github.com/PyCQA/bandit + - repo: https://github.com/PyCQA/bandit rev: 1.6.2 hooks: - - id: bandit + - id: bandit args: - --quiet - --format=custom - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ -- repo: https://github.com/pre-commit/mirrors-isort + - repo: https://github.com/pre-commit/mirrors-isort rev: v4.3.21 hooks: - - id: isort -- repo: https://github.com/pre-commit/pre-commit-hooks + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.4.0 hooks: - - id: check-json + - id: check-json + - id: no-commit-to-branch + args: + - --branch=dev + - --branch=master + - --branch=rc + - repo: local + hooks: + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy + name: mypy + entry: script/run-in-env.sh mypy + language: script + types: [python] + require_serial: true + files: ^homeassistant/.+\.py$ diff --git a/CODEOWNERS b/CODEOWNERS index 5cc80797c52024..411c615e857255 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -23,6 +23,7 @@ homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya +homeassistant/components/amcrest/* @pnbruckner homeassistant/components/androidtv/* @JeffLIrion homeassistant/components/apache_kafka/* @bachya homeassistant/components/api/* @home-assistant/core @@ -34,6 +35,7 @@ homeassistant/components/arest/* @fabaff homeassistant/components/asuswrt/* @kennedyshead homeassistant/components/aten_pe/* @mtdcr homeassistant/components/atome/* @baqs +homeassistant/components/august/* @bdraco homeassistant/components/aurora_abb_powerone/* @davet2001 homeassistant/components/auth/* @home-assistant/core homeassistant/components/automatic/* @armills @@ -82,6 +84,7 @@ homeassistant/components/discogs/* @thibmaek homeassistant/components/doorbird/* @oblogic7 homeassistant/components/dsmr_reader/* @depl0y homeassistant/components/dweet/* @fabaff +homeassistant/components/dynalite/* @ziv1234 homeassistant/components/dyson/* @etheralm homeassistant/components/ecobee/* @marthoc homeassistant/components/ecovacs/* @OverloadUT @@ -100,6 +103,7 @@ homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/esphome/* @OttoWinter homeassistant/components/essent/* @TheLastProject homeassistant/components/evohome/* @zxdavb +homeassistant/components/ezviz/* @baqs homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes @@ -115,6 +119,8 @@ homeassistant/components/foursquare/* @robbiet480 homeassistant/components/freebox/* @snoof85 homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend +homeassistant/components/garmin_connect/* @cyberjunky +homeassistant/components/gdacs/* @exxamalte homeassistant/components/gearbest/* @HerrHofrat homeassistant/components/geniushub/* @zxdavb homeassistant/components/geo_rss_events/* @exxamalte @@ -142,7 +148,6 @@ homeassistant/components/hikvision/* @mezz64 homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core -homeassistant/components/history_graph/* @andrey-git homeassistant/components/hive/* @Rendili @KJonline homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit_controller/* @Jc2k @@ -182,14 +187,13 @@ homeassistant/components/kef/* @basnijholt homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills -homeassistant/components/konnected/* @heythisisnate +homeassistant/components/konnected/* @heythisisnate @kit-klein homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus homeassistant/components/life360/* @pnbruckner homeassistant/components/linky/* @Quentame homeassistant/components/linux_battery/* @fabaff -homeassistant/components/liveboxplaytv/* @pschmitt homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd @@ -202,13 +206,16 @@ homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf homeassistant/components/mcp23017/* @jardiamj homeassistant/components/mediaroom/* @dgomes +homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen -homeassistant/components/meteo_france/* @victorcerutti @oncleben31 +homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel +homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff +homeassistant/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 homeassistant/components/modbus/* @adamchengtkc @@ -246,9 +253,9 @@ homeassistant/components/onewire/* @garbled1 homeassistant/components/opentherm_gw/* @mvn23 homeassistant/components/openuv/* @bachya homeassistant/components/openweathermap/* @fabaff +homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu -homeassistant/components/owlet/* @oblogic7 homeassistant/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/pcal9535a/* @Shulyaka @@ -272,7 +279,7 @@ homeassistant/components/quantum_gateway/* @cisasteelersfan homeassistant/components/qwikswitch/* @kellerza homeassistant/components/rainbird/* @konikvranik homeassistant/components/raincloud/* @vanstinator -homeassistant/components/rainforest_eagle/* @gtdiehl +homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff homeassistant/components/repetier/* @MTrab @@ -282,6 +289,7 @@ homeassistant/components/rmvtransport/* @cgtobi homeassistant/components/roomba/* @pschmitt homeassistant/components/safe_mode/* @home-assistant/core homeassistant/components/saj/* @fredericvl +homeassistant/components/salt/* @bjornorri homeassistant/components/samsungtv/* @escoand homeassistant/components/scene/* @home-assistant/core homeassistant/components/scrape/* @fabaff @@ -305,6 +313,7 @@ homeassistant/components/sma/* @kellerza homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smarty/* @z0mbieprocess +homeassistant/components/sms/* @ocalvo homeassistant/components/smtp/* @fabaff homeassistant/components/solaredge_local/* @drobtravels @scheric homeassistant/components/solarlog/* @Ernst79 @@ -315,6 +324,7 @@ homeassistant/components/songpal/* @rytilahti homeassistant/components/spaceapi/* @fabaff homeassistant/components/speedtestdotnet/* @rohankapoorcom homeassistant/components/spider/* @peternijssen +homeassistant/components/spotify/* @frenck homeassistant/components/sql/* @dgomes homeassistant/components/starline/* @anonym-tsk homeassistant/components/statistics/* @fabaff @@ -349,12 +359,13 @@ homeassistant/components/time_date/* @fabaff homeassistant/components/tmb/* @alemuro homeassistant/components/todoist/* @boralyl homeassistant/components/toon/* @frenck +homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti homeassistant/components/traccar/* @ludeeus homeassistant/components/tradfri/* @ggravlingen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins -homeassistant/components/tts/* @robbiet480 +homeassistant/components/tts/* @pvizeli homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 @@ -368,19 +379,19 @@ homeassistant/components/upnp/* @robbiet480 homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes -homeassistant/components/velbus/* @cereal2nd +homeassistant/components/velbus/* @Cereal2nd @brefra homeassistant/components/velux/* @Julius2342 homeassistant/components/versasense/* @flamm3blemuff1n homeassistant/components/version/* @fabaff homeassistant/components/vesync/* @markperdue @webdjoe homeassistant/components/vicare/* @oischinger +homeassistant/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff -homeassistant/components/weblink/* @home-assistant/core homeassistant/components/webostv/* @bendavid homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @sqldiablo diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 546b63950fe4d1..4c6a353d77551d 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -43,7 +43,11 @@ stages: . venv/bin/activate pip install -r requirements_test.txt -c homeassistant/package_constraints.txt - pre-commit install-hooks --config .pre-commit-config-all.yaml + pre-commit install-hooks + - script: | + . venv/bin/activate + pre-commit run codespell --all-files + displayName: 'Run codespell' - script: | . venv/bin/activate pre-commit run flake8 --all-files @@ -94,7 +98,7 @@ stages: . venv/bin/activate pip install -r requirements_test.txt -c homeassistant/package_constraints.txt - pre-commit install-hooks --config .pre-commit-config-all.yaml + pre-commit install-hooks - script: | . venv/bin/activate pre-commit run black --all-files --show-diff-on-failure @@ -190,8 +194,8 @@ stages: . venv/bin/activate pip install -e . -r requirements_test.txt -c homeassistant/package_constraints.txt - pre-commit install-hooks --config .pre-commit-config-all.yaml + pre-commit install-hooks - script: | . venv/bin/activate - pre-commit run --config .pre-commit-config-all.yaml mypy --all-files + pre-commit run mypy --all-files displayName: 'Run mypy' diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 135057f2ae4445..c98f12dfac6bde 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -163,7 +163,7 @@ stages: git commit -am "Bump Home Assistant $version" git push - displayName: 'Update version files' + displayName: "Update version files" - job: 'ReleaseDocker' pool: vmImage: 'ubuntu-latest' diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index 5092010c49c385..b537aa3bf535c6 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -30,7 +30,7 @@ jobs: - template: templates/azp-job-wheels.yaml@azure parameters: builderVersion: '$(versionWheels)' - builderApk: '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;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev' + builderApk: '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;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev' builderPip: 'Cython;numpy' wheelsRequirement: 'requirements_wheels.txt' wheelsRequirementDiff: 'requirements_diff.txt' @@ -68,6 +68,7 @@ jobs: sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} sed -i "s|# bme680|bme680|g" ${requirement_file} + sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} if [[ "$(buildArch)" =~ arm ]]; then sed -i "s|# VL53L1X|VL53L1X|g" ${requirement_file} diff --git a/.codecov.yml b/codecov.yml similarity index 92% rename from .codecov.yml rename to codecov.yml index be739b61809839..1455c20749a5c3 100644 --- a/.codecov.yml +++ b/codecov.yml @@ -13,4 +13,7 @@ coverage: url: "secret:TgWDUM4Jw0w7wMJxuxNF/yhSOHglIo1fGwInJnRLEVPy2P2aLimkoK1mtKCowH5TFw+baUXVXT3eAqefbdvIuM8BjRR4aRji95C6CYyD0QHy4N8i7nn1SQkWDPpS8IthYTg07rUDF7s5guurkKv2RrgoCdnnqjAMSzHoExMOF7xUmblMdhBTWJgBpWEhASJy85w/xxjlsE1xoTkzeJu9Q67pTXtRcn+5kb5/vIzPSYg=" comment: require_changes: yes - branches: master + layout: reach + branches: + - master + - !dev diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9b3cf49fa22f16..710b4af1cd85c7 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -301,7 +301,7 @@ async def async_activate_user(self, user: models.User) -> None: async def async_deactivate_user(self, user: models.User) -> None: """Deactivate a user.""" if user.is_owner: - raise ValueError("Unable to deactive the owner") + raise ValueError("Unable to deactivate the owner") await self._store.async_deactivate_user(user) async def async_remove_credentials(self, credentials: models.Credentials) -> None: diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index fd9e61b9d17117..c2ec2260cf2245 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -1,4 +1,4 @@ -"""Plugable auth modules for Home Assistant.""" +"""Pluggable auth modules for Home Assistant.""" import importlib import logging import types diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 46cc634bcaeecc..8da81a44a61e67 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -317,7 +317,7 @@ async def async_step_init( async def async_step_setup( self, user_input: Optional[Dict[str, str]] = None ) -> Dict[str, Any]: - """Verify user can recevie one-time password.""" + """Verify user can receive one-time password.""" errors: Dict[str, str] = {} hass = self._auth_module.hass diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 08f2f375b41161..8b4e63557004a5 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -31,22 +31,28 @@ class User: """A user.""" name = attr.ib(type=Optional[str]) - perm_lookup = attr.ib(type=perm_mdl.PermissionLookup, cmp=False) + perm_lookup = attr.ib(type=perm_mdl.PermissionLookup, eq=False, order=False) id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) system_generated = attr.ib(type=bool, default=False) - groups = attr.ib(type=List[Group], factory=list, cmp=False) + groups = attr.ib(type=List[Group], factory=list, eq=False, order=False) # List of credentials of a user. - credentials = attr.ib(type=List["Credentials"], factory=list, cmp=False) + credentials = attr.ib(type=List["Credentials"], factory=list, eq=False, order=False) # Tokens associated with a user. - refresh_tokens = attr.ib(type=Dict[str, "RefreshToken"], factory=dict, cmp=False) + refresh_tokens = attr.ib( + type=Dict[str, "RefreshToken"], factory=dict, eq=False, order=False + ) _permissions = attr.ib( - type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None + type=Optional[perm_mdl.PolicyPermissions], + init=False, + eq=False, + order=False, + default=None, ) @property diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3d8523bf9acc9f..7d4155257dbf0b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,23 +1,26 @@ """Provide methods to bootstrap a Home Assistant instance.""" import asyncio +import contextlib import logging import logging.handlers import os import sys -from time import time +from time import monotonic from typing import Any, Dict, Optional, Set +from async_timeout import timeout import voluptuous as vol from homeassistant import config as conf_util, config_entries, core, loader from homeassistant.components import http from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_STOP, REQUIRED_NEXT_PYTHON_DATE, REQUIRED_NEXT_PYTHON_VER, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component +from homeassistant.setup import DATA_SETUP, async_setup_component from homeassistant.util.logging import AsyncHandler from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache @@ -71,6 +74,7 @@ async def async_setup_hass( _LOGGER.info("Config directory: %s", config_dir) config_dict = None + basic_setup_success = False if not safe_mode: await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) @@ -79,19 +83,45 @@ async def async_setup_hass( config_dict = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: _LOGGER.error( - "Failed to parse configuration.yaml: %s. Falling back to safe mode", - err, + "Failed to parse configuration.yaml: %s. Activating safe mode", err, ) else: if not is_virtual_env(): await async_mount_local_lib_path(config_dir) - await async_from_config_dict(config_dict, hass) + basic_setup_success = ( + await async_from_config_dict(config_dict, hass) is not None + ) finally: clear_secret_cache() - if safe_mode or config_dict is None: + if config_dict is None: + safe_mode = True + + elif not basic_setup_success: + _LOGGER.warning("Unable to set up core integrations. Activating safe mode") + safe_mode = True + + elif ( + "frontend" in hass.data.get(DATA_SETUP, {}) + and "frontend" not in hass.config.components + ): + _LOGGER.warning("Detected that frontend did not load. Activating safe mode") + # Ask integrations to shut down. It's messy but we can't + # do a clean stop without knowing what is broken + hass.async_track_tasks() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) + with contextlib.suppress(asyncio.TimeoutError): + async with timeout(10): + await hass.async_block_till_done() + + safe_mode = True + hass = core.HomeAssistant() + hass.config.config_dir = config_dir + + if safe_mode: _LOGGER.info("Starting in safe mode") + hass.config.safe_mode = True http_conf = (await http.async_get_last_config(hass)) or {} @@ -110,7 +140,26 @@ async def async_from_config_dict( Dynamically loads required components and its dependencies. This method is a coroutine. """ - start = time() + start = monotonic() + + hass.config_entries = config_entries.ConfigEntries(hass, config) + await hass.config_entries.async_initialize() + + # Set up core. + _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) + + if not all( + await asyncio.gather( + *( + async_setup_component(hass, domain, config) + for domain in CORE_INTEGRATIONS + ) + ) + ): + _LOGGER.error("Home Assistant core failed to initialize. ") + return None + + _LOGGER.debug("Home Assistant core initialized") core_config = config.get(core.DOMAIN, {}) @@ -126,12 +175,9 @@ async def async_from_config_dict( ) return None - hass.config_entries = config_entries.ConfigEntries(hass, config) - await hass.config_entries.async_initialize() - await _async_set_up_integrations(hass, config) - stop = time() + stop = monotonic() _LOGGER.info("Home Assistant initialized in %.2fs", stop - start) if REQUIRED_NEXT_PYTHON_DATE and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER: @@ -193,7 +239,7 @@ def async_enable_logging( pass # If the above initialization failed for any reason, setup the default - # formatting. If the above succeeds, this wil result in a no-op. + # formatting. If the above succeeds, this will result in a no-op. logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) # Suppress overly verbose logs from libraries that aren't helpful @@ -264,7 +310,7 @@ def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]: domains = set(key.split(" ")[0] for key in config.keys() if key != core.DOMAIN) # Add config entry domains - if "safe_mode" not in config: + if not hass.config.safe_mode: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded @@ -296,25 +342,6 @@ async def _async_set_up_integrations( return_exceptions=True, ) - # Set up core. - _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) - - if not all( - await asyncio.gather( - *( - async_setup_component(hass, domain, config) - for domain in CORE_INTEGRATIONS - ) - ) - ): - _LOGGER.error( - "Home Assistant core failed to initialize. " - "Further initialization aborted" - ) - return - - _LOGGER.debug("Home Assistant core initialized") - # Finish resolving domains for dep_domains in await resolved_domains_task: # Result is either a set or an exception. We ignore exceptions diff --git a/homeassistant/components/abode/.translations/ca.json b/homeassistant/components/abode/.translations/ca.json index 2424fd9b5f0ef8..7763ff04a7ab08 100644 --- a/homeassistant/components/abode/.translations/ca.json +++ b/homeassistant/components/abode/.translations/ca.json @@ -14,7 +14,7 @@ "password": "Contrasenya", "username": "Correu electr\u00f2nic" }, - "title": "Introdueix la teva informaci\u00f3 d'inici de sessi\u00f3 a Abode." + "title": "Introducci\u00f3 de la informaci\u00f3 d'inici de sessi\u00f3 a Abode." } }, "title": "Abode" diff --git a/homeassistant/components/abode/.translations/es-419.json b/homeassistant/components/abode/.translations/es-419.json new file mode 100644 index 00000000000000..f2def50d063033 --- /dev/null +++ b/homeassistant/components/abode/.translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." + }, + "error": { + "connection_error": "No se puede conectar a Abode.", + "identifier_exists": "Cuenta ya registrada.", + "invalid_credentials": "Credenciales inv\u00e1lidas." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Complete su informaci\u00f3n de inicio de sesi\u00f3n de Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/hu.json b/homeassistant/components/abode/.translations/hu.json new file mode 100644 index 00000000000000..385334c8549b7c --- /dev/null +++ b/homeassistant/components/abode/.translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Csak egyetlen Abode konfigur\u00e1ci\u00f3 enged\u00e9lyezett." + }, + "error": { + "connection_error": "Nem lehet csatlakozni az Abode-hez.", + "identifier_exists": "Fi\u00f3k m\u00e1r regisztr\u00e1lva van", + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Email c\u00edm" + }, + "title": "T\u00f6ltse ki az Abode bejelentkez\u00e9si adatait" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/.translations/pl.json b/homeassistant/components/abode/.translations/pl.json index c3f3b8f2c88b84..d086aaca3951b2 100644 --- a/homeassistant/components/abode/.translations/pl.json +++ b/homeassistant/components/abode/.translations/pl.json @@ -5,7 +5,7 @@ }, "error": { "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z Abode.", - "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane", + "identifier_exists": "Konto jest ju\u017c zarejestrowane.", "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" }, "step": { diff --git a/homeassistant/components/abode/.translations/sv.json b/homeassistant/components/abode/.translations/sv.json new file mode 100644 index 00000000000000..9a59e4c2007f50 --- /dev/null +++ b/homeassistant/components/abode/.translations/sv.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Endast en enda konfiguration av Abode \u00e4r till\u00e5ten." + }, + "error": { + "connection_error": "Det gick inte att ansluta till Abode.", + "identifier_exists": "Kontot \u00e4r redan registrerat.", + "invalid_credentials": "Ogiltiga autentiseringsuppgifter." + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "E-postadress" + }, + "title": "Fyll i din inloggningsinformation f\u00f6r Abode" + } + }, + "title": "Abode" + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index a5f3e6116a4628..4ce9a4faca0282 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -24,13 +24,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity -from .const import ( - ATTRIBUTION, - DEFAULT_CACHEDB, - DOMAIN, - SIGNAL_CAPTURE_IMAGE, - SIGNAL_TRIGGER_QUICK_ACTION, -) +from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -193,7 +187,7 @@ def capture_image(call): ] for entity_id in target_entities: - signal = SIGNAL_CAPTURE_IMAGE.format(entity_id) + signal = f"abode_camera_capture_{entity_id}" dispatcher_send(hass, signal) def trigger_quick_action(call): @@ -207,7 +201,7 @@ def trigger_quick_action(call): ] for entity_id in target_entities: - signal = SIGNAL_TRIGGER_QUICK_ACTION.format(entity_id) + signal = f"abode_trigger_quick_action_{entity_id}" dispatcher_send(hass, signal) hass.services.register( diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index c27357ca07648e..7d4474437e9de4 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import AbodeAutomation, AbodeDevice -from .const import DOMAIN, SIGNAL_TRIGGER_QUICK_ACTION +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -60,7 +60,7 @@ class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): async def async_added_to_hass(self): """Subscribe Abode events.""" await super().async_added_to_hass() - signal = SIGNAL_TRIGGER_QUICK_ACTION.format(self.entity_id) + signal = f"abode_trigger_quick_action_{self.entity_id}" async_dispatcher_connect(self.hass, signal, self.trigger) def trigger(self): diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 1742a0a5d6ce46..edf29c4a198024 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -11,7 +11,7 @@ from homeassistant.util import Throttle from . import AbodeDevice -from .const import DOMAIN, SIGNAL_CAPTURE_IMAGE +from .const import DOMAIN MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) @@ -50,7 +50,7 @@ async def async_added_to_hass(self): self._capture_callback, ) - signal = SIGNAL_CAPTURE_IMAGE.format(self.entity_id) + signal = f"abode_camera_capture_{self.entity_id}" async_dispatcher_connect(self.hass, signal, self.capture) def capture(self): diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py index 267eb04f72e408..092843ba2120b5 100644 --- a/homeassistant/components/abode/const.py +++ b/homeassistant/components/abode/const.py @@ -3,6 +3,3 @@ ATTRIBUTION = "Data provided by goabode.com" DEFAULT_CACHEDB = "abodepy_cache.pickle" - -SIGNAL_CAPTURE_IMAGE = "abode_camera_capture_{}" -SIGNAL_TRIGGER_QUICK_ACTION = "abode_trigger_quick_action_{}" diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index ce71906dfcc511..383320141e5906 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -3,7 +3,7 @@ "name": "Abode", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/abode", - "requirements": ["abodepy==0.16.7"], + "requirements": ["abodepy==0.17.0"], "dependencies": [], "codeowners": ["@shred86"] } diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index dc622cb1a384f5..afa5e372222899 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -44,9 +44,7 @@ def __init__(self, data, device, sensor_type): """Initialize a sensor for an Abode device.""" super().__init__(data, device) self._sensor_type = sensor_type - self._name = "{0} {1}".format( - self._device.name, SENSOR_TYPES[self._sensor_type][0] - ) + self._name = f"{self._device.name} {SENSOR_TYPES[self._sensor_type][0]}" self._device_class = SENSOR_TYPES[self._sensor_type][1] @property diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index bbe3f01f4886d8..d6773e10ca129f 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -11,6 +11,8 @@ _LOGGER = logging.getLogger(__name__) +DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE] + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode switch devices.""" @@ -18,8 +20,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] - for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH): - entities.append(AbodeSwitch(data, device)) + for device_type in DEVICE_TYPES: + for device in data.abode.get_devices(generic_type=device_type): + entities.append(AbodeSwitch(data, device)) for automation in data.abode.get_automations(generic_type=CONST.TYPE_AUTOMATION): entities.append( diff --git a/homeassistant/components/adguard/.translations/cs.json b/homeassistant/components/adguard/.translations/cs.json new file mode 100644 index 00000000000000..fc450c2e908fff --- /dev/null +++ b/homeassistant/components/adguard/.translations/cs.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k AddGuard pomoc\u00ed hass.io {addon}?", + "title": "AdGuard prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/es-419.json b/homeassistant/components/adguard/.translations/es-419.json index ed8e0c3a35800a..eb3274f19b6670 100644 --- a/homeassistant/components/adguard/.translations/es-419.json +++ b/homeassistant/components/adguard/.translations/es-419.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "adguard_home_addon_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, tiene {current_version}. Actualice su complemento Hass.io AdGuard Home.", + "adguard_home_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, tiene {current_version}.", "existing_instance_updated": "Se actualiz\u00f3 la configuraci\u00f3n existente.", "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." }, diff --git a/homeassistant/components/adguard/.translations/sv.json b/homeassistant/components/adguard/.translations/sv.json index 22bd81e3e97398..519ecef52dbba6 100644 --- a/homeassistant/components/adguard/.translations/sv.json +++ b/homeassistant/components/adguard/.translations/sv.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "adguard_home_addon_outdated": "Den h\u00e4r integrationen kr\u00e4ver AdGuard Home {minimal_version} eller senare, du har {current_version}. Uppdatera ditt Hass.io AdGuard Home-till\u00e4gg.", + "adguard_home_outdated": "Den h\u00e4r integrationen kr\u00e4ver AdGuard Home {minimal_version} eller senare, du har {current_version}.", "existing_instance_updated": "Uppdaterade existerande konfiguration.", "single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten." }, diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index bdfec1f254b785..c77e0b3254d7bf 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -3,7 +3,7 @@ "name": "AdGuard Home", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adguard", - "requirements": ["adguardhome==0.4.0"], + "requirements": ["adguardhome==0.4.1"], "dependencies": [], "codeowners": ["@frenck"] } diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index e5618282a9731e..9d0d5245d804a4 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -11,6 +11,7 @@ DOMAIN, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_MILLISECONDS from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.typing import HomeAssistantType @@ -206,7 +207,7 @@ def __init__(self, adguard): "AdGuard Average Processing Speed", "mdi:speedometer", "average_speed", - "ms", + TIME_MILLISECONDS, ) async def _adguard_update(self) -> None: diff --git a/homeassistant/components/airly/.translations/ca.json b/homeassistant/components/airly/.translations/ca.json index bf50b4f23e55b5..4c5a7a6bd59e88 100644 --- a/homeassistant/components/airly/.translations/ca.json +++ b/homeassistant/components/airly/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Ja est\u00e0 configurada un integraci\u00f3 Airly amb aquestes coordenades." + }, "error": { "auth": "La clau API no \u00e9s correcta.", "name_exists": "El nom ja existeix.", diff --git a/homeassistant/components/airly/.translations/es-419.json b/homeassistant/components/airly/.translations/es-419.json new file mode 100644 index 00000000000000..7492449386352a --- /dev/null +++ b/homeassistant/components/airly/.translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "auth": "La clave API no es correcta.", + "name_exists": "El nombre ya existe.", + "wrong_location": "No hay estaciones de medici\u00f3n Airly en esta \u00e1rea." + }, + "step": { + "user": { + "data": { + "api_key": "Clave API de Airly", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre de la integraci\u00f3n" + }, + "description": "Configure la integraci\u00f3n de la calidad del aire de Airly. Para generar la clave API, vaya a https://developer.airly.eu/register", + "title": "Airly" + } + }, + "title": "Airly" + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/.translations/hu.json b/homeassistant/components/airly/.translations/hu.json new file mode 100644 index 00000000000000..30898c61abb6c2 --- /dev/null +++ b/homeassistant/components/airly/.translations/hu.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Ezen koordin\u00e1t\u00e1k Airly integr\u00e1ci\u00f3ja m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "auth": "Az API kulcs nem megfelel\u0151.", + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik", + "wrong_location": "Ezen a ter\u00fcleten nincs Airly m\u00e9r\u0151\u00e1llom\u00e1s." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API kulcs", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "name": "Az integr\u00e1ci\u00f3 neve" + }, + "description": "Az Airly leveg\u0151min\u0151s\u00e9gi integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Api-kulcs l\u00e9trehoz\u00e1s\u00e1hoz nyissa meg a k\u00f6vetkez\u0151 weboldalt: https://developer.airly.eu/register", + "title": "Airly" + } + }, + "title": "Airly" + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/.translations/nl.json b/homeassistant/components/airly/.translations/nl.json index 232d5d54d8537e..a9c6865ad916b9 100644 --- a/homeassistant/components/airly/.translations/nl.json +++ b/homeassistant/components/airly/.translations/nl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Airly-integratie voor deze co\u00f6rdinaten is al geconfigureerd." + }, "error": { "auth": "API-sleutel is niet correct.", "name_exists": "Naam bestaat al.", diff --git a/homeassistant/components/airly/.translations/sl.json b/homeassistant/components/airly/.translations/sl.json index 08f57d88bcba98..f8ca4e5b6d5301 100644 --- a/homeassistant/components/airly/.translations/sl.json +++ b/homeassistant/components/airly/.translations/sl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Airly integracija za te koordinate je \u017ee nastavljen." + }, "error": { "auth": "Klju\u010d API ni pravilen.", "name_exists": "Ime \u017ee obstaja", diff --git a/homeassistant/components/airly/.translations/sv.json b/homeassistant/components/airly/.translations/sv.json new file mode 100644 index 00000000000000..5b81b4625a2a24 --- /dev/null +++ b/homeassistant/components/airly/.translations/sv.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Airly-integrationen f\u00f6r dessa koordinater \u00e4r redan konfigurerad." + }, + "error": { + "auth": "API-nyckeln \u00e4r inte korrekt.", + "name_exists": "Namnet finns redan.", + "wrong_location": "Inga Airly m\u00e4tstationer i detta omr\u00e5de." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API-nyckel", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Integrationens namn" + }, + "description": "Konfigurera integration av luftkvalitet. F\u00f6r att skapa API-nyckel, g\u00e5 till https://developer.airly.eu/register", + "title": "Airly" + } + }, + "title": "Airly" + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index ab83f711153a08..2d42dac5614a42 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -2,6 +2,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -27,14 +28,13 @@ ATTR_UNIT = "unit" HUMI_PERCENT = "%" -VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m³" SENSOR_TYPES = { ATTR_API_PM1: { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:blur", ATTR_LABEL: ATTR_API_PM1, - ATTR_UNIT: VOLUME_MICROGRAMS_PER_CUBIC_METER, + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, ATTR_API_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 3b177c4ce670b0..a7bf3f4dd1b578 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -11,6 +11,9 @@ ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, @@ -37,10 +40,6 @@ DEFAULT_ATTRIBUTION = "Data provided by AirVisual" DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) -MASS_PARTS_PER_MILLION = "ppm" -MASS_PARTS_PER_BILLION = "ppb" -VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" - SENSOR_TYPE_LEVEL = "air_pollution_level" SENSOR_TYPE_AQI = "air_quality_index" SENSOR_TYPE_POLLUTANT = "main_pollutant" @@ -70,12 +69,12 @@ ] POLLUTANT_MAPPING = { - "co": {"label": "Carbon Monoxide", "unit": MASS_PARTS_PER_MILLION}, - "n2": {"label": "Nitrogen Dioxide", "unit": MASS_PARTS_PER_BILLION}, - "o3": {"label": "Ozone", "unit": MASS_PARTS_PER_BILLION}, - "p1": {"label": "PM10", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER}, - "p2": {"label": "PM2.5", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER}, - "s2": {"label": "Sulfur Dioxide", "unit": MASS_PARTS_PER_BILLION}, + "co": {"label": "Carbon Monoxide", "unit": CONCENTRATION_PARTS_PER_MILLION}, + "n2": {"label": "Nitrogen Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, + "o3": {"label": "Ozone", "unit": CONCENTRATION_PARTS_PER_BILLION}, + "p1": {"label": "PM10", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + "p2": {"label": "PM2.5", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + "s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, } SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} @@ -185,7 +184,7 @@ def icon(self): @property def name(self): """Return the name.""" - return "{0} {1}".format(SENSOR_LOCALES[self._locale], self._name) + return f"{SENSOR_LOCALES[self._locale]} {self._name}" @property def state(self): diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 4cfcd5403ddd5a..351703c5cb376c 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -53,9 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/alarm_control_panel/.translations/sv.json b/homeassistant/components/alarm_control_panel/.translations/sv.json new file mode 100644 index 00000000000000..65e4433f5a3812 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Larma {entity_name} borta", + "arm_home": "Larma {entity_name} hemma", + "arm_night": "Larma {entity_name} natt", + "disarm": "Avlarma {entity_name}", + "trigger": "Utl\u00f6sare {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} larmad borta", + "armed_home": "{entity_name} larmad hemma", + "armed_night": "{entity_name} larmad natt", + "disarmed": "{entity_name} bortkopplad", + "triggered": "{entity_name} utl\u00f6st" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json b/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json index 72c0b65436dd8f..94729865c6fb19 100644 --- a/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json +++ b/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json @@ -1,18 +1,18 @@ { "device_automation": { "action_type": { - "arm_away": "\u8a2d\u5b9a {entity_name} \u5916\u51fa\u6a21\u5f0f", - "arm_home": "\u8a2d\u5b9a {entity_name} \u8fd4\u5bb6\u6a21\u5f0f", - "arm_night": "\u8a2d\u5b9a {entity_name} \u591c\u9593\u6a21\u5f0f", - "disarm": "\u89e3\u9664 {entity_name}", - "trigger": "\u89f8\u767c {entity_name}" + "arm_away": "\u8a2d\u5b9a{entity_name}\u5916\u51fa\u6a21\u5f0f", + "arm_home": "\u8a2d\u5b9a{entity_name}\u8fd4\u5bb6\u6a21\u5f0f", + "arm_night": "\u8a2d\u5b9a{entity_name}\u591c\u9593\u6a21\u5f0f", + "disarm": "\u89e3\u9664{entity_name}", + "trigger": "\u89f8\u767c{entity_name}" }, "trigger_type": { - "armed_away": "{entity_name} \u8a2d\u5b9a\u5916\u51fa", - "armed_home": "{entity_name} \u8a2d\u5b9a\u5728\u5bb6", - "armed_night": "{entity_name} \u8a2d\u5b9a\u591c\u9593", - "disarmed": "{entity_name} \u5df2\u89e3\u9664", - "triggered": "{entity_name} \u5df2\u89f8\u767c" + "armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", + "armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", + "armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "disarmed": "{entity_name}\u5df2\u89e3\u9664", + "triggered": "{entity_name}\u5df2\u89f8\u767c" } } } \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 5fb44a18a0be6d..67b0309e513f52 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -121,67 +121,49 @@ def alarm_disarm(self, code=None): """Send disarm command.""" raise NotImplementedError() - def async_alarm_disarm(self, code=None): - """Send disarm command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job(self.alarm_disarm, code) + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self.hass.async_add_executor_job(self.alarm_disarm, code) def alarm_arm_home(self, code=None): """Send arm home command.""" raise NotImplementedError() - def async_alarm_arm_home(self, code=None): - """Send arm home command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job(self.alarm_arm_home, code) + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await self.hass.async_add_executor_job(self.alarm_arm_home, code) def alarm_arm_away(self, code=None): """Send arm away command.""" raise NotImplementedError() - def async_alarm_arm_away(self, code=None): - """Send arm away command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job(self.alarm_arm_away, code) + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self.hass.async_add_executor_job(self.alarm_arm_away, code) def alarm_arm_night(self, code=None): """Send arm night command.""" raise NotImplementedError() - def async_alarm_arm_night(self, code=None): - """Send arm night command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job(self.alarm_arm_night, code) + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + await self.hass.async_add_executor_job(self.alarm_arm_night, code) def alarm_trigger(self, code=None): """Send alarm trigger command.""" raise NotImplementedError() - def async_alarm_trigger(self, code=None): - """Send alarm trigger command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job(self.alarm_trigger, code) + async def async_alarm_trigger(self, code=None): + """Send alarm trigger command.""" + await self.hass.async_add_executor_job(self.alarm_trigger, code) def alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command.""" raise NotImplementedError() - def async_alarm_arm_custom_bypass(self, code=None): - """Send arm custom bypass command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) + async def async_alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command.""" + await self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) @property @abstractmethod diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index 833156e98b2ced..a990de9bf988fc 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -118,11 +118,12 @@ def setup(hass, config): conf = config.get(DOMAIN) restart = False - device = conf.get(CONF_DEVICE) - display = conf.get(CONF_PANEL_DISPLAY) + device = conf[CONF_DEVICE] + display = conf[CONF_PANEL_DISPLAY] + auto_bypass = conf[CONF_AUTO_BYPASS] zones = conf.get(CONF_ZONES) - device_type = device.get(CONF_DEVICE_TYPE) + device_type = device[CONF_DEVICE_TYPE] host = DEFAULT_DEVICE_HOST port = DEFAULT_DEVICE_PORT path = DEFAULT_DEVICE_PATH @@ -204,7 +205,9 @@ def handle_rel_message(sender, message): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - load_platform(hass, "alarm_control_panel", DOMAIN, conf, config) + load_platform( + hass, "alarm_control_panel", DOMAIN, {CONF_AUTO_BYPASS: auto_bypass}, config + ) if zones: load_platform(hass, "binary_sensor", DOMAIN, {CONF_ZONES: zones}, config) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 70f3e67e15b0aa..e217bcb6cf9294 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -21,7 +21,7 @@ ) import homeassistant.helpers.config_validation as cv -from . import DATA_AD, DOMAIN, SIGNAL_PANEL_MESSAGE +from . import CONF_AUTO_BYPASS, DATA_AD, DOMAIN, SIGNAL_PANEL_MESSAGE _LOGGER = logging.getLogger(__name__) @@ -35,13 +35,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up for AlarmDecoder alarm panels.""" - device = AlarmDecoderAlarmPanel(discovery_info["autobypass"]) - add_entities([device]) + if discovery_info is None: + return + + auto_bypass = discovery_info[CONF_AUTO_BYPASS] + entity = AlarmDecoderAlarmPanel(auto_bypass) + add_entities([entity]) def alarm_toggle_chime_handler(service): """Register toggle chime handler.""" code = service.data.get(ATTR_CODE) - device.alarm_toggle_chime(code) + entity.alarm_toggle_chime(code) hass.services.register( DOMAIN, @@ -53,7 +57,7 @@ def alarm_toggle_chime_handler(service): def alarm_keypress_handler(service): """Register keypress handler.""" keypress = service.data[ATTR_KEYPRESS] - device.alarm_keypress(keypress) + entity.alarm_keypress(keypress) hass.services.register( DOMAIN, diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index dc3f16b7d22487..13a7913e190817 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -138,7 +138,7 @@ def _fault_callback(self, zone): def _restore_callback(self, zone): """Update the zone's state, if needed.""" - if zone is None or int(zone) == self._zone_number: + if zone is None or (int(zone) == self._zone_number and not self._loop): self._state = 0 self.schedule_update_ha_state() diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index fd0e79cef8aed2..f146f6f4a7e0ef 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -2,7 +2,9 @@ "domain": "alarmdecoder", "name": "AlarmDecoder", "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", - "requirements": ["alarmdecoder==1.13.9"], + "requirements": [ + "alarmdecoder==1.13.2" + ], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 3a473b17f170d0..9aa3c62e76c9f2 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -31,7 +31,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "alert" -ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_CAN_ACK = "can_acknowledge" CONF_NOTIFIERS = "notifiers" @@ -200,7 +199,7 @@ def __init__( self._ack = False self._cancel = None self._send_done_message = False - self.entity_id = ENTITY_ID_FORMAT.format(entity_id) + self.entity_id = f"{DOMAIN}.{entity_id}" event.async_track_state_change( hass, watched_entity_id, self.watched_entity_change diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 080a8c39147d9f..8b38fe4d298f06 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1,5 +1,6 @@ """Alexa capabilities.""" import logging +import math from homeassistant.components import ( cover, @@ -10,6 +11,11 @@ vacuum, ) from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) import homeassistant.components.climate.const as climate import homeassistant.components.media_player.const as media_player from homeassistant.const import ( @@ -221,7 +227,6 @@ def serialize_properties(self): """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop["name"] - # pylint: disable=assignment-from-no-return prop_value = self.get_property(prop_name) if prop_value is not None: result = { @@ -640,6 +645,43 @@ def name(self): """Return the Alexa API name of this interface.""" return "Alexa.Speaker" + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [{"name": "volume"}] + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.SUPPORT_VOLUME_MUTE: + properties.append({"name": "muted"}) + + return properties + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name == "volume": + current_level = self.entity.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL + ) + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + return current + + if name == "muted": + return bool( + self.entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED) + ) + + return None + class AlexaStepSpeaker(AlexaCapability): """Implements Alexa.StepSpeaker. @@ -706,6 +748,13 @@ def inputs(self): source_list = self.entity.attributes.get( media_player.ATTR_INPUT_SOURCE_LIST, [] ) + input_list = AlexaInputController.get_valid_inputs(source_list) + + return input_list + + @staticmethod + def get_valid_inputs(source_list): + """Return list of supported inputs.""" input_list = [] for source in source_list: formatted_source = ( @@ -1082,10 +1131,23 @@ def get_property(self, name): def configuration(self): """Return configuration object with supported authorization types.""" code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) + supported = self.entity.attributes[ATTR_SUPPORTED_FEATURES] + configuration = {} + + supported_arm_states = [{"value": "DISARMED"}] + if supported & SUPPORT_ALARM_ARM_AWAY: + supported_arm_states.append({"value": "ARMED_AWAY"}) + if supported & SUPPORT_ALARM_ARM_HOME: + supported_arm_states.append({"value": "ARMED_STAY"}) + if supported & SUPPORT_ALARM_ARM_NIGHT: + supported_arm_states.append({"value": "ARMED_NIGHT"}) + + configuration["supportedArmStates"] = supported_arm_states if code_format == FORMAT_NUMBER: - return {"supportedAuthorizationTypes": [{"type": "FOUR_DIGIT_PIN"}]} - return None + configuration["supportedAuthorizationTypes"] = [{"type": "FOUR_DIGIT_PIN"}] + + return configuration class AlexaModeController(AlexaCapability): @@ -1308,14 +1370,20 @@ def get_property(self, name): if name != "rangeValue": raise UnsupportedProperty(name) + # Return None for unavailable and unknown states. + # Allows the Alexa.EndpointHealth Interface to handle the unavailable state in a stateReport. + if self.entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + # Fan Speed if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes[fan.ATTR_SPEED_LIST] - speed = self.entity.attributes[fan.ATTR_SPEED] - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index + speed_list = self.entity.attributes.get(fan.ATTR_SPEED_LIST) + speed = self.entity.attributes.get(fan.ATTR_SPEED) + if speed_list is not None and speed is not None: + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": @@ -1331,12 +1399,13 @@ def get_property(self, name): # Vacuum Fan Speed if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": - speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] - speed = self.entity.attributes[vacuum.ATTR_FAN_SPEED] - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index + speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST) + speed = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED) + if speed_list is not None and speed is not None: + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index return None @@ -1361,12 +1430,16 @@ def capability_resources(self): precision=1, ) for index, speed in enumerate(speed_list): - labels = [speed.replace("_", " ")] + labels = [] + if isinstance(speed, str): + labels.append(speed.replace("_", " ")) if index == 1: labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) if index == max_value: labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) - self._resource.add_preset(value=index, labels=labels) + + if len(labels) > 0: + self._resource.add_preset(value=index, labels=labels) return self._resource.serialize_capability_resources() diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index bd579dc4dadaf3..7d3a3994acef5c 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -53,7 +53,7 @@ async def async_enable_proactive_mode(self): ) try: await self._unsub_proactive_report - except Exception: # pylint: disable=broad-except + except Exception: self._unsub_proactive_report = None raise diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 6b8319861926d6..b10f11e2bbcadb 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -400,6 +400,7 @@ def default_display_categories(self): def interfaces(self): """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & cover.SUPPORT_SET_POSITION: yield AlexaRangeController( @@ -507,12 +508,7 @@ def interfaces(self): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & media_player.const.SUPPORT_VOLUME_SET: yield AlexaSpeaker(self.entity) - - step_volume_features = ( - media_player.const.SUPPORT_VOLUME_MUTE - | media_player.const.SUPPORT_VOLUME_STEP - ) - if supported & step_volume_features: + elif supported & media_player.const.SUPPORT_VOLUME_STEP: yield AlexaStepSpeaker(self.entity) playback_features = ( @@ -530,7 +526,13 @@ def interfaces(self): yield AlexaSeekController(self.entity) if supported & media_player.SUPPORT_SELECT_SOURCE: - yield AlexaInputController(self.entity) + inputs = AlexaInputController.get_valid_inputs( + self.entity.attributes.get( + media_player.const.ATTR_INPUT_SOURCE_LIST, [] + ) + ) + if len(inputs) > 0: + yield AlexaInputController(self.entity) if supported & media_player.const.SUPPORT_PLAY_MEDIA: yield AlexaChannelController(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8bd52b1e40bc14..b771a8fc50c249 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -119,7 +119,9 @@ async def async_api_turn_on(hass, config, directive, context): domain = ha.DOMAIN service = SERVICE_TURN_ON - if domain == media_player.DOMAIN: + if domain == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + elif domain == media_player.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF if not supported & power_features: @@ -145,7 +147,9 @@ async def async_api_turn_off(hass, config, directive, context): domain = ha.DOMAIN service = SERVICE_TURN_OFF - if domain == media_player.DOMAIN: + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + elif domain == media_player.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF if not supported & power_features: @@ -474,8 +478,8 @@ async def async_api_select_input(hass, config, directive, context): media_input = source break else: - msg = "failed to map input {} to a media source on {}".format( - media_input, entity.entity_id + msg = ( + f"failed to map input {media_input} to a media source on {entity.entity_id}" ) raise AlexaInvalidValueError(msg) @@ -908,8 +912,11 @@ async def async_api_arm(hass, config, directive, context): entity.domain, service, data, blocking=False, context=context ) + # return 0 until alarm integration supports an exit delay + payload = {"exitDelayInSeconds": 0} + response = directive.response( - name="Arm.Response", namespace="Alexa.SecurityPanelController" + name="Arm.Response", namespace="Alexa.SecurityPanelController", payload=payload ) response.add_context_property( @@ -928,6 +935,12 @@ async def async_api_disarm(hass, config, directive, context): """Process a Security Panel Disarm request.""" entity = directive.entity data = {ATTR_ENTITY_ID: entity.entity_id} + response = directive.response() + + # Per Alexa Documentation: If you receive a Disarm directive, and the system is already disarmed, + # respond with a success response, not an error response. + if entity.state == STATE_ALARM_DISARMED: + return response payload = directive.payload if "authorization" in payload: @@ -941,7 +954,6 @@ async def async_api_disarm(hass, config, directive, context): msg = "Invalid Code" raise AlexaSecurityPanelUnauthorizedError(msg) - response = directive.response() response.add_context_property( { "name": "armState", @@ -1129,7 +1141,7 @@ async def async_api_set_range(hass, config, directive, context): service = cover.SERVICE_OPEN_COVER_TILT else: service = cover.SERVICE_SET_COVER_TILT_POSITION - data[cover.ATTR_POSITION] = range_value + data[cover.ATTR_TILT_POSITION] = range_value # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": @@ -1183,6 +1195,7 @@ async def async_api_adjust_range(hass, config, directive, context): service = None data = {ATTR_ENTITY_ID: entity.entity_id} range_delta = directive.payload["rangeValueDelta"] + range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) response_value = 0 # Fan Speed @@ -1208,9 +1221,12 @@ async def async_api_adjust_range(hass, config, directive, context): # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": - range_delta = int(range_delta) + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) service = SERVICE_SET_COVER_POSITION current = entity.attributes.get(cover.ATTR_POSITION) + if not current: + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) position = response_value = min(100, max(0, range_delta + current)) if position == 100: service = cover.SERVICE_OPEN_COVER @@ -1221,9 +1237,12 @@ async def async_api_adjust_range(hass, config, directive, context): # Cover Tilt elif instance == f"{cover.DOMAIN}.tilt": - range_delta = int(range_delta) + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) service = SERVICE_SET_COVER_TILT_POSITION current = entity.attributes.get(cover.ATTR_TILT_POSITION) + if not current: + msg = f"Unable to determine {entity.entity_id} current tilt position" + raise AlexaInvalidValueError(msg) tilt_position = response_value = min(100, max(0, range_delta + current)) if tilt_position == 100: service = cover.SERVICE_OPEN_COVER_TILT @@ -1418,9 +1437,7 @@ async def async_api_set_eq_mode(hass, config, directive, context): if sound_mode_list and mode.lower() in sound_mode_list: data[media_player.const.ATTR_SOUND_MODE] = mode.lower() else: - msg = "failed to map sound mode {} to a mode on {}".format( - mode, entity.entity_id - ) + msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" raise AlexaInvalidValueError(msg) await hass.services.async_call( diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py index cb78f269f8f3fc..4dd154ea11f2e3 100644 --- a/homeassistant/components/alexa/messages.py +++ b/homeassistant/components/alexa/messages.py @@ -43,7 +43,7 @@ def load_entity(self, hass, config): Behavior when self.has_endpoint is False is undefined. Will raise AlexaInvalidEndpointError if the endpoint in the request is - malformed or nonexistant. + malformed or nonexistent. """ _endpoint_id = self._directive[API_ENDPOINT]["endpointId"] self.entity_id = _endpoint_id.replace("#", ".") diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 44e1b7f4f554a7..b595bc98589b4c 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -26,6 +26,9 @@ async def async_enable_proactive_mode(hass, smart_home_config): await smart_home_config.async_get_access_token() async def async_entity_state_listener(changed_entity, old_state, new_state): + if not hass.is_running: + return + if not new_state: return diff --git a/homeassistant/components/almond/.translations/ca.json b/homeassistant/components/almond/.translations/ca.json index 6f7df11477401b..5cedcfef481cfe 100644 --- a/homeassistant/components/almond/.translations/ca.json +++ b/homeassistant/components/almond/.translations/ca.json @@ -7,6 +7,7 @@ }, "step": { "hassio_confirm": { + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb Almond proporcionat pel complement de Hass.io: {addon}?", "title": "Almond (complement de Hass.io)" }, "pick_implementation": { diff --git a/homeassistant/components/almond/.translations/cs.json b/homeassistant/components/almond/.translations/cs.json new file mode 100644 index 00000000000000..f103fcc2727747 --- /dev/null +++ b/homeassistant/components/almond/.translations/cs.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k Almond pomoc\u00ed hass.io {addon}?", + "title": "Almond prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/de.json b/homeassistant/components/almond/.translations/de.json index b4e5f168f7ca0b..89021793f94e7d 100644 --- a/homeassistant/components/almond/.translations/de.json +++ b/homeassistant/components/almond/.translations/de.json @@ -6,6 +6,10 @@ "missing_configuration": "Bitte \u00fcberpr\u00fcfe die Dokumentation zur Einrichtung von Almond." }, "step": { + "hassio_confirm": { + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit Almond als Hass.io-Add-On hergestellt wird: {addon}?", + "title": "Almond \u00fcber das Hass.io Add-on" + }, "pick_implementation": { "title": "W\u00e4hle die Authentifizierungsmethode" } diff --git a/homeassistant/components/almond/.translations/es.json b/homeassistant/components/almond/.translations/es.json index 26eacb834b0ca7..41e1fad412663b 100644 --- a/homeassistant/components/almond/.translations/es.json +++ b/homeassistant/components/almond/.translations/es.json @@ -6,6 +6,10 @@ "missing_configuration": "Consulte la documentaci\u00f3n sobre c\u00f3mo configurar Almond." }, "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a Almond proporcionado por el complemento Hass.io: {addon} ?", + "title": "Almond a trav\u00e9s del complemento Hass.io" + }, "pick_implementation": { "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" } diff --git a/homeassistant/components/almond/.translations/hu.json b/homeassistant/components/almond/.translations/hu.json new file mode 100644 index 00000000000000..2331e57c6eb33a --- /dev/null +++ b/homeassistant/components/almond/.translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_setup": "Csak egy Almond fi\u00f3kot konfigur\u00e1lhat.", + "cannot_connect": "Nem lehet csatlakozni az Almond szerverhez.", + "missing_configuration": "K\u00e9rj\u00fck, ellen\u0151rizze az Almond be\u00e1ll\u00edt\u00e1s\u00e1nak dokument\u00e1ci\u00f3j\u00e1t." + }, + "step": { + "hassio_confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant alkalmaz\u00e1st az Almondhoz val\u00f3 csatlakoz\u00e1shoz, amelyet a Hass.io kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", + "title": "Almond a Hass.io kieg\u00e9sz\u00edt\u0151n kereszt\u00fcl" + }, + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9si m\u00f3dszert" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/.translations/nl.json b/homeassistant/components/almond/.translations/nl.json index d77fe69f7fa8eb..939a9a904ad241 100644 --- a/homeassistant/components/almond/.translations/nl.json +++ b/homeassistant/components/almond/.translations/nl.json @@ -6,6 +6,10 @@ "missing_configuration": "Raadpleeg de documentatie over het instellen van Almond." }, "step": { + "hassio_confirm": { + "description": "Wilt u Home Assistant configureren om verbinding te maken met Almond die wordt aangeboden door de hass.io add-on {addon} ?", + "title": "Almond via Hass.io add-on" + }, "pick_implementation": { "title": "Kies de authenticatie methode" } diff --git a/homeassistant/components/almond/.translations/pl.json b/homeassistant/components/almond/.translations/pl.json index 201905255a7048..dc5717539a630d 100644 --- a/homeassistant/components/almond/.translations/pl.json +++ b/homeassistant/components/almond/.translations/pl.json @@ -7,8 +7,8 @@ }, "step": { "hassio_confirm": { - "description": "Czy chcesz skonfigurowa\u0107 Home Assistant, aby \u0142\u0105czy\u0142 si\u0119 z Almond dostarczonym przez dodatek Hass.io: {addon} ?", - "title": "Almond przez dodatek Hass.io" + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby \u0142\u0105czy\u0142 si\u0119 z Almond dostarczonym przez dodatek Hass.io: {addon}?", + "title": "Almond poprzez dodatek Hass.io" }, "pick_implementation": { "title": "Wybierz metod\u0119 uwierzytelniania" diff --git a/homeassistant/components/almond/.translations/sl.json b/homeassistant/components/almond/.translations/sl.json index 086190590ac88c..4a593cc56059e6 100644 --- a/homeassistant/components/almond/.translations/sl.json +++ b/homeassistant/components/almond/.translations/sl.json @@ -6,6 +6,10 @@ "missing_configuration": "Prosimo, preverite dokumentacijo o tem, kako nastaviti Almond." }, "step": { + "hassio_confirm": { + "description": "Ali \u017eelite konfigurirati Home Assistant za povezavo z Almondom, ki ga ponuja dodatek Hass.io: {addon} ?", + "title": "Almond prek dodatka Hass.io" + }, "pick_implementation": { "title": "Izberite na\u010din preverjanja pristnosti" } diff --git a/homeassistant/components/almond/.translations/sv.json b/homeassistant/components/almond/.translations/sv.json new file mode 100644 index 00000000000000..d2630b95c02b9b --- /dev/null +++ b/homeassistant/components/almond/.translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bara konfigurera ett Almond-konto.", + "cannot_connect": "Det g\u00e5r inte att ansluta till Almond-servern.", + "missing_configuration": "Kontrollera dokumentationen f\u00f6r hur du st\u00e4ller in Almond." + }, + "step": { + "hassio_confirm": { + "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till Almond som tillhandah\u00e5lls av Hass.io-till\u00e4gget: {addon} ?", + "title": "Almond via Hass.io-till\u00e4gget" + }, + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + } + }, + "title": "Almond" + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index 42f9318a06f76b..b1eb506270b862 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -87,7 +87,6 @@ async def async_step_import(self, user_input: dict = None) -> dict: ) return self.async_abort(reason="cannot_connect") - # pylint: disable=invalid-name self.CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL return self.async_create_entry( diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json index 33348c9d7b36fb..c7220d8e059660 100644 --- a/homeassistant/components/alpha_vantage/manifest.json +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -2,7 +2,7 @@ "domain": "alpha_vantage", "name": "Alpha Vantage", "documentation": "https://www.home-assistant.io/integrations/alpha_vantage", - "requirements": ["alpha_vantage==2.1.2"], + "requirements": ["alpha_vantage==2.1.3"], "dependencies": [], "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant/components/ambient_station/.translations/ca.json index d3c451f3e3ff81..280a90354b057e 100644 --- a/homeassistant/components/ambient_station/.translations/ca.json +++ b/homeassistant/components/ambient_station/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquesta clau d'aplicaci\u00f3 ja est\u00e0 en \u00fas." + }, "error": { "identifier_exists": "Clau d'aplicaci\u00f3 i/o clau API ja registrada", "invalid_key": "Clau API i/o clau d'aplicaci\u00f3 inv\u00e0lida/es", diff --git a/homeassistant/components/ambient_station/.translations/da.json b/homeassistant/components/ambient_station/.translations/da.json index 6cec31eca29a53..6428508687df08 100644 --- a/homeassistant/components/ambient_station/.translations/da.json +++ b/homeassistant/components/ambient_station/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne appn\u00f8gle er allerede i brug." + }, "error": { "identifier_exists": "Applikationsn\u00f8gle og/eller API n\u00f8gle er allerede registreret", "invalid_key": "Ugyldig API n\u00f8gle og/eller applikationsn\u00f8gle", diff --git a/homeassistant/components/ambient_station/.translations/de.json b/homeassistant/components/ambient_station/.translations/de.json index 1431efbf167b29..451a2e70e68df2 100644 --- a/homeassistant/components/ambient_station/.translations/de.json +++ b/homeassistant/components/ambient_station/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser App-Schl\u00fcssel wird bereits verwendet." + }, "error": { "identifier_exists": "Anwendungsschl\u00fcssel und / oder API-Schl\u00fcssel bereits registriert", "invalid_key": "Ung\u00fcltiger API Key und / oder Anwendungsschl\u00fcssel", diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json index 5bd643da55cfa3..8b8e71d5316c3b 100644 --- a/homeassistant/components/ambient_station/.translations/en.json +++ b/homeassistant/components/ambient_station/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This app key is already in use." + }, "error": { "identifier_exists": "Application Key and/or API Key already registered", "invalid_key": "Invalid API Key and/or Application Key", diff --git a/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant/components/ambient_station/.translations/ko.json index eb9209a6c3781d..3379411678ba4f 100644 --- a/homeassistant/components/ambient_station/.translations/ko.json +++ b/homeassistant/components/ambient_station/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 \uc571 \ud0a4\ub294 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_key": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", diff --git a/homeassistant/components/ambient_station/.translations/no.json b/homeassistant/components/ambient_station/.translations/no.json index 0b9d377718ba29..4a089eba4c0645 100644 --- a/homeassistant/components/ambient_station/.translations/no.json +++ b/homeassistant/components/ambient_station/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne app n\u00f8kkelen er allerede i bruk." + }, "error": { "identifier_exists": "Programn\u00f8kkel og/eller API-n\u00f8kkel er allerede registrert", "invalid_key": "Ugyldig API-n\u00f8kkel og/eller programn\u00f8kkel", diff --git a/homeassistant/components/ambient_station/.translations/pl.json b/homeassistant/components/ambient_station/.translations/pl.json index 6ebd0848a632fe..5da886f05cd8c0 100644 --- a/homeassistant/components/ambient_station/.translations/pl.json +++ b/homeassistant/components/ambient_station/.translations/pl.json @@ -1,7 +1,10 @@ { "config": { + "abort": { + "already_configured": "Ten klucz aplikacji jest ju\u017c w u\u017cyciu." + }, "error": { - "identifier_exists": "Klucz aplikacji i/lub klucz API ju\u017c jest zarejestrowany", + "identifier_exists": "Klucz aplikacji i/lub klucz API ju\u017c jest zarejestrowany.", "invalid_key": "Nieprawid\u0142owy klucz API i/lub klucz aplikacji", "no_devices": "Nie znaleziono urz\u0105dze\u0144 na koncie" }, diff --git a/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant/components/ambient_station/.translations/zh-Hant.json index 6c7c88a804576f..6de1579f6ffae1 100644 --- a/homeassistant/components/ambient_station/.translations/zh-Hant.json +++ b/homeassistant/components/ambient_station/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64\u61c9\u7528\u7a0b\u5f0f\u5bc6\u9470\u5df2\u88ab\u4f7f\u7528\u3002" + }, "error": { "identifier_exists": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u5df2\u8a3b\u518a", "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548", diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index c61e15dfeb5731..63c00b05038d2d 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -10,8 +10,10 @@ from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, + CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, + SPEED_MILES_PER_HOUR, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -23,14 +25,12 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later -from .config_flow import configured_instances from .const import ( ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, CONF_APP_KEY, DATA_CLIENT, DOMAIN, - TOPIC_UPDATE, TYPE_BINARY_SENSOR, TYPE_SENSOR, ) @@ -148,7 +148,7 @@ TYPE_BATT8: ("Battery 8", None, TYPE_BINARY_SENSOR, "battery"), TYPE_BATT9: ("Battery 9", None, TYPE_BINARY_SENSOR, "battery"), TYPE_BATTOUT: ("Battery", None, TYPE_BINARY_SENSOR, "battery"), - TYPE_CO2: ("co2", "ppm", TYPE_SENSOR, None), + TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, TYPE_SENSOR, None), TYPE_DAILYRAININ: ("Daily Rain", "in", TYPE_SENSOR, None), TYPE_DEWPOINT: ("Dew Point", "°F", TYPE_SENSOR, "temperature"), TYPE_EVENTRAININ: ("Event Rain", "in", TYPE_SENSOR, None), @@ -167,7 +167,7 @@ TYPE_HUMIDITY: ("Humidity", "%", TYPE_SENSOR, "humidity"), TYPE_HUMIDITYIN: ("Humidity In", "%", TYPE_SENSOR, "humidity"), TYPE_LASTRAIN: ("Last Rain", None, TYPE_SENSOR, "timestamp"), - TYPE_MAXDAILYGUST: ("Max Gust", "mph", TYPE_SENSOR, None), + TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_MONTHLYRAININ: ("Monthly Rain", "in", TYPE_SENSOR, None), TYPE_RELAY10: ("Relay 10", None, TYPE_BINARY_SENSOR, "connectivity"), TYPE_RELAY1: ("Relay 1", None, TYPE_BINARY_SENSOR, "connectivity"), @@ -218,12 +218,12 @@ TYPE_WEEKLYRAININ: ("Weekly Rain", "in", TYPE_SENSOR, None), TYPE_WINDDIR: ("Wind Dir", "°", TYPE_SENSOR, None), TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", "°", TYPE_SENSOR, None), - TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", "mph", TYPE_SENSOR, None), + TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_WINDGUSTDIR: ("Gust Dir", "°", TYPE_SENSOR, None), - TYPE_WINDGUSTMPH: ("Wind Gust", "mph", TYPE_SENSOR, None), - TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", "mph", TYPE_SENSOR, None), - TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", "mph", TYPE_SENSOR, None), - TYPE_WINDSPEEDMPH: ("Wind Speed", "mph", TYPE_SENSOR, None), + TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_YEARLYRAININ: ("Yearly Rain", "in", TYPE_SENSOR, None), } @@ -253,9 +253,6 @@ async def async_setup(hass, config): # Store config for use during entry setup: hass.data[DOMAIN][DATA_CONFIG] = conf - if conf[CONF_APP_KEY] in configured_instances(hass): - return True - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -269,6 +266,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up the Ambient PWS as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_APP_KEY] + ) + session = aiohttp_client.async_get_clientsession(hass) try: @@ -378,7 +380,9 @@ def on_data(data): if data != self.stations[mac_address][ATTR_LAST_DATA]: _LOGGER.debug("New data received: %s", data) self.stations[mac_address][ATTR_LAST_DATA] = data - async_dispatcher_send(self._hass, TOPIC_UPDATE) + async_dispatcher_send( + self._hass, f"ambient_station_data_update_{mac_address}" + ) _LOGGER.debug("Resetting watchdog") self._watchdog_listener() @@ -518,7 +522,7 @@ def update(): self.async_schedule_update_ha_state(True) self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, update + self.hass, f"ambient_station_data_update_{self._mac_address}", update ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index c20b43598ca4f8..c363a2839fbf99 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -5,35 +5,29 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import CONF_APP_KEY, DOMAIN +from .const import CONF_APP_KEY, DOMAIN # pylint: disable=unused-import -@callback -def configured_instances(hass): - """Return a set of configured Ambient PWS instances.""" - return set( - entry.data[CONF_APP_KEY] for entry in hass.config_entries.async_entries(DOMAIN) - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class AmbientStationFlowHandler(config_entries.ConfigFlow): +class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Ambient PWS config flow.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - async def _show_form(self, errors=None): - """Show the form to the user.""" - data_schema = vol.Schema( + def __init__(self): + """Initialize the config flow.""" + self.data_schema = vol.Schema( {vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str} ) + async def _show_form(self, errors=None): + """Show the form to the user.""" return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors if errors else {} + step_id="user", + data_schema=self.data_schema, + errors=errors if errors else {}, ) async def async_step_import(self, import_config): @@ -42,12 +36,11 @@ async def async_step_import(self, import_config): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_APP_KEY] in configured_instances(self.hass): - return await self._show_form({CONF_APP_KEY: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_APP_KEY]) + self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) client = Client(user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py index 21a6e514b3073e..3b1990ae837497 100644 --- a/homeassistant/components/ambient_station/const.py +++ b/homeassistant/components/ambient_station/const.py @@ -8,7 +8,5 @@ DATA_CLIENT = "data_client" -TOPIC_UPDATE = "update" - TYPE_BINARY_SENSOR = "binary_sensor" TYPE_SENSOR = "sensor" diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 25f60f63abfa52..a6572070a5e45a 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.0.2"], + "requirements": ["aioambient==1.0.4"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json index 657b3477bb2259..3cfe36b3220923 100644 --- a/homeassistant/components/ambient_station/strings.json +++ b/homeassistant/components/ambient_station/strings.json @@ -11,9 +11,11 @@ } }, "error": { - "identifier_exists": "Application Key and/or API Key already registered", "invalid_key": "Invalid API Key and/or Application Key", "no_devices": "No devices found in account" + }, + "abort": { + "already_configured": "This app key is already in use." } } } diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 63daeb047315c3..b4b3e1866b47d5 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -23,6 +23,7 @@ CONF_SENSORS, CONF_USERNAME, ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, HTTP_BASIC_AUTHENTICATION, ) from homeassistant.exceptions import Unauthorized, UnknownUser @@ -34,7 +35,15 @@ from .binary_sensor import BINARY_SENSORS from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST -from .const import CAMERAS, DATA_AMCREST, DEVICES, DOMAIN, SERVICE_UPDATE +from .const import ( + CAMERAS, + COMM_RETRIES, + COMM_TIMEOUT, + DATA_AMCREST, + DEVICES, + DOMAIN, + SERVICE_UPDATE, +) from .helpers import service_signal from .sensor import SENSORS @@ -100,7 +109,6 @@ def _has_unique_names(devices): ) -# pylint: disable=too-many-ancestors class AmcrestChecker(Http): """amcrest.Http wrapper for catching errors.""" @@ -110,38 +118,56 @@ def __init__(self, hass, name, host, port, user, password): self._wrap_name = name self._wrap_errors = 0 self._wrap_lock = threading.Lock() + self._wrap_login_err = False self._unsub_recheck = None super().__init__( - host, port, user, password, retries_connection=1, timeout_protocol=3.05 + host, + port, + user, + password, + retries_connection=COMM_RETRIES, + timeout_protocol=COMM_TIMEOUT, ) @property def available(self): """Return if camera's API is responding.""" - return self._wrap_errors <= MAX_ERRORS + return self._wrap_errors <= MAX_ERRORS and not self._wrap_login_err + + def _start_recovery(self): + dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name)) + self._unsub_recheck = track_time_interval( + self._hass, self._wrap_test_online, RECHECK_INTERVAL + ) def command(self, cmd, retries=None, timeout_cmd=None, stream=False): """amcrest.Http.command wrapper to catch errors.""" try: ret = super().command(cmd, retries, timeout_cmd, stream) + except LoginError as ex: + with self._wrap_lock: + was_online = self.available + was_login_err = self._wrap_login_err + self._wrap_login_err = True + if not was_login_err: + _LOGGER.error("%s camera offline: Login error: %s", self._wrap_name, ex) + if was_online: + self._start_recovery() + raise except AmcrestError: with self._wrap_lock: was_online = self.available - self._wrap_errors += 1 - _LOGGER.debug("%s camera errs: %i", self._wrap_name, self._wrap_errors) + errs = self._wrap_errors = self._wrap_errors + 1 offline = not self.available - if offline and was_online: + _LOGGER.debug("%s camera errs: %i", self._wrap_name, errs) + if was_online and offline: _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) - dispatcher_send( - self._hass, service_signal(SERVICE_UPDATE, self._wrap_name) - ) - self._unsub_recheck = track_time_interval( - self._hass, self._wrap_test_online, RECHECK_INTERVAL - ) + self._start_recovery() raise with self._wrap_lock: was_offline = not self.available self._wrap_errors = 0 + self._wrap_login_err = False if was_offline: self._unsub_recheck() self._unsub_recheck = None @@ -151,6 +177,7 @@ def command(self, cmd, retries=None, timeout_cmd=None, stream=False): def _wrap_test_online(self, now): """Test if camera is back online.""" + _LOGGER.debug("Testing if %s back online", self._wrap_name) try: self.current_time except AmcrestError: @@ -166,14 +193,9 @@ def setup(hass, config): username = device[CONF_USERNAME] password = device[CONF_PASSWORD] - try: - api = AmcrestChecker( - hass, name, device[CONF_HOST], device[CONF_PORT], username, password - ) - - except LoginError as ex: - _LOGGER.error("Login error for %s camera: %s", name, ex) - continue + api = AmcrestChecker( + hass, name, device[CONF_HOST], device[CONF_PORT], username, password + ) ffmpeg_arguments = device[CONF_FFMPEG_ARGUMENTS] resolution = RESOLUTION_LIST[device[CONF_RESOLUTION]] @@ -236,6 +258,9 @@ async def async_extract_from_service(call): if have_permission(user, entity_id) ] + if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: + return [] + call_ids = await async_extract_entity_ids(hass, call) entity_ids = [] for entity_id in hass.data[DATA_AMCREST][CAMERAS]: @@ -256,7 +281,7 @@ async def async_service_handler(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]) + hass.services.register(DOMAIN, service, async_service_handler, params[0]) return True diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index ac16f0664aa43c..809b448876c9fd 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,4 +1,4 @@ -"""Suppoort for Amcrest IP camera binary sensors.""" +"""Support for Amcrest IP camera binary sensors.""" from datetime import timedelta import logging @@ -54,7 +54,7 @@ class AmcrestBinarySensor(BinarySensorDevice): def __init__(self, name, device, sensor_type): """Initialize entity.""" - self._name = "{} {}".format(name, BINARY_SENSORS[sensor_type][0]) + self._name = f"{name} {BINARY_SENSORS[sensor_type][0]}" self._signal_name = name self._api = device.api self._sensor_type = sensor_type diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index e9e1e2b5f84856..f951525640326d 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,11 +1,11 @@ """Support for Amcrest IP cameras.""" import asyncio from datetime import timedelta +from functools import partial import logging from amcrest import AmcrestError from haffmpeg.camera import CameraMjpeg -from urllib3.exceptions import HTTPError import voluptuous as vol from homeassistant.components.camera import ( @@ -26,9 +26,11 @@ from .const import ( CAMERA_WEB_SESSION_TIMEOUT, CAMERAS, + COMM_TIMEOUT, DATA_AMCREST, DEVICES, SERVICE_UPDATE, + SNAPSHOT_TIMEOUT, ) from .helpers import log_update_error, service_signal @@ -90,6 +92,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities([AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) +class CannotSnapshot(Exception): + """Conditions are not valid for taking a snapshot.""" + + class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" @@ -112,28 +118,58 @@ def __init__(self, name, device, ffmpeg): self._motion_recording_enabled = None self._color_bw = None self._rtsp_url = None - self._snapshot_lock = asyncio.Lock() + self._snapshot_task = None self._unsub_dispatcher = [] self._update_succeeded = False - async def async_camera_image(self): - """Return a still image response from the camera.""" + def _check_snapshot_ok(self): available = self.available if not available or not self.is_on: _LOGGER.warning( - "Attempt to take snaphot when %s camera is %s", + "Attempt to take snapshot when %s camera is %s", self.name, "offline" if not available else "off", ) + raise CannotSnapshot + + async def _async_get_image(self): + try: + # Send the request to snap a picture and return raw jpg data + # Snapshot command needs a much longer read timeout than other commands. + return await self.hass.async_add_executor_job( + partial( + self._api.snapshot, + timeout=(COMM_TIMEOUT, SNAPSHOT_TIMEOUT), + stream=False, + ) + ) + except AmcrestError as error: + log_update_error(_LOGGER, "get image from", self.name, "camera", error) + return None + finally: + self._snapshot_task = None + + async def async_camera_image(self): + """Return a still image response from the camera.""" + _LOGGER.debug("Take snapshot from %s", self._name) + try: + # Amcrest cameras only support one snapshot command at a time. + # Hence need to wait if a previous snapshot has not yet finished. + # Also need to check that camera is online and turned on before each wait + # and before initiating shapshot. + while self._snapshot_task: + self._check_snapshot_ok() + _LOGGER.debug("Waiting for previous snapshot from %s ...", self._name) + await self._snapshot_task + self._check_snapshot_ok() + # Run snapshot command in separate Task that can't be cancelled so + # 1) it's not possible to send another snapshot command while camera is + # still working on a previous one, and + # 2) someone will be around to catch any exceptions. + self._snapshot_task = self.hass.async_create_task(self._async_get_image()) + return await asyncio.shield(self._snapshot_task) + except CannotSnapshot: return None - async with self._snapshot_lock: - try: - # Send the request to snap a picture and return raw jpg data - response = await self.hass.async_add_executor_job(self._api.snapshot) - return response.data - except (AmcrestError, HTTPError) as error: - log_update_error(_LOGGER, "get image from", self.name, "camera", error) - return None async def handle_async_mjpeg_stream(self, request): """Return an MJPEG stream.""" @@ -455,9 +491,7 @@ def _enable_light(self, enable): """Enable or disable indicator light.""" try: self._api.command( - "configManager.cgi?action=setConfig&LightGlobal[0].Enable={}".format( - str(enable).lower() - ) + f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={str(enable).lower()}" ) except AmcrestError as error: log_update_error( diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index 98d613634b5143..38ff8a8894e8ad 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -6,6 +6,9 @@ BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 CAMERA_WEB_SESSION_TIMEOUT = 10 +COMM_RETRIES = 1 +COMM_TIMEOUT = 6.05 SENSOR_SCAN_INTERVAL_SECS = 10 +SNAPSHOT_TIMEOUT = 20 SERVICE_UPDATE = "update" diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index a40d6ace50a31d..57d1a73c97eccd 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -6,7 +6,7 @@ def service_signal(service, ident=None): """Encode service and identifier into signal.""" signal = f"{DOMAIN}_{service}" if ident: - signal += "_{}".format(ident.replace(".", "_")) + signal += f"_{ident.replace('.', '_')}" return signal diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index ee5b97b857975a..38e19e4ec26559 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -2,7 +2,7 @@ "domain": "amcrest", "name": "Amcrest", "documentation": "https://www.home-assistant.io/integrations/amcrest", - "requirements": ["amcrest==1.5.3"], + "requirements": ["amcrest==1.5.6"], "dependencies": ["ffmpeg"], - "codeowners": [] + "codeowners": ["@pnbruckner"] } diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index be03b3bedffa82..bcff1775879960 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,4 +1,4 @@ -"""Suppoort for Amcrest IP camera sensors.""" +"""Support for Amcrest IP camera sensors.""" from datetime import timedelta import logging @@ -45,7 +45,7 @@ class AmcrestSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize a sensor for Amcrest camera.""" - self._name = "{} {}".format(name, SENSORS[sensor_type][0]) + self._name = f"{name} {SENSORS[sensor_type][0]}" self._signal_name = name self._api = device.api self._sensor_type = sensor_type @@ -98,15 +98,21 @@ def update(self): elif self._sensor_type == SENSOR_SDCARD: storage = self._api.storage_all try: - self._attrs["Total"] = "{:.2f} {}".format(*storage["total"]) + self._attrs[ + "Total" + ] = f"{storage['total'][0]:.2f} {storage['total'][1]}" except ValueError: - self._attrs["Total"] = "{} {}".format(*storage["total"]) + self._attrs[ + "Total" + ] = f"{storage['total'][0]} {storage['total'][1]}" try: - self._attrs["Used"] = "{:.2f} {}".format(*storage["used"]) + self._attrs[ + "Used" + ] = f"{storage['used'][0]:.2f} {storage['used'][1]}" except ValueError: - self._attrs["Used"] = "{} {}".format(*storage["used"]) + self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}" try: - self._state = "{:.2f}".format(storage["used_percent"]) + self._state = f"{storage['used_percent']:.2f}" except ValueError: self._state = storage["used_percent"] except AmcrestError as error: diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index d81e7863503dd0..5fea6c3f2e29cf 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ "adb-shell==0.1.1", - "androidtv==0.0.38", + "androidtv==0.0.39", "pure-python-adb==0.2.2.dev0" ], "dependencies": [], diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 63b27f17bb2e78..9366695891902f 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -26,6 +26,7 @@ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( @@ -59,6 +60,7 @@ | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP ) @@ -80,6 +82,7 @@ CONF_ADB_SERVER_IP = "adb_server_ip" CONF_ADB_SERVER_PORT = "adb_server_port" CONF_APPS = "apps" +CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps" CONF_GET_SOURCES = "get_sources" CONF_STATE_DETECTION_RULES = "state_detection_rules" CONF_TURN_ON_COMMAND = "turn_on_command" @@ -132,12 +135,15 @@ vol.Optional(CONF_ADB_SERVER_IP): cv.string, vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, - vol.Optional(CONF_APPS, default=dict()): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_APPS, default=dict()): vol.Schema( + {cv.string: vol.Any(cv.string, None)} + ), vol.Optional(CONF_TURN_ON_COMMAND): cv.string, vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( {cv.string: ha_state_detection_rules_validator(vol.Invalid)} ), + vol.Optional(CONF_EXCLUDE_UNNAMED_APPS, default=False): cv.boolean, } ) @@ -230,6 +236,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config[CONF_GET_SOURCES], config.get(CONF_TURN_ON_COMMAND), config.get(CONF_TURN_OFF_COMMAND), + config[CONF_EXCLUDE_UNNAMED_APPS], ] if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: @@ -365,7 +372,14 @@ class ADBDevice(MediaPlayerDevice): """Representation of an Android TV or Fire TV device.""" def __init__( - self, aftv, name, apps, get_sources, turn_on_command, turn_off_command + self, + aftv, + name, + apps, + get_sources, + turn_on_command, + turn_off_command, + exclude_unnamed_apps, ): """Initialize the Android TV / Fire TV device.""" self.aftv = aftv @@ -373,7 +387,7 @@ def __init__( self._app_id_to_name = APPS.copy() self._app_id_to_name.update(apps) self._app_name_to_id = { - value: key for key, value in self._app_id_to_name.items() + value: key for key, value in self._app_id_to_name.items() if value } self._get_sources = get_sources self._keys = KEYS @@ -384,12 +398,15 @@ def __init__( self.turn_on_command = turn_on_command self.turn_off_command = turn_off_command + self._exclude_unnamed_apps = exclude_unnamed_apps + # ADB exceptions to catch if not self.aftv.adb_server_ip: # Using "adb_shell" (Python ADB implementation) self.exceptions = ( AttributeError, BrokenPipeError, + ConnectionResetError, TypeError, ValueError, InvalidChecksumError, @@ -558,11 +575,24 @@ class AndroidTVDevice(ADBDevice): """Representation of an Android TV device.""" def __init__( - self, aftv, name, apps, get_sources, turn_on_command, turn_off_command + self, + aftv, + name, + apps, + get_sources, + turn_on_command, + turn_off_command, + exclude_unnamed_apps, ): """Initialize the Android TV device.""" super().__init__( - aftv, name, apps, get_sources, turn_on_command, turn_off_command + aftv, + name, + apps, + get_sources, + turn_on_command, + turn_off_command, + exclude_unnamed_apps, ) self._is_volume_muted = None @@ -600,9 +630,13 @@ def update(self): self._available = False if running_apps: - self._sources = [ - self._app_id_to_name.get(app_id, app_id) for app_id in running_apps + sources = [ + self._app_id_to_name.get( + app_id, app_id if not self._exclude_unnamed_apps else None + ) + for app_id in running_apps ] + self._sources = [source for source in sources if source] else: self._sources = None @@ -631,6 +665,11 @@ def mute_volume(self, mute): """Mute the volume.""" self.aftv.mute_volume() + @adb_decorator() + def set_volume_level(self, volume): + """Set the volume level.""" + self.aftv.set_volume_level(volume) + @adb_decorator() def volume_down(self): """Send volume down command.""" @@ -670,9 +709,13 @@ def update(self): self._available = False if running_apps: - self._sources = [ - self._app_id_to_name.get(app_id, app_id) for app_id in running_apps + sources = [ + self._app_id_to_name.get( + app_id, app_id if not self._exclude_unnamed_apps else None + ) + for app_id in running_apps ] + self._sources = [source for source in sources if source] else: self._sources = None diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 3c181d7d04b4b6..19a0cc7c6ad570 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -75,9 +75,7 @@ def should_poll(self): @property def unique_id(self): """Return the unique ID of the device.""" - return "{device}-{switch_idx}".format( - device=self._port.device.host, switch_idx=self._port.get_index() - ) + return f"{self._port.device.host}-{self._port.get_index()}" @property def name(self): diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index f7b385d80a2210..f4efd0de355265 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -20,6 +20,7 @@ STATE_OFF, STATE_ON, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -55,9 +56,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.info("Provisioning Anthem AVR device at %s:%d", host, port) + @callback def async_anthemav_update_callback(message): """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update callback from AVR: %s", message) + _LOGGER.debug("Received update callback from AVR: %s", message) hass.async_create_task(device.async_update_ha_state()) avr = await anthemav.Connection.create( diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index a8a5506fc0a00d..5908523e6d894a 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -1,6 +1,6 @@ { "domain": "apcupsd", - "name": "APCUPSd", + "name": "apcupsd", "documentation": "https://www.home-assistant.io/integrations/apcupsd", "requirements": ["apcaccess==0.0.13"], "dependencies": [], diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 255eb1624ff9f4..7947ba7599980a 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -6,7 +6,13 @@ from homeassistant.components import apcupsd from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_RESOURCES, POWER_WATT, TEMP_CELSIUS +from homeassistant.const import ( + CONF_RESOURCES, + POWER_WATT, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -84,8 +90,8 @@ SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} INFERRED_UNITS = { - " Minutes": "min", - " Seconds": "sec", + " Minutes": TIME_MINUTES, + " Seconds": TIME_SECONDS, " Percent": "%", " Volts": "V", " Ampere": "A", diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index fc2f01d418d919..e11bc5e61f9115 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -26,7 +26,6 @@ URL_API_EVENTS, URL_API_SERVICES, URL_API_STATES, - URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__, @@ -254,7 +253,7 @@ async def post(self, request, entity_id): status_code = HTTP_CREATED if is_new_state else 200 resp = self.json(hass.states.get(entity_id), status_code) - resp.headers.add("Location", URL_API_STATES_ENTITY.format(entity_id)) + resp.headers.add("Location", f"/api/states/{entity_id}") return resp @@ -411,6 +410,7 @@ async def async_services_json(hass): return [{"domain": key, "services": value} for key, value in descriptions.items()] +@ha.callback def async_events_json(hass): """Generate event data to JSONify.""" return [ diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py index febe344a9c46bf..3cd43ee36ae6c8 100644 --- a/homeassistant/components/apns/notify.py +++ b/homeassistant/components/apns/notify.py @@ -177,7 +177,7 @@ def __init__(self, hass, app_name, topic, sandbox, cert_file): def device_state_changed_listener(self, entity_id, from_s, to_s): """ - Listen for sate change. + Listen for state change. Track device state change if a device has a tracking id specified. """ diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index e11b246fd5e4ca..52e02cfaf72a59 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -88,16 +88,15 @@ async def configuration_callback(callback_data): try: await atv.airplay.finish_authentication(pin) hass.components.persistent_notification.async_create( - "Authentication succeeded!

Add the following " - "to credentials: in your apple_tv configuration:

" - "{0}".format(credentials), + f"Authentication succeeded!

" + f"Add the following to credentials: " + f"in your apple_tv configuration:

{credentials}", title=NOTIFICATION_AUTH_TITLE, notification_id=NOTIFICATION_AUTH_ID, ) except DeviceAuthenticationError as ex: hass.components.persistent_notification.async_create( - "Authentication failed! Did you enter correct PIN?

" - "Details: {0}".format(ex), + f"Authentication failed! Did you enter correct PIN?

Details: {ex}", title=NOTIFICATION_AUTH_TITLE, notification_id=NOTIFICATION_AUTH_ID, ) @@ -124,9 +123,7 @@ async def scan_apple_tvs(hass): if login_id is None: login_id = "Home Sharing disabled" devices.append( - "Name: {0}
Host: {1}
Login ID: {2}".format( - atv.name, atv.address, login_id - ) + f"Name: {atv.name}
Host: {atv.address}
Login ID: {login_id}" ) if not devices: diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 2f37d941ac8963..8ca42beab61ac2 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "requirements": ["pyatv==0.3.13"], "dependencies": ["configurator"], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index c816be52259ffb..c34a46a8b827f2 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -229,62 +229,42 @@ async def async_turn_off(self): self._playing = None self._power.set_power_on(False) - def async_media_play_pause(self): - """Pause media on media player. + async def async_media_play_pause(self): + """Pause media on media player.""" + if not self._playing: + return + state = self.state + if state == STATE_PAUSED: + await self.atv.remote_control.play() + elif state == STATE_PLAYING: + await self.atv.remote_control.pause() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_play(self): + """Play media.""" if self._playing: - state = self.state - if state == STATE_PAUSED: - return self.atv.remote_control.play() - if state == STATE_PLAYING: - return self.atv.remote_control.pause() - - def async_media_play(self): - """Play media. + await self.atv.remote_control.play() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_stop(self): + """Stop the media player.""" if self._playing: - return self.atv.remote_control.play() - - def async_media_stop(self): - """Stop the media player. + await self.atv.remote_control.stop() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_pause(self): + """Pause the media player.""" if self._playing: - return self.atv.remote_control.stop() + await self.atv.remote_control.pause() - def async_media_pause(self): - """Pause the media player. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_next_track(self): + """Send next track command.""" if self._playing: - return self.atv.remote_control.pause() - - def async_media_next_track(self): - """Send next track command. + await self.atv.remote_control.next() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_previous_track(self): + """Send previous track command.""" if self._playing: - return self.atv.remote_control.next() - - def async_media_previous_track(self): - """Send previous track command. - - This method must be run in the event loop and returns a coroutine. - """ - if self._playing: - return self.atv.remote_control.previous() - - def async_media_seek(self, position): - """Send seek command. + await self.atv.remote_control.previous() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_seek(self, position): + """Send seek command.""" if self._playing: - return self.atv.remote_control.set_position(position) + await self.atv.remote_control.set_position(position) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 1229b756e7224b..dd784cc449dcc8 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -61,17 +61,10 @@ async def async_turn_off(self, **kwargs): """ self._power.set_power_on(False) - def async_send_command(self, command, **kwargs): - """Send a command to one device. + async def async_send_command(self, command, **kwargs): + """Send a command to one device.""" + for single_command in command: + if not hasattr(self._atv.remote_control, single_command): + continue - This method must be run in the event loop and returns a coroutine. - """ - # Send commands in specified order but schedule only one coroutine - async def _send_commands(): - for single_command in command: - if not hasattr(self._atv.remote_control, single_command): - continue - - await getattr(self._atv.remote_control, single_command)() - - return _send_commands() + await getattr(self._atv.remote_control, single_command)() diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 1f41d5a24e2afd..0895c2af1f9b6d 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.8.3"], + "requirements": ["apprise==0.8.4"], "dependencies": [], "codeowners": ["@caronc"] } diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index 6258b470ebb8d0..fb29a0ac8c714e 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -57,7 +57,7 @@ def make_filter(callsigns: list) -> str: """Make a server-side filter from a list of callsigns.""" - return " ".join("b/{0}".format(cs.upper()) for cs in callsigns) + return " ".join(f"b/{sign.upper()}" for sign in callsigns) def gps_accuracy(gps, posambiguity: int) -> int: diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 1cc06fc446f22a..7fff009baa56e0 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -70,7 +70,7 @@ def state(self): @property def name(self): """Return the name of the sensor.""" - return "AquaLogic {}".format(SENSOR_TYPES[self._type][0]) + return f"AquaLogic {SENSOR_TYPES[self._type][0]}" @property def unit_of_measurement(self): diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 74f1a9d9f9aaf4..6950929ee8076d 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -70,7 +70,7 @@ def __init__(self, processor, switch_type): @property def name(self): """Return the name of the switch.""" - return "AquaLogic {}".format(SWITCH_TYPES[self._type]) + return f"AquaLogic {SWITCH_TYPES[self._type]}" @property def should_poll(self): diff --git a/homeassistant/components/arcam_fmj/.translations/sv.json b/homeassistant/components/arcam_fmj/.translations/sv.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/sv.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index d818414753fd08..59bcd08a641b0d 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -44,9 +44,9 @@ def _optional_zone(value): def _zone_name_validator(config): for zone, zone_config in config[CONF_ZONE].items(): if CONF_NAME not in zone_config: - zone_config[CONF_NAME] = "{} ({}:{}) - {}".format( - DEFAULT_NAME, config[CONF_HOST], config[CONF_PORT], zone - ) + zone_config[ + CONF_NAME + ] = f"{DEFAULT_NAME} ({config[CONF_HOST]}:{config[CONF_PORT]}) - {zone}" return config diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 2533ce3619ed2a..1bb34a11693cf7 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -140,7 +140,7 @@ def __init__( """Initialize the sensor.""" self.arest = arest self._resource = resource - self._name = "{} {}".format(location.title(), name.title()) + self._name = f"{location.title()} {name.title()}" self._variable = variable self._pin = pin self._state = None @@ -204,8 +204,7 @@ def update(self): try: if str(self._pin[0]) == "A": response = requests.get( - "{}/analog/{}".format(self._resource, self._pin[1:]), - timeout=10, + f"{self._resource,}/analog/{self._pin[1:]}", timeout=10 ) self.data = {"value": response.json()["return_value"]} except TypeError: diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index ccc2c5d8bf58e2..d3a5139162737c 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -86,7 +86,7 @@ class ArestSwitchBase(SwitchDevice): def __init__(self, resource, location, name): """Initialize the switch.""" self._resource = resource - self._name = "{} {}".format(location.title(), name.title()) + self._name = f"{location.title()} {name.title()}" self._state = None self._available = True diff --git a/homeassistant/components/arlo/__init__.py b/homeassistant/components/arlo/__init__.py index df24bdd1a920a8..40d75d557bbdbd 100644 --- a/homeassistant/components/arlo/__init__.py +++ b/homeassistant/components/arlo/__init__.py @@ -67,9 +67,7 @@ def setup(hass, config): except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex)) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py index 838f319abc1457..49a1bced577946 100644 --- a/homeassistant/components/arlo/alarm_control_panel.py +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -110,19 +110,19 @@ def update(self): else: self._state = None - async def async_alarm_disarm(self, code=None): + def alarm_disarm(self, code=None): """Send disarm command.""" self._base_station.mode = DISARMED - async def async_alarm_arm_away(self, code=None): + def alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" self._base_station.mode = self._away_mode_name - async def async_alarm_arm_home(self, code=None): + def alarm_arm_home(self, code=None): """Send arm home command. Uses custom mode.""" self._base_station.mode = self._home_mode_name - async def async_alarm_arm_night(self, code=None): + def alarm_arm_night(self, code=None): """Send arm night command. Uses custom mode.""" self._base_station.mode = self._night_mode_name diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 958c383765a101..8152a76feecc29 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -78,11 +78,14 @@ def _update_callback(self): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" + video = await self.hass.async_add_executor_job( + getattr, self._camera, "last_video" + ) - video = self._camera.last_video if not video: - error_msg = "Video not found for {0}. Is it older than {1} days?".format( - self.name, self._camera.min_days_vdo_cache + error_msg = ( + f"Video not found for {self.name}. " + f"Is it older than {self._camera.min_days_vdo_cache} days?" ) _LOGGER.error(error_msg) return diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index aadd5a48d3724f..5d11e9bc891b08 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, + CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -30,7 +31,7 @@ "signal_strength": ["Signal Strength", None, "signal"], "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], "humidity": ["Humidity", "%", "water-percent"], - "air_quality": ["Air Quality", "ppm", "biohazard"], + "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -57,7 +58,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if sensor_type in ("temperature", "humidity", "air_quality"): continue - name = "{0} {1}".format(SENSOR_TYPES[sensor_type][0], camera.name) + name = f"{SENSOR_TYPES[sensor_type][0]} {camera.name}" sensors.append(ArloSensor(name, camera, sensor_type)) for base_station in arlo.base_stations: @@ -65,9 +66,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensor_type in ("temperature", "humidity", "air_quality") and base_station.model_id == "ABC1000" ): - name = "{0} {1}".format( - SENSOR_TYPES[sensor_type][0], base_station.name - ) + name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}" sensors.append(ArloSensor(name, base_station, sensor_type)) add_entities(sensors, True) @@ -83,7 +82,7 @@ def __init__(self, name, device, sensor_type): self._data = device self._sensor_type = sensor_type self._state = None - self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[2]) + self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" @property def name(self): @@ -141,8 +140,9 @@ def update(self): video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") except (AttributeError, IndexError): - error_msg = "Video not found for {0}. Older than {1} days?".format( - self.name, self._data.min_days_vdo_cache + error_msg = ( + f"Video not found for {self.name}. " + f"Older than {self._data.min_days_vdo_cache} days?" ) _LOGGER.debug(error_msg) self._state = None diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 485c731ff6a3be..355bcad3aaf24b 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -84,8 +84,8 @@ def _update_info(self): def get_aruba_data(self): """Retrieve data from Aruba Access Point and return parsed result.""" - connect = "ssh {}@{}" - ssh = pexpect.spawn(connect.format(self.username, self.host)) + connect = f"ssh {self.username}@{self.host}" + ssh = pexpect.spawn(connect) query = ssh.expect( [ "password:", diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 685e5d90f53669..014c46fd73c837 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -50,7 +50,7 @@ def discover_sensors(topic, payload): def _slug(name): - return "sensor.arwn_{}".format(slugify(name)) + return f"sensor.arwn_{slugify(name)}" async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py index 0bae6ebf3adf62..12587e531d7e59 100644 --- a/homeassistant/components/asterisk_cdr/mailbox.py +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -49,8 +49,10 @@ def _build_message(self): "duration": entry["duration"], } sha = hashlib.sha256(str(entry).encode("utf-8")).hexdigest() - msg = "Destination: {}\nApplication: {}\n Context: {}".format( - entry["dest"], entry["application"], entry["context"] + msg = ( + f"Destination: {entry['dest']}\n" + f"Application: {entry['application']}\n " + f"Context: {entry['context']}" ) cdr.append({"info": info, "sha": sha, "text": msg}) self.cdr = cdr diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py index 3cd6fe059b6d7e..b3863eeb13f074 100644 --- a/homeassistant/components/asterisk_mbox/mailbox.py +++ b/homeassistant/components/asterisk_mbox/mailbox.py @@ -1,4 +1,5 @@ """Support for the Asterisk Voicemail interface.""" +from functools import partial import logging from asterisk_mbox import ServerError @@ -55,7 +56,9 @@ async def async_get_media(self, msgid): client = self.hass.data[ASTERISK_DOMAIN].client try: - return client.mp3(msgid, sync=True) + return await self.hass.async_add_executor_job( + partial(client.mp3, msgid, sync=True) + ) except ServerError as err: raise StreamError(err) @@ -63,9 +66,9 @@ async def async_get_messages(self): """Return a list of the current messages.""" return self.hass.data[ASTERISK_DOMAIN].messages - def async_delete(self, msgid): + async def async_delete(self, msgid): """Delete the specified messages.""" client = self.hass.data[ASTERISK_DOMAIN].client _LOGGER.info("Deleting: %s", msgid) - client.delete(msgid) + await self.hass.async_add_executor_job(client.delete, msgid) return True diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index 64d2d7c7a4bf15..f2d7a72e54d724 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -17,6 +17,8 @@ _LOGGER = logging.getLogger(__name__) +CONF_DNSMASQ = "dnsmasq" +CONF_INTERFACE = "interface" CONF_PUB_KEY = "pub_key" CONF_REQUIRE_IP = "require_ip" CONF_SENSORS = "sensors" @@ -24,7 +26,10 @@ DOMAIN = "asuswrt" DATA_ASUSWRT = DOMAIN + DEFAULT_SSH_PORT = 22 +DEFAULT_INTERFACE = "eth0" +DEFAULT_DNSMASQ = "/var/lib/misc" SECRET_GROUP = "Password or SSH Key" SENSOR_TYPES = ["upload_speed", "download_speed", "download", "upload"] @@ -45,6 +50,8 @@ vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, [vol.In(SENSOR_TYPES)] ), + vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.isdir, } ) }, @@ -59,18 +66,20 @@ async def async_setup(hass, config): api = AsusWrt( conf[CONF_HOST], - conf.get(CONF_PORT), - conf.get(CONF_PROTOCOL) == "telnet", + conf[CONF_PORT], + conf[CONF_PROTOCOL] == "telnet", conf[CONF_USERNAME], conf.get(CONF_PASSWORD, ""), conf.get("ssh_key", conf.get("pub_key", "")), - conf.get(CONF_MODE), - conf.get(CONF_REQUIRE_IP), + conf[CONF_MODE], + conf[CONF_REQUIRE_IP], + conf[CONF_INTERFACE], + conf[CONF_DNSMASQ], ) await api.connection.async_connect() if not api.is_connected: - _LOGGER.error("Unable to setup asuswrt component") + _LOGGER.error("Unable to setup component") return False hass.data[DATA_ASUSWRT] = api diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 02999ada68ba23..c161dc4f5367b2 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -1,8 +1,8 @@ { "domain": "asuswrt", - "name": "Asuswrt", + "name": "ASUSWRT", "documentation": "https://www.home-assistant.io/integrations/asuswrt", - "requirements": ["aioasuswrt==1.1.22"], + "requirements": ["aioasuswrt==1.2.2"], "dependencies": [], "codeowners": ["@kennedyshead"] } diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index b5ce8539f440a4..50100d3625d33e 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -1,6 +1,7 @@ """Asuswrt status sensors.""" import logging +from homeassistant.const import DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND from homeassistant.helpers.entity import Entity from . import DATA_ASUSWRT @@ -61,7 +62,7 @@ class AsuswrtRXSensor(AsuswrtSensor): """Representation of a asuswrt download speed sensor.""" _name = "Asuswrt Download Speed" - _unit = "Mbit/s" + _unit = DATA_RATE_MEGABITS_PER_SECOND @property def unit_of_measurement(self): @@ -79,7 +80,7 @@ class AsuswrtTXSensor(AsuswrtSensor): """Representation of a asuswrt upload speed sensor.""" _name = "Asuswrt Upload Speed" - _unit = "Mbit/s" + _unit = DATA_RATE_MEGABITS_PER_SECOND @property def unit_of_measurement(self): @@ -97,7 +98,7 @@ class AsuswrtTotalRXSensor(AsuswrtSensor): """Representation of a asuswrt total download sensor.""" _name = "Asuswrt Download" - _unit = "Gigabyte" + _unit = DATA_GIGABYTES @property def unit_of_measurement(self): @@ -115,7 +116,7 @@ class AsuswrtTotalTXSensor(AsuswrtSensor): """Representation of a asuswrt total upload sensor.""" _name = "Asuswrt Upload" - _unit = "Gigabyte" + _unit = DATA_GIGABYTES @property def unit_of_measurement(self): diff --git a/homeassistant/components/august/.translations/da.json b/homeassistant/components/august/.translations/da.json new file mode 100644 index 00000000000000..d63bcf9accabc6 --- /dev/null +++ b/homeassistant/components/august/.translations/da.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigureret" + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse. Pr\u00f8v igen", + "invalid_auth": "Ugyldig godkendelse", + "unknown": "Uventet fejl" + }, + "step": { + "user": { + "data": { + "login_method": "Loginmetode", + "password": "Adgangskode", + "timeout": "Timeout (sekunder)", + "username": "Brugernavn" + }, + "description": "Hvis loginmetoden er 'e-mail', er brugernavn e-mailadressen. Hvis loginmetoden er 'telefon', er brugernavn telefonnummeret i formatet '+NNNNNNNNNN'.", + "title": "Konfigurer en August-konto" + }, + "validation": { + "data": { + "code": "Bekr\u00e6ftelseskode" + }, + "description": "Kontroller dit {login_method} ({username}), og angiv bekr\u00e6ftelseskoden nedenfor", + "title": "Tofaktorgodkendelse" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/de.json b/homeassistant/components/august/.translations/de.json new file mode 100644 index 00000000000000..dd3b2ea9f4409a --- /dev/null +++ b/homeassistant/components/august/.translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konto ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "login_method": "Anmeldemethode", + "password": "Passwort", + "timeout": "Zeit\u00fcberschreitung (Sekunden)", + "username": "Benutzername" + }, + "title": "Richten Sie ein August-Konto ein" + }, + "validation": { + "data": { + "code": "Verifizierungs-Code" + }, + "description": "Bitte \u00fcberpr\u00fcfen Sie Ihre {login_method} ({username}) und geben Sie den Best\u00e4tigungscode ein", + "title": "Zwei-Faktor-Authentifizierung" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/en.json b/homeassistant/components/august/.translations/en.json new file mode 100644 index 00000000000000..32c628f0b0dce0 --- /dev/null +++ b/homeassistant/components/august/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "login_method": "Login Method", + "password": "Password", + "timeout": "Timeout (seconds)", + "username": "Username" + }, + "description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "title": "Setup an August account" + }, + "validation": { + "data": { + "code": "Verification code" + }, + "description": "Please check your {login_method} ({username}) and enter the verification code below", + "title": "Two factor authentication" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 0bb0d6398961c8..6a497920ce33e3 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -1,44 +1,46 @@ """Support for August devices.""" +import asyncio from datetime import timedelta +from functools import partial import logging -from august.api import Api -from august.authenticator import AuthenticationState, Authenticator, ValidationResult -from requests import RequestException, Session +from august.api import AugustApiHTTPError +from august.authenticator import ValidationResult +from august.doorbell import Doorbell +from august.lock import Lock +from requests import RequestException import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.helpers import discovery +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -_CONFIGURING = {} - -DEFAULT_TIMEOUT = 10 -ACTIVITY_FETCH_LIMIT = 10 -ACTIVITY_INITIAL_FETCH_LIMIT = 20 - -CONF_LOGIN_METHOD = "login_method" -CONF_INSTALL_ID = "install_id" +from .const import ( + AUGUST_COMPONENTS, + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DATA_AUGUST, + DEFAULT_AUGUST_CONFIG_FILE, + DEFAULT_NAME, + DEFAULT_TIMEOUT, + DOMAIN, + LOGIN_METHODS, + MIN_TIME_BETWEEN_ACTIVITY_UPDATES, + MIN_TIME_BETWEEN_DETAIL_UPDATES, + VERIFICATION_CODE_KEY, +) +from .exceptions import InvalidAuth, RequireValidation +from .gateway import AugustGateway -NOTIFICATION_ID = "august_notification" -NOTIFICATION_TITLE = "August Setup" +_LOGGER = logging.getLogger(__name__) -AUGUST_CONFIG_FILE = ".august.conf" +TWO_FA_REVALIDATE = "verify_configurator" -DATA_AUGUST = "august" -DOMAIN = "august" -DEFAULT_ENTITY_NAMESPACE = "august" -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) -LOGIN_METHODS = ["phone", "email"] +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) CONFIG_SCHEMA = vol.Schema( { @@ -55,139 +57,173 @@ extra=vol.ALLOW_EXTRA, ) -AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"] +async def async_request_validation(hass, config_entry, august_gateway): + """Request a new verification code from the user.""" -def request_configuration(hass, config, api, authenticator): - """Request configuration steps from the user.""" + # + # In the future this should start a new config flow + # instead of using the legacy configurator + # + _LOGGER.error("Access token is no longer valid.") configurator = hass.components.configurator + entry_id = config_entry.entry_id - def august_configuration_callback(data): - """Run when the configuration callback is called.""" - - result = authenticator.validate_verification_code(data.get("verification_code")) + async def async_august_configuration_validation_callback(data): + code = data.get(VERIFICATION_CODE_KEY) + result = await hass.async_add_executor_job( + august_gateway.authenticator.validate_verification_code, code + ) if result == ValidationResult.INVALID_VERIFICATION_CODE: - configurator.notify_errors( - _CONFIGURING[DOMAIN], "Invalid verification code" + configurator.async_notify_errors( + hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE], + "Invalid verification code, please make sure you are using the latest code and try again.", ) elif result == ValidationResult.VALIDATED: - setup_august(hass, config, api, authenticator) + return await async_setup_august(hass, config_entry, august_gateway) - if DOMAIN not in _CONFIGURING: - authenticator.send_verification_code() + return False - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - login_method = conf.get(CONF_LOGIN_METHOD) + if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]: + await hass.async_add_executor_job( + august_gateway.authenticator.send_verification_code + ) - _CONFIGURING[DOMAIN] = configurator.request_config( - NOTIFICATION_TITLE, - august_configuration_callback, - description="Please check your {} ({}) and enter the verification " + entry_data = config_entry.data + login_method = entry_data.get(CONF_LOGIN_METHOD) + username = entry_data.get(CONF_USERNAME) + + hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE] = configurator.async_request_config( + f"{DEFAULT_NAME} ({username})", + async_august_configuration_validation_callback, + description="August must be re-verified. Please check your {} ({}) and enter the verification " "code below".format(login_method, username), submit_caption="Verify", fields=[ - {"id": "verification_code", "name": "Verification code", "type": "string"} + {"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"} ], ) + return -def setup_august(hass, config, api, authenticator): +async def async_setup_august(hass, config_entry, august_gateway): """Set up the August component.""" - authentication = None + entry_id = config_entry.entry_id + hass.data[DOMAIN].setdefault(entry_id, {}) + try: - authentication = authenticator.authenticate() - except RequestException as ex: - _LOGGER.error("Unable to connect to August service: %s", str(ex)) - - hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, + august_gateway.authenticate() + except RequireValidation: + await async_request_validation(hass, config_entry, august_gateway) + return False + except InvalidAuth: + _LOGGER.error("Password is no longer valid. Please set up August again") + return False + + # We still use the configurator to get a new 2fa code + # when needed since config_flow doesn't have a way + # to re-request if it expires + if TWO_FA_REVALIDATE in hass.data[DOMAIN][entry_id]: + hass.components.configurator.async_request_done( + hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE) ) - state = authentication.state + hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job( + AugustData, hass, august_gateway + ) - if state == AuthenticationState.AUTHENTICATED: - if DOMAIN in _CONFIGURING: - hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + for component in AUGUST_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) - hass.data[DATA_AUGUST] = AugustData(hass, api, authentication.access_token) + return True - for component in AUGUST_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) - return True - if state == AuthenticationState.BAD_PASSWORD: - _LOGGER.error("Invalid password provided") - return False - if state == AuthenticationState.REQUIRES_VALIDATION: - request_configuration(hass, config, api, authenticator) +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the August component from YAML.""" + + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) + + if not conf: return True - return False + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LOGIN_METHOD: conf.get(CONF_LOGIN_METHOD), + CONF_USERNAME: conf.get(CONF_USERNAME), + CONF_PASSWORD: conf.get(CONF_PASSWORD), + CONF_INSTALL_ID: conf.get(CONF_INSTALL_ID), + CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE, + }, + ) + ) + return True -def setup(hass, config): - """Set up the August component.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up August from a config entry.""" - conf = config[DOMAIN] - api_http_session = None - try: - api_http_session = Session() - except RequestException as ex: - _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) - - api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) - - authenticator = Authenticator( - api, - conf.get(CONF_LOGIN_METHOD), - conf.get(CONF_USERNAME), - conf.get(CONF_PASSWORD), - install_id=conf.get(CONF_INSTALL_ID), - access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE), - ) + august_gateway = AugustGateway(hass) + august_gateway.async_setup(entry.data) + + return await async_setup_august(hass, entry, august_gateway) - def close_http_session(event): - """Close API sessions used to connect to August.""" - _LOGGER.debug("Closing August HTTP sessions") - if api_http_session: - try: - api_http_session.close() - except RequestException: - pass - _LOGGER.debug("August HTTP session closed.") +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in AUGUST_COMPONENTS + ] + ) + ) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) - _LOGGER.debug("Registered for Home Assistant stop event") + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - return setup_august(hass, config, api, authenticator) + return unload_ok class AugustData: """August data object.""" - def __init__(self, hass, api, access_token): + DEFAULT_ACTIVITY_FETCH_LIMIT = 10 + + def __init__(self, hass, august_gateway): """Init August data object.""" self._hass = hass - self._api = api - self._access_token = access_token - self._doorbells = self._api.get_doorbells(self._access_token) or [] - self._locks = self._api.get_operable_locks(self._access_token) or [] - self._house_ids = [d.house_id for d in self._doorbells + self._locks] + self._august_gateway = august_gateway + self._api = august_gateway.api + + self._doorbells = ( + self._api.get_doorbells(self._august_gateway.access_token) or [] + ) + self._locks = ( + self._api.get_operable_locks(self._august_gateway.access_token) or [] + ) + self._house_ids = set() + for device in self._doorbells + self._locks: + self._house_ids.add(device.house_id) self._doorbell_detail_by_id = {} - self._lock_status_by_id = {} self._lock_detail_by_id = {} - self._door_state_by_id = {} self._activities_by_id = {} + # We check the locks right away so we can + # remove inoperative ones + self._update_locks_detail() + self._update_doorbells_detail() + self._filter_inoperative_locks() + @property def house_ids(self): """Return a list of house_ids.""" @@ -203,30 +239,39 @@ def locks(self): """Return a list of locks.""" return self._locks - def get_device_activities(self, device_id, *activity_types): + async def async_get_device_activities(self, device_id, *activity_types): """Return a list of activities.""" - _LOGGER.debug("Getting device activities") - self._update_device_activities() + _LOGGER.debug("Getting device activities for %s", device_id) + await self._async_update_device_activities() activities = self._activities_by_id.get(device_id, []) if activity_types: return [a for a in activities if a.activity_type in activity_types] return activities - def get_latest_device_activity(self, device_id, *activity_types): + async def async_get_latest_device_activity(self, device_id, *activity_types): """Return latest activity.""" - activities = self.get_device_activities(device_id, *activity_types) + activities = await self.async_get_device_activities(device_id, *activity_types) return next(iter(activities or []), None) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + @Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES) + async def _async_update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" + + # This is the only place we refresh the api token + await self._august_gateway.async_refresh_access_token_if_needed() + + return await self._hass.async_add_executor_job( + partial(self._update_device_activities, limit=limit) + ) + + def _update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): _LOGGER.debug("Start retrieving device activities") for house_id in self.house_ids: _LOGGER.debug("Updating device activity for house id %s", house_id) activities = self._api.get_house_activities( - self._access_token, house_id, limit=limit + self._august_gateway.access_token, house_id, limit=limit ) device_ids = {a.device_id for a in activities} @@ -234,131 +279,137 @@ def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): self._activities_by_id[device_id] = [ a for a in activities if a.device_id == device_id ] - _LOGGER.debug("Completed retrieving device activities") - def get_doorbell_detail(self, doorbell_id): - """Return doorbell detail.""" - self._update_doorbells() - return self._doorbell_detail_by_id.get(doorbell_id) + _LOGGER.debug("Completed retrieving device activities") - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _update_doorbells(self): - detail_by_id = {} + async def async_get_device_detail(self, device): + """Return the detail for a device.""" + if isinstance(device, Lock): + return await self.async_get_lock_detail(device.device_id) + if isinstance(device, Doorbell): + return await self.async_get_doorbell_detail(device.device_id) + raise ValueError - _LOGGER.debug("Start retrieving doorbell details") - for doorbell in self._doorbells: - _LOGGER.debug("Updating doorbell status for %s", doorbell.device_name) - try: - detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( - self._access_token, doorbell.device_id - ) - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve doorbell status for %s. %s", - doorbell.device_name, - ex, - ) - detail_by_id[doorbell.device_id] = None - except Exception: - detail_by_id[doorbell.device_id] = None - raise + async def async_get_doorbell_detail(self, device_id): + """Return doorbell detail.""" + await self._async_update_doorbells_detail() + return self._doorbell_detail_by_id.get(device_id) - _LOGGER.debug("Completed retrieving doorbell details") - self._doorbell_detail_by_id = detail_by_id + @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) + async def _async_update_doorbells_detail(self): + await self._hass.async_add_executor_job(self._update_doorbells_detail) - def get_lock_status(self, lock_id): - """Return status if the door is locked or unlocked. + def _update_doorbells_detail(self): + self._doorbell_detail_by_id = self._update_device_detail( + "doorbell", self._doorbells, self._api.get_doorbell_detail + ) - This is status for the lock itself. - """ - self._update_locks() - return self._lock_status_by_id.get(lock_id) + def lock_has_doorsense(self, device_id): + """Determine if a lock has doorsense installed and can tell when the door is open or closed.""" + # We do not update here since this is not expected + # to change until restart + if self._lock_detail_by_id[device_id] is None: + return False + return self._lock_detail_by_id[device_id].doorsense - def get_lock_detail(self, lock_id): + async def async_get_lock_detail(self, device_id): """Return lock detail.""" - self._update_locks() - return self._lock_detail_by_id.get(lock_id) + await self._async_update_locks_detail() + return self._lock_detail_by_id[device_id] - def get_door_state(self, lock_id): - """Return status if the door is open or closed. + def get_lock_name(self, device_id): + """Return lock name as August has it stored.""" + for lock in self._locks: + if lock.device_id == device_id: + return lock.device_name - This is the status from the door sensor. - """ - self._update_doors() - return self._door_state_by_id.get(lock_id) + @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) + async def _async_update_locks_detail(self): + await self._hass.async_add_executor_job(self._update_locks_detail) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _update_doors(self): - state_by_id = {} + def _update_locks_detail(self): + self._lock_detail_by_id = self._update_device_detail( + "lock", self._locks, self._api.get_lock_detail + ) - _LOGGER.debug("Start retrieving door status") - for lock in self._locks: - _LOGGER.debug("Updating door status for %s", lock.device_name) + def _update_device_detail(self, device_type, devices, api_call): + detail_by_id = {} + _LOGGER.debug("Start retrieving %s detail", device_type) + for device in devices: + device_id = device.device_id + detail_by_id[device_id] = None try: - state_by_id[lock.device_id] = self._api.get_lock_door_status( - self._access_token, lock.device_id + detail_by_id[device_id] = api_call( + self._august_gateway.access_token, device_id ) except RequestException as ex: _LOGGER.error( - "Request error trying to retrieve door status for %s. %s", - lock.device_name, + "Request error trying to retrieve %s details for %s. %s", + device_type, + device.device_name, ex, ) - state_by_id[lock.device_id] = None - except Exception: - state_by_id[lock.device_id] = None - raise - _LOGGER.debug("Completed retrieving door status") - self._door_state_by_id = state_by_id + _LOGGER.debug("Completed retrieving %s detail", device_type) + return detail_by_id - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def _update_locks(self): - status_by_id = {} - detail_by_id = {} + def lock(self, device_id): + """Lock the device.""" + return _call_api_operation_that_requires_bridge( + self.get_lock_name(device_id), + "lock", + self._api.lock_return_activities, + self._august_gateway.access_token, + device_id, + ) - _LOGGER.debug("Start retrieving locks status") + def unlock(self, device_id): + """Unlock the device.""" + return _call_api_operation_that_requires_bridge( + self.get_lock_name(device_id), + "unlock", + self._api.unlock_return_activities, + self._august_gateway.access_token, + device_id, + ) + + def _filter_inoperative_locks(self): + # Remove non-operative locks as there must + # be a bridge (August Connect) for them to + # be usable + operative_locks = [] for lock in self._locks: - _LOGGER.debug("Updating lock status for %s", lock.device_name) - try: - status_by_id[lock.device_id] = self._api.get_lock_status( - self._access_token, lock.device_id - ) - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve door status for %s. %s", + lock_detail = self._lock_detail_by_id.get(lock.device_id) + if lock_detail is None: + _LOGGER.info( + "The lock %s could not be setup because the system could not fetch details about the lock.", lock.device_name, - ex, ) - status_by_id[lock.device_id] = None - except Exception: - status_by_id[lock.device_id] = None - raise - - try: - detail_by_id[lock.device_id] = self._api.get_lock_detail( - self._access_token, lock.device_id + elif lock_detail.bridge is None: + _LOGGER.info( + "The lock %s could not be setup because it does not have a bridge (Connect).", + lock.device_name, ) - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve door details for %s. %s", + elif not lock_detail.bridge.operative: + _LOGGER.info( + "The lock %s could not be setup because the bridge (Connect) is not operative.", lock.device_name, - ex, ) - detail_by_id[lock.device_id] = None - except Exception: - detail_by_id[lock.device_id] = None - raise + else: + operative_locks.append(lock) - _LOGGER.debug("Completed retrieving locks status") - self._lock_status_by_id = status_by_id - self._lock_detail_by_id = detail_by_id + self._locks = operative_locks - def lock(self, device_id): - """Lock the device.""" - return self._api.lock(self._access_token, device_id) - def unlock(self, device_id): - """Unlock the device.""" - return self._api.unlock(self._access_token, device_id) +def _call_api_operation_that_requires_bridge( + device_name, operation_name, func, *args, **kwargs +): + """Call an API that requires the bridge to be online.""" + ret = None + try: + ret = func(*args, **kwargs) + except AugustApiHTTPError as err: + raise HomeAssistantError(device_name + ": " + str(err)) + + return ret diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 14d03189c92436..c2b5603759d1f4 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -4,96 +4,93 @@ from august.activity import ActivityType from august.lock import LockDoorStatus +from august.util import update_lock_detail_from_activity -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + BinarySensorDevice, +) -from . import DATA_AUGUST +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -def _retrieve_door_state(data, lock): - """Get the latest state of the DoorSense sensor.""" - return data.get_door_state(lock.device_id) - - -def _retrieve_online_state(data, doorbell): +async def _async_retrieve_online_state(data, detail): """Get the latest state of the sensor.""" - detail = data.get_doorbell_detail(doorbell.device_id) - if detail is None: - return None - - return detail.is_online + return detail.is_online or detail.is_standby -def _retrieve_motion_state(data, doorbell): +async def _async_retrieve_motion_state(data, detail): - return _activity_time_based_state( - data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING] + return await _async_activity_time_based_state( + data, + detail.device_id, + [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING], ) -def _retrieve_ding_state(data, doorbell): +async def _async_retrieve_ding_state(data, detail): - return _activity_time_based_state(data, doorbell, [ActivityType.DOORBELL_DING]) + return await _async_activity_time_based_state( + data, detail.device_id, [ActivityType.DOORBELL_DING] + ) -def _activity_time_based_state(data, doorbell, activity_types): +async def _async_activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = data.get_latest_device_activity(doorbell.device_id, *activity_types) + latest = await data.async_get_latest_device_activity(device_id, *activity_types) if latest is not None: start = latest.activity_start_time - end = latest.activity_end_time + timedelta(seconds=30) + end = latest.activity_end_time + timedelta(seconds=45) return start <= datetime.now() <= end return None -# Sensor types: Name, device_class, state_provider -SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _retrieve_door_state]} +SENSOR_NAME = 0 +SENSOR_DEVICE_CLASS = 1 +SENSOR_STATE_PROVIDER = 2 +# sensor_type: [name, device_class, async_state_provider] SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", "occupancy", _retrieve_ding_state], - "doorbell_motion": ["Motion", "motion", _retrieve_motion_state], - "doorbell_online": ["Online", "connectivity", _retrieve_online_state], + "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _async_retrieve_ding_state], + "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _async_retrieve_motion_state], + "doorbell_online": [ + "Online", + DEVICE_CLASS_CONNECTIVITY, + _async_retrieve_online_state, + ], } -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the August binary sensors.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for door in data.locks: - for sensor_type in SENSOR_TYPES_DOOR: - state_provider = SENSOR_TYPES_DOOR[sensor_type][2] - if state_provider(data, door) is LockDoorStatus.UNKNOWN: - _LOGGER.debug( - "Not adding sensor class %s for lock %s ", - SENSOR_TYPES_DOOR[sensor_type][1], - door.device_name, - ) - continue + if not data.lock_has_doorsense(door.device_id): + _LOGGER.debug("Not adding sensor class door for lock %s ", door.device_name) + continue - _LOGGER.debug( - "Adding sensor class %s for %s", - SENSOR_TYPES_DOOR[sensor_type][1], - door.device_name, - ) - devices.append(AugustDoorBinarySensor(data, sensor_type, door)) + _LOGGER.debug("Adding sensor class door for %s", door.device_name) + devices.append(AugustDoorBinarySensor(data, "door_open", door)) for doorbell in data.doorbells: for sensor_type in SENSOR_TYPES_DOORBELL: _LOGGER.debug( "Adding doorbell sensor class %s for %s", - SENSOR_TYPES_DOORBELL[sensor_type][1], + SENSOR_TYPES_DOORBELL[sensor_type][SENSOR_DEVICE_CLASS], doorbell.device_name, ) devices.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) - add_entities(devices, True) + async_add_entities(devices, True) class AugustDoorBinarySensor(BinarySensorDevice): @@ -106,6 +103,8 @@ def __init__(self, data, sensor_type, door): self._door = door self._state = None self._available = False + self._firmware_version = None + self._model = None @property def available(self): @@ -119,30 +118,49 @@ def is_on(self): @property def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOOR[self._sensor_type][1] + """Return the class of this device.""" + return "door" @property def name(self): """Return the name of the binary sensor.""" - return "{} {}".format( - self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][0] + return f"{self._door.device_name} Open" + + async def async_update(self): + """Get the latest state of the sensor and update activity.""" + door_activity = await self._data.async_get_latest_device_activity( + self._door.device_id, ActivityType.DOOR_OPERATION ) + detail = await self._data.async_get_lock_detail(self._door.device_id) - def update(self): - """Get the latest state of the sensor.""" - state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] - self._state = state_provider(self._data, self._door) - self._available = self._state is not None + if door_activity is not None: + update_lock_detail_from_activity(detail, door_activity) + + lock_door_state = None + self._available = False + if detail is not None: + lock_door_state = detail.door_state + self._available = detail.bridge_is_online + self._firmware_version = detail.firmware_version + self._model = detail.model - self._state = self._state == LockDoorStatus.OPEN + self._state = lock_door_state == LockDoorStatus.OPEN @property def unique_id(self) -> str: """Get the unique of the door open binary sensor.""" - return "{:s}_{:s}".format( - self._door.device_id, SENSOR_TYPES_DOOR[self._sensor_type][0].lower() - ) + return f"{self._door.device_id}_open" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._door.device_id)}, + "name": self._door.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + "model": self._model, + } class AugustDoorbellBinarySensor(BinarySensorDevice): @@ -155,6 +173,8 @@ def __init__(self, data, sensor_type, doorbell): self._doorbell = doorbell self._state = None self._available = False + self._firmware_version = None + self._model = None @property def available(self): @@ -169,25 +189,50 @@ def is_on(self): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOORBELL[self._sensor_type][1] + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_DEVICE_CLASS] @property def name(self): """Return the name of the binary sensor.""" - return "{} {}".format( - self._doorbell.device_name, SENSOR_TYPES_DOORBELL[self._sensor_type][0] - ) + return f"{self._doorbell.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" - def update(self): + async def async_update(self): """Get the latest state of the sensor.""" - state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] - self._state = state_provider(self._data, self._doorbell) - self._available = self._doorbell.is_online + async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ + SENSOR_STATE_PROVIDER + ] + detail = await self._data.async_get_doorbell_detail(self._doorbell.device_id) + # The doorbell will go into standby mode when there is no motion + # for a short while. It will wake by itself when needed so we need + # to consider is available or we will not report motion or dings + if self.device_class == DEVICE_CLASS_CONNECTIVITY: + self._available = True + else: + self._available = detail is not None and ( + detail.is_online or detail.is_standby + ) + + self._state = None + if detail is not None: + self._firmware_version = detail.firmware_version + self._model = detail.model + self._state = await async_state_provider(self._data, detail) @property def unique_id(self) -> str: """Get the unique id of the doorbell sensor.""" - return "{:s}_{:s}".format( - self._doorbell.device_id, - SENSOR_TYPES_DOORBELL[self._sensor_type][0].lower(), + return ( + f"{self._doorbell.device_id}_" + f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" ) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._doorbell.device_id)}, + "name": self._doorbell.device_name, + "manufacturer": "August", + "sw_version": self._firmware_version, + "model": self._model, + } diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 2492eb754181ab..02c3a6b123186d 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,37 +1,41 @@ """Support for August camera.""" from datetime import timedelta -import requests +from august.activity import ActivityType +from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera -from . import DATA_AUGUST, DEFAULT_TIMEOUT +from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN SCAN_INTERVAL = timedelta(seconds=5) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August cameras.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for doorbell in data.doorbells: devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) - add_entities(devices, True) + async_add_entities(devices, True) class AugustCamera(Camera): - """An implementation of a Canary security camera.""" + """An implementation of a August security camera.""" def __init__(self, data, doorbell, timeout): - """Initialize a Canary security camera.""" + """Initialize a August security camera.""" super().__init__() self._data = data self._doorbell = doorbell + self._doorbell_detail = None self._timeout = timeout self._image_url = None self._image_content = None + self._firmware_version = None + self._model = None @property def name(self): @@ -51,26 +55,65 @@ def motion_detection_enabled(self): @property def brand(self): """Return the camera brand.""" - return "August" + return DEFAULT_NAME @property def model(self): """Return the camera model.""" - return "Doorbell" + return self._model - def camera_image(self): + async def async_camera_image(self): """Return bytes of camera image.""" - latest = self._data.get_doorbell_detail(self._doorbell.device_id) + self._doorbell_detail = await self._data.async_get_doorbell_detail( + self._doorbell.device_id + ) + doorbell_activity = await self._data.async_get_latest_device_activity( + self._doorbell.device_id, ActivityType.DOORBELL_MOTION + ) + + if doorbell_activity is not None: + update_doorbell_image_from_activity( + self._doorbell_detail, doorbell_activity + ) + + if self._doorbell_detail is None: + return None + + if self._image_url is not self._doorbell_detail.image_url: + self._image_url = self._doorbell_detail.image_url + self._image_content = await self.hass.async_add_executor_job( + self._camera_image + ) + return self._image_content - if self._image_url is not latest.image_url: - self._image_url = latest.image_url - self._image_content = requests.get( - self._image_url, timeout=self._timeout - ).content + async def async_update(self): + """Update camera data.""" + self._doorbell_detail = await self._data.async_get_doorbell_detail( + self._doorbell.device_id + ) - return self._image_content + if self._doorbell_detail is None: + return None + + self._firmware_version = self._doorbell_detail.firmware_version + self._model = self._doorbell_detail.model + + def _camera_image(self): + """Return bytes of camera image.""" + return self._doorbell_detail.get_doorbell_image(timeout=self._timeout) @property def unique_id(self) -> str: """Get the unique id of the camera.""" return f"{self._doorbell.device_id:s}_camera" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._doorbell.device_id)}, + "name": self._doorbell.device_name + " Camera", + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + "model": self._model, + } diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py new file mode 100644 index 00000000000000..1fa446ea5664e2 --- /dev/null +++ b/homeassistant/components/august/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for August integration.""" +import logging + +from august.authenticator import ValidationResult +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME + +from .const import ( + CONF_LOGIN_METHOD, + DEFAULT_TIMEOUT, + LOGIN_METHODS, + VERIFICATION_CODE_KEY, +) +from .const import DOMAIN # pylint:disable=unused-import +from .exceptions import CannotConnect, InvalidAuth, RequireValidation +from .gateway import AugustGateway + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOGIN_METHOD, default="phone"): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +async def async_validate_input( + hass: core.HomeAssistant, data, august_gateway, +): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + + Request configuration steps from the user. + """ + + code = data.get(VERIFICATION_CODE_KEY) + + if code is not None: + result = await hass.async_add_executor_job( + august_gateway.authenticator.validate_verification_code, code + ) + _LOGGER.debug("Verification code validation: %s", result) + if result != ValidationResult.VALIDATED: + raise RequireValidation + + try: + august_gateway.authenticate() + except RequireValidation: + _LOGGER.debug( + "Requesting new verification code for %s via %s", + data.get(CONF_USERNAME), + data.get(CONF_LOGIN_METHOD), + ) + if code is None: + await hass.async_add_executor_job( + august_gateway.authenticator.send_verification_code + ) + raise + + return { + "title": data.get(CONF_USERNAME), + "data": august_gateway.config_entry(), + } + + +class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for August.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Store an AugustGateway().""" + self._august_gateway = None + self.user_auth_details = {} + super().__init__() + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self._august_gateway is None: + self._august_gateway = AugustGateway(self.hass) + errors = {} + if user_input is not None: + self._august_gateway.async_setup(user_input) + + try: + info = await async_validate_input( + self.hass, user_input, self._august_gateway, + ) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + return self.async_create_entry(title=info["title"], data=info["data"]) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except RequireValidation: + self.user_auth_details = user_input + + return await self.async_step_validation() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_validation(self, user_input=None): + """Handle validation (2fa) step.""" + if user_input: + return await self.async_step_user({**self.user_auth_details, **user_input}) + + return self.async_show_form( + step_id="validation", + data_schema=vol.Schema( + {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} + ), + description_placeholders={ + CONF_USERNAME: self.user_auth_details.get(CONF_USERNAME), + CONF_LOGIN_METHOD: self.user_auth_details.get(CONF_LOGIN_METHOD), + }, + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py new file mode 100644 index 00000000000000..a7ba61efe1fecc --- /dev/null +++ b/homeassistant/components/august/const.py @@ -0,0 +1,36 @@ +"""Constants for August devices.""" + +from datetime import timedelta + +DEFAULT_TIMEOUT = 10 + +CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" +CONF_LOGIN_METHOD = "login_method" +CONF_INSTALL_ID = "install_id" + +VERIFICATION_CODE_KEY = "verification_code" + +NOTIFICATION_ID = "august_notification" +NOTIFICATION_TITLE = "August" + +DEFAULT_AUGUST_CONFIG_FILE = ".august.conf" + +DATA_AUGUST = "data_august" + +DEFAULT_NAME = "August" +DOMAIN = "august" + +# Limit battery, online, and hardware updates to 1800 seconds +# in order to reduce the number of api requests and +# avoid hitting rate limits +MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(seconds=1800) + +# Activity needs to be checked more frequently as the +# doorbell motion and rings are included here +MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +LOGIN_METHODS = ["phone", "email"] + +AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock", "sensor"] diff --git a/homeassistant/components/august/exceptions.py b/homeassistant/components/august/exceptions.py new file mode 100644 index 00000000000000..78c467ab3a1b70 --- /dev/null +++ b/homeassistant/components/august/exceptions.py @@ -0,0 +1,15 @@ +"""Shared excecption for the august integration.""" + +from homeassistant import exceptions + + +class RequireValidation(exceptions.HomeAssistantError): + """Error to indicate we require validation (2fa).""" + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py new file mode 100644 index 00000000000000..e01e2fb9a8f5d9 --- /dev/null +++ b/homeassistant/components/august/gateway.py @@ -0,0 +1,143 @@ +"""Handle August connection setup and authentication.""" + +import asyncio +import logging + +from august.api import Api +from august.authenticator import AuthenticationState, Authenticator +from requests import RequestException, Session + +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DEFAULT_AUGUST_CONFIG_FILE, + VERIFICATION_CODE_KEY, +) +from .exceptions import CannotConnect, InvalidAuth, RequireValidation + +_LOGGER = logging.getLogger(__name__) + + +class AugustGateway: + """Handle the connection to August.""" + + def __init__(self, hass): + """Init the connection.""" + self._api_http_session = Session() + self._token_refresh_lock = asyncio.Lock() + self._hass = hass + self._config = None + self._api = None + self._authenticator = None + self._authentication = None + + @property + def authenticator(self): + """August authentication object from py-august.""" + return self._authenticator + + @property + def authentication(self): + """August authentication object from py-august.""" + return self._authentication + + @property + def access_token(self): + """Access token for the api.""" + return self._authentication.access_token + + @property + def api(self): + """August api object from py-august.""" + return self._api + + def config_entry(self): + """Config entry.""" + return { + CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], + CONF_USERNAME: self._config[CONF_USERNAME], + CONF_PASSWORD: self._config[CONF_PASSWORD], + CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), + CONF_TIMEOUT: self._config.get(CONF_TIMEOUT), + CONF_ACCESS_TOKEN_CACHE_FILE: self._config[CONF_ACCESS_TOKEN_CACHE_FILE], + } + + @callback + def async_setup(self, conf): + """Create the api and authenticator objects.""" + if conf.get(VERIFICATION_CODE_KEY): + return + if conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) is None: + conf[ + CONF_ACCESS_TOKEN_CACHE_FILE + ] = f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}" + self._config = conf + + self._api = Api( + timeout=self._config.get(CONF_TIMEOUT), http_session=self._api_http_session, + ) + + self._authenticator = Authenticator( + self._api, + self._config[CONF_LOGIN_METHOD], + self._config[CONF_USERNAME], + self._config[CONF_PASSWORD], + install_id=self._config.get(CONF_INSTALL_ID), + access_token_cache_file=self._hass.config.path( + self._config[CONF_ACCESS_TOKEN_CACHE_FILE] + ), + ) + + def authenticate(self): + """Authenticate with the details provided to setup.""" + self._authentication = None + try: + self._authentication = self.authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + raise CannotConnect + + if self._authentication.state == AuthenticationState.BAD_PASSWORD: + raise InvalidAuth + + if self._authentication.state == AuthenticationState.REQUIRES_VALIDATION: + raise RequireValidation + + if self._authentication.state != AuthenticationState.AUTHENTICATED: + _LOGGER.error( + "Unknown authentication state: %s", self._authentication.state + ) + raise InvalidAuth + + return self._authentication + + async def async_refresh_access_token_if_needed(self): + """Refresh the august access token if needed.""" + if self.authenticator.should_refresh(): + async with self._token_refresh_lock: + await self._hass.async_add_executor_job(self._refresh_access_token) + + def _refresh_access_token(self): + refreshed_authentication = self.authenticator.refresh_access_token(force=False) + _LOGGER.info( + "Refreshed august access token. The old token expired at %s, and the new token expires at %s", + self.authentication.access_token_expires, + refreshed_authentication.access_token_expires, + ) + self._authentication = refreshed_authentication + + def _close_http_session(self): + """Close API sessions used to connect to August.""" + if self._api_http_session: + try: + self._api_http_session.close() + except RequestException: + pass + + def __del__(self): + """Close out the http session on destroy.""" + self._close_http_session() diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index a541be67097371..c335292ca54fef 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -4,27 +4,28 @@ from august.activity import ActivityType from august.lock import LockStatus +from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL -from . import DATA_AUGUST +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for lock in data.locks: _LOGGER.debug("Adding lock for %s", lock.device_name) devices.append(AugustLock(data, lock)) - add_entities(devices, True) + async_add_entities(devices, True) class AugustLock(LockDevice): @@ -38,28 +39,58 @@ def __init__(self, data, lock): self._lock_detail = None self._changed_by = None self._available = False + self._firmware_version = None + self._model = None - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the device.""" - self._data.lock(self._lock.device_id) + await self._call_lock_operation(self._data.lock) - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._data.unlock(self._lock.device_id) + await self._call_lock_operation(self._data.unlock) - def update(self): - """Get the latest state of the sensor.""" - self._lock_status = self._data.get_lock_status(self._lock.device_id) - self._available = self._lock_status is not None + async def _call_lock_operation(self, lock_operation): + activities = await self.hass.async_add_executor_job( + lock_operation, self._lock.device_id + ) + for lock_activity in activities: + update_lock_detail_from_activity(self._lock_detail, lock_activity) + + if self._update_lock_status_from_detail(): + self.schedule_update_ha_state() + + def _update_lock_status_from_detail(self): + detail = self._lock_detail + lock_status = None + self._available = False - self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + if detail is not None: + lock_status = detail.lock_status + self._available = detail.bridge_is_online - activity = self._data.get_latest_device_activity( + if self._lock_status != lock_status: + self._lock_status = lock_status + return True + return False + + async def async_update(self): + """Get the latest state of the sensor and update activity.""" + self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id) + lock_activity = await self._data.async_get_latest_device_activity( self._lock.device_id, ActivityType.LOCK_OPERATION ) - if activity is not None: - self._changed_by = activity.operated_by + if lock_activity is not None: + self._changed_by = lock_activity.operated_by + if self._lock_detail is not None: + update_lock_detail_from_activity(self._lock_detail, lock_activity) + + if self._lock_detail is not None: + self._firmware_version = self._lock_detail.firmware_version + self._model = self._lock_detail.model + + self._update_lock_status_from_detail() @property def name(self): @@ -74,7 +105,8 @@ def available(self): @property def is_locked(self): """Return true if device is on.""" - + if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: + return None return self._lock_status is LockStatus.LOCKED @property @@ -88,7 +120,23 @@ def device_state_attributes(self): if self._lock_detail is None: return None - return {ATTR_BATTERY_LEVEL: self._lock_detail.battery_level} + attributes = {ATTR_BATTERY_LEVEL: self._lock_detail.battery_level} + + if self._lock_detail.keypad is not None: + attributes["keypad_battery_level"] = self._lock_detail.keypad.battery_level + + return attributes + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._lock.device_id)}, + "name": self._lock.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + "model": self._model, + } @property def unique_id(self) -> str: diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e3e417d20e0453..523cb5a361fafa 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,14 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["py-august==0.7.0"], - "dependencies": ["configurator"], - "codeowners": [] + "requirements": [ + "py-august==0.21.0" + ], + "dependencies": [ + "configurator" + ], + "codeowners": [ + "@bdraco" + ], + "config_flow": true } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py new file mode 100644 index 00000000000000..8b54c42352acde --- /dev/null +++ b/homeassistant/components/august/sensor.py @@ -0,0 +1,159 @@ +"""Support for August sensors.""" +from datetime import timedelta +import logging + +from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.helpers.entity import Entity + +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN + +BATTERY_LEVEL_FULL = "Full" +BATTERY_LEVEL_MEDIUM = "Medium" +BATTERY_LEVEL_LOW = "Low" + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_device_battery_state(detail): + """Get the latest state of the sensor.""" + if detail is None: + return None + + return detail.battery_level + + +def _retrieve_linked_keypad_battery_state(detail): + """Get the latest state of the sensor.""" + if detail is None: + return None + + if detail.keypad is None: + return None + + battery_level = detail.keypad.battery_level + + if battery_level == BATTERY_LEVEL_FULL: + return 100 + if battery_level == BATTERY_LEVEL_MEDIUM: + return 60 + if battery_level == BATTERY_LEVEL_LOW: + return 10 + + return 0 + + +SENSOR_TYPES_BATTERY = { + "device_battery": { + "name": "Battery", + "state_provider": _retrieve_device_battery_state, + }, + "linked_keypad_battery": { + "name": "Keypad Battery", + "state_provider": _retrieve_linked_keypad_battery_state, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] + devices = [] + + batteries = { + "device_battery": [], + "linked_keypad_battery": [], + } + for device in data.doorbells: + batteries["device_battery"].append(device) + for device in data.locks: + batteries["device_battery"].append(device) + batteries["linked_keypad_battery"].append(device) + + for sensor_type in SENSOR_TYPES_BATTERY: + for device in batteries[sensor_type]: + state_provider = SENSOR_TYPES_BATTERY[sensor_type]["state_provider"] + detail = await data.async_get_device_detail(device) + state = state_provider(detail) + sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"] + if state is None: + _LOGGER.debug( + "Not adding battery sensor %s for %s because it is not present", + sensor_name, + device.device_name, + ) + else: + _LOGGER.debug( + "Adding battery sensor %s for %s", sensor_name, device.device_name, + ) + devices.append(AugustBatterySensor(data, sensor_type, device)) + + async_add_entities(devices, True) + + +class AugustBatterySensor(Entity): + """Representation of an August sensor.""" + + def __init__(self, data, sensor_type, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device = device + self._state = None + self._available = False + self._firmware_version = None + self._model = None + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" # UNIT_PERCENTAGE will be available after PR#32094 + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_BATTERY + + @property + def name(self): + """Return the name of the sensor.""" + device_name = self._device.device_name + sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"] + return f"{device_name} {sensor_name}" + + async def async_update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] + detail = await self._data.async_get_device_detail(self._device) + self._state = state_provider(detail) + self._available = self._state is not None + if detail is not None: + self._firmware_version = detail.firmware_version + self._model = detail.model + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device.device_id}_{self._sensor_type}" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + "model": self._model, + } diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json new file mode 100644 index 00000000000000..1695d33cd63da3 --- /dev/null +++ b/homeassistant/components/august/strings.json @@ -0,0 +1,32 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication" + }, + "abort" : { + "already_configured" : "Account is already configured" + }, + "step" : { + "validation" : { + "title" : "Two factor authentication", + "data" : { + "code" : "Verification code" + }, + "description" : "Please check your {login_method} ({username}) and enter the verification code below" + }, + "user" : { + "description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "data" : { + "timeout" : "Timeout (seconds)", + "password" : "Password", + "username" : "Username", + "login_method" : "Login Method" + }, + "title" : "Setup an August account" + } + }, + "title" : "August" + } +} diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index a2645e5d7cb35b..69a513dd8fb5ed 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -96,7 +96,6 @@ def update(self): if "No response after" in str(error): _LOGGER.debug("No response from inverter (could be dark)") else: - # print("Exception!!: {}".format(str(e))) raise error self._state = None finally: diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 3266ae65d7a491..a2d015c279b3b2 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -33,8 +33,8 @@ async def verify_redirect_uri(hass, client_id, redirect_uri): # Whitelist the iOS and Android callbacks so that people can link apps # without being connected to the internet. if redirect_uri == "homeassistant://auth-callback" and client_id in ( - "https://home-assistant.io/android", - "https://home-assistant.io/iOS", + "https://www.home-assistant.io/android", + "https://www.home-assistant.io/iOS", ): return True diff --git a/homeassistant/components/automatic/device_tracker.py b/homeassistant/components/automatic/device_tracker.py index 3c9e33cdc844ac..0fc747ffaa9735 100644 --- a/homeassistant/components/automatic/device_tracker.py +++ b/homeassistant/components/automatic/device_tracker.py @@ -28,7 +28,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_FUEL_LEVEL = "fuel_level" -AUTOMATIC_CONFIG_FILE = ".automatic/session-{}.json" CONF_CLIENT_ID = "client_id" CONF_CURRENT_LOCATION = "current_location" @@ -95,7 +94,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): request_kwargs={"timeout": DEFAULT_TIMEOUT}, ) - filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) + filename = f".automatic/session-{config[CONF_CLIENT_ID]}.json" refresh_token = yield from hass.async_add_job( _get_refresh_token_from_file, hass, filename ) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 70b8b26fa2c7a6..c19a0033f86336 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,16 +1,18 @@ """Allow to set up simple automation rules via the config file.""" -from functools import partial import importlib import logging -from typing import Any, Awaitable, Callable +from typing import Any, Awaitable, Callable, List, Optional, Set import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, + CONF_DEVICE_ID, + CONF_ENTITY_ID, CONF_ID, CONF_PLATFORM, + CONF_ZONE, EVENT_AUTOMATION_TRIGGERED, EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, @@ -19,7 +21,7 @@ SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import Context, CoreState, HomeAssistant +from homeassistant.core import Context, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import condition, extract_domain_configs, script import homeassistant.helpers.config_validation as cv @@ -55,7 +57,6 @@ CONDITION_TYPE_OR = "or" DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND -DEFAULT_HIDE_ENTITY = False DEFAULT_INITIAL_STATE = True ATTR_LAST_TRIGGERED = "last_triggered" @@ -70,9 +71,7 @@ def _platform_validator(config): """Validate it is a valid platform.""" try: - platform = importlib.import_module( - ".{}".format(config[CONF_PLATFORM]), __name__ - ) + platform = importlib.import_module(f".{config[CONF_PLATFORM]}", __name__) except ImportError: raise vol.Invalid("Invalid platform specified") from None @@ -92,7 +91,7 @@ def _platform_validator(config): _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.107"), + cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.110"), vol.Schema( { # str on purpose @@ -100,7 +99,7 @@ def _platform_validator(config): CONF_ALIAS: cv.string, vol.Optional(CONF_DESCRIPTION): cv.string, vol.Optional(CONF_INITIAL_STATE): cv.boolean, - vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, + vol.Optional(CONF_HIDE_ENTITY): cv.boolean, vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, @@ -119,9 +118,75 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) +@callback +def automations_with_entity(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all automations that reference the entity.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + results = [] + + for automation_entity in component.entities: + if entity_id in automation_entity.referenced_entities: + results.append(automation_entity.entity_id) + + return results + + +@callback +def entities_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all entities in a scene.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + automation_entity = component.get_entity(entity_id) + + if automation_entity is None: + return [] + + return list(automation_entity.referenced_entities) + + +@callback +def automations_with_device(hass: HomeAssistant, device_id: str) -> List[str]: + """Return all automations that reference the device.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + results = [] + + for automation_entity in component.entities: + if device_id in automation_entity.referenced_devices: + results.append(automation_entity.entity_id) + + return results + + +@callback +def devices_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all devices in a scene.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + automation_entity = component.get_entity(entity_id) + + if automation_entity is None: + return [] + + return list(automation_entity.referenced_devices) + + async def async_setup(hass, config): """Set up the automation.""" - component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = component = EntityComponent(_LOGGER, DOMAIN, hass) await _async_process_config(hass, config, component) @@ -153,7 +218,7 @@ async def reload_service_handler(service_call): await _async_process_config(hass, conf, component) async_register_admin_service( - hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}), + hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) ) return True @@ -166,29 +231,34 @@ def __init__( self, automation_id, name, - async_attach_triggers, + trigger_config, cond_func, - async_action, - hidden, + action_script, initial_state, ): """Initialize an automation entity.""" self._id = automation_id self._name = name - self._async_attach_triggers = async_attach_triggers + self._trigger_config = trigger_config self._async_detach_triggers = None self._cond_func = cond_func - self._async_action = async_action + self.action_script = action_script self._last_triggered = None - self._hidden = hidden self._initial_state = initial_state self._is_enabled = False + self._referenced_entities: Optional[Set[str]] = None + self._referenced_devices: Optional[Set[str]] = None @property def name(self): """Name of the automation.""" return self._name + @property + def unique_id(self): + """Return unique ID.""" + return self._id + @property def should_poll(self): """No polling needed for automation entities.""" @@ -199,16 +269,50 @@ def state_attributes(self): """Return the entity state attributes.""" return {ATTR_LAST_TRIGGERED: self._last_triggered} - @property - def hidden(self) -> bool: - """Return True if the automation entity should be hidden from UIs.""" - return self._hidden - @property def is_on(self) -> bool: """Return True if entity is on.""" return self._async_detach_triggers is not None or self._is_enabled + @property + def referenced_devices(self): + """Return a set of referenced devices.""" + if self._referenced_devices is not None: + return self._referenced_devices + + referenced = self.action_script.referenced_devices + + if self._cond_func is not None: + for conf in self._cond_func.config: + referenced |= condition.async_extract_devices(conf) + + for conf in self._trigger_config: + device = _trigger_extract_device(conf) + if device is not None: + referenced.add(device) + + self._referenced_devices = referenced + return referenced + + @property + def referenced_entities(self): + """Return a set of referenced entities.""" + if self._referenced_entities is not None: + return self._referenced_entities + + referenced = self.action_script.referenced_entities + + if self._cond_func is not None: + for conf in self._cond_func.config: + referenced |= condition.async_extract_entities(conf) + + for conf in self._trigger_config: + for entity_id in _trigger_extract_entities(conf): + referenced.add(entity_id) + + self._referenced_entities = referenced + return referenced + async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" await super().async_added_to_hass() @@ -259,7 +363,11 @@ async def async_trigger(self, variables, skip_condition=False, context=None): This method is a coroutine. """ - if not skip_condition and not self._cond_func(variables): + if ( + not skip_condition + and self._cond_func is not None + and not self._cond_func(variables) + ): return # Create a new context referring to the old context. @@ -272,7 +380,14 @@ async def async_trigger(self, variables, skip_condition=False, context=None): {ATTR_NAME: self._name, ATTR_ENTITY_ID: self.entity_id}, context=trigger_context, ) - await self._async_action(self.entity_id, variables, trigger_context) + + _LOGGER.info("Executing %s", self._name) + + try: + await self.action_script.async_run(variables, trigger_context) + except Exception: # pylint: disable=broad-except + pass + self._last_triggered = utcnow() await self.async_update_ha_state() @@ -293,9 +408,7 @@ async def async_enable(self): # HomeAssistant is starting up if self.hass.state != CoreState.not_running: - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger - ) + self._async_detach_triggers = await self._async_attach_triggers() self.async_write_ha_state() return @@ -305,9 +418,7 @@ async def async_enable_automation(event): if not self._is_enabled or self._async_detach_triggers is not None: return - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger - ) + self._async_detach_triggers = await self._async_attach_triggers() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_enable_automation @@ -327,6 +438,36 @@ async def async_disable(self): self.async_write_ha_state() + async def _async_attach_triggers(self): + """Set up the triggers.""" + removes = [] + info = {"name": self._name} + + for conf in self._trigger_config: + platform = importlib.import_module(f".{conf[CONF_PLATFORM]}", __name__) + + remove = await platform.async_attach_trigger( + self.hass, conf, self.async_trigger, info + ) + + if not remove: + _LOGGER.error("Error setting up trigger %s", self._name) + continue + + _LOGGER.info("Initialized trigger %s", self._name) + removes.append(remove) + + if not removes: + return None + + @callback + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers + @property def device_state_attributes(self): """Return automation attributes.""" @@ -350,10 +491,11 @@ async def _async_process_config(hass, config, component): automation_id = config_block.get(CONF_ID) name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" - hidden = config_block[CONF_HIDE_ENTITY] initial_state = config_block.get(CONF_INITIAL_STATE) - action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), name) + action_script = script.Script( + hass, config_block.get(CONF_ACTION, {}), name, logger=_LOGGER + ) if CONF_CONDITION in config_block: cond_func = await _async_process_if(hass, config, config_block) @@ -361,25 +503,14 @@ async def _async_process_config(hass, config, component): if cond_func is None: continue else: + cond_func = None - def cond_func(variables): - """Condition will always pass.""" - return True - - async_attach_triggers = partial( - _async_process_trigger, - hass, - config, - config_block.get(CONF_TRIGGER, []), - name, - ) entity = AutomationEntity( automation_id, name, - async_attach_triggers, + config_block[CONF_TRIGGER], cond_func, - action, - hidden, + action_script, initial_state, ) @@ -389,27 +520,9 @@ def cond_func(variables): await component.async_add_entities(entities) -def _async_get_action(hass, config, name): - """Return an action based on a configuration.""" - script_obj = script.Script(hass, config, name) - - async def action(entity_id, variables, context): - """Execute an action.""" - _LOGGER.info("Executing %s", name) - - try: - await script_obj.async_run(variables, context) - except Exception as err: # pylint: disable=broad-except - script_obj.async_log_exception( - _LOGGER, f"Error while executing automation {entity_id}", err - ) - - return action - - async def _async_process_if(hass, config, p_config): """Process if checks.""" - if_configs = p_config.get(CONF_CONDITION) + if_configs = p_config[CONF_CONDITION] checks = [] for if_config in if_configs: @@ -423,35 +536,33 @@ def if_action(variables=None): """AND all conditions.""" return all(check(hass, variables) for check in checks) - return if_action + if_action.config = if_configs + return if_action -async def _async_process_trigger(hass, config, trigger_configs, name, action): - """Set up the triggers. - This method is a coroutine. - """ - removes = [] - info = {"name": name} +@callback +def _trigger_extract_device(trigger_conf: dict) -> Optional[str]: + """Extract devices from a trigger config.""" + if trigger_conf[CONF_PLATFORM] != "device": + return None - for conf in trigger_configs: - platform = importlib.import_module(".{}".format(conf[CONF_PLATFORM]), __name__) + return trigger_conf[CONF_DEVICE_ID] - remove = await platform.async_attach_trigger(hass, conf, action, info) - if not remove: - _LOGGER.error("Error setting up trigger %s", name) - continue +@callback +def _trigger_extract_entities(trigger_conf: dict) -> List[str]: + """Extract entities from a trigger config.""" + if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): + return trigger_conf[CONF_ENTITY_ID] - _LOGGER.info("Initialized trigger %s", name) - removes.append(remove) + if trigger_conf[CONF_PLATFORM] == "zone": + return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] - if not removes: - return None + if trigger_conf[CONF_PLATFORM] == "geo_location": + return [trigger_conf[CONF_ZONE]] - def remove_triggers(): - """Remove attached triggers.""" - for remove in removes: - remove() + if trigger_conf[CONF_PLATFORM] == "sun": + return ["sun.sun"] - return remove_triggers + return [] diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index d11472a21289dc..d29a561f378724 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -26,7 +26,7 @@ async def async_validate_config_item(hass, config, full_config=None): triggers = [] for trigger in config[CONF_TRIGGER]: trigger_platform = importlib.import_module( - "..{}".format(trigger[CONF_PLATFORM]), __name__ + f"..{trigger[CONF_PLATFORM]}", __name__ ) if hasattr(trigger_platform, "async_validate_trigger_config"): trigger = await trigger_platform.async_validate_trigger_config( diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py index 5dc4f3c80f6e88..92094a751a0926 100644 --- a/homeassistant/components/automation/geo_location.py +++ b/homeassistant/components/automation/geo_location.py @@ -58,7 +58,6 @@ def state_change_listener(event): from_match = condition.zone(hass, zone_state, from_state) to_match = condition.zone(hass, zone_state, to_state) - # pylint: disable=too-many-boolean-expressions if ( trigger_event == EVENT_ENTER and not from_match diff --git a/homeassistant/components/automation/litejet.py b/homeassistant/components/automation/litejet.py index 466fc941a9ae02..12ffa29b962268 100644 --- a/homeassistant/components/automation/litejet.py +++ b/homeassistant/components/automation/litejet.py @@ -92,6 +92,7 @@ def released(): hass.data["litejet_system"].on_switch_pressed(number, pressed) hass.data["litejet_system"].on_switch_released(number, released) + @callback def async_remove(): """Remove all subscriptions used for this trigger.""" return diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index fb0073c78d539a..046cbba2873eaf 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -11,8 +11,10 @@ # mypy: allow-untyped-defs CONF_ENCODING = "encoding" +CONF_QOS = "qos" CONF_TOPIC = "topic" DEFAULT_ENCODING = "utf-8" +DEFAULT_QOS = 0 TRIGGER_SCHEMA = vol.Schema( { @@ -20,6 +22,9 @@ vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( + vol.Coerce(int), vol.In([0, 1, 2]) + ), } ) @@ -29,6 +34,7 @@ async def async_attach_trigger(hass, config, action, automation_info): topic = config[CONF_TOPIC] payload = config.get(CONF_PAYLOAD) encoding = config[CONF_ENCODING] or None + qos = config[CONF_QOS] @callback def mqtt_automation_listener(mqttmsg): @@ -49,6 +55,6 @@ def mqtt_automation_listener(mqttmsg): hass.async_run_job(action, {"trigger": data}) remove = await mqtt.async_subscribe( - hass, topic, mqtt_automation_listener, encoding=encoding + hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos ) return remove diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index 3dba1a4df355b6..14233d783f95dc 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -53,7 +53,6 @@ def zone_automation_listener(entity, from_s, to_s): from_match = False to_match = condition.zone(hass, zone_state, to_s) - # pylint: disable=too-many-boolean-expressions if ( event == EVENT_ENTER and not from_match diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index f15e4a80e36989..18fb3f2cd5433a 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -8,6 +8,9 @@ import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY, @@ -50,29 +53,29 @@ }, "CO2": { "device_class": DEVICE_CLASS_CARBON_DIOXIDE, - "unit_of_measurement": "ppm", + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, "icon": "mdi:periodic-table-co2", }, "VOC": { "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - "unit_of_measurement": "ppb", + "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, "icon": "mdi:cloud", }, # Awair docs don't actually specify the size they measure for 'dust', # but 2.5 allows the sensor to show up in HomeKit "DUST": { "device_class": DEVICE_CLASS_PM2_5, - "unit_of_measurement": "µg/m3", + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "icon": "mdi:cloud", }, "PM25": { "device_class": DEVICE_CLASS_PM2_5, - "unit_of_measurement": "µg/m3", + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "icon": "mdi:cloud", }, "PM10": { "device_class": DEVICE_CLASS_PM10, - "unit_of_measurement": "µg/m3", + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "icon": "mdi:cloud", }, "score": { diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 7b706eb1bfa479..3f9c0043a3e3c2 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -2,7 +2,7 @@ "domain": "aws", "name": "Amazon Web Services (AWS)", "documentation": "https://www.home-assistant.io/integrations/aws", - "requirements": ["aiobotocore==0.10.4"], + "requirements": ["aiobotocore==0.11.1"], "dependencies": [], "codeowners": ["@awarecan", "@robbiet480"] } diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json index ecf7b552bba484..58f5c0e4ad251d 100644 --- a/homeassistant/components/axis/.translations/ca.json +++ b/homeassistant/components/axis/.translations/ca.json @@ -9,7 +9,7 @@ }, "error": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", + "already_in_progress": "El flux de dades de configuraci\u00f3 pel dispositiu ja est\u00e0 en curs.", "device_unavailable": "El dispositiu no est\u00e0 disponible", "faulty_credentials": "Credencials d'usuari incorrectes" }, diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json index abc1e2f17ec88f..1f00800422cb63 100644 --- a/homeassistant/components/axis/.translations/en.json +++ b/homeassistant/components/axis/.translations/en.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Device is already configured", - "bad_config_file": "Bad data from config file", + "bad_config_file": "Bad data from configuration file", "link_local_address": "Link local addresses are not supported", "not_axis_device": "Discovered device not an Axis device", "updated_configuration": "Updated device configuration with new host address" diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/axis/.translations/hu.json index 41dd3c00d2b32d..4f05087cad8bf9 100644 --- a/homeassistant/components/axis/.translations/hu.json +++ b/homeassistant/components/axis/.translations/hu.json @@ -1,10 +1,14 @@ { "config": { + "abort": { + "updated_configuration": "Friss\u00edtett eszk\u00f6zkonfigur\u00e1ci\u00f3 \u00faj \u00e1llom\u00e1sc\u00edmmel" + }, "error": { "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk", "device_unavailable": "Az eszk\u00f6z nem \u00e9rhet\u0151 el", "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok" }, + "flow_title": "Axis eszk\u00f6z: {name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json index e471ae3ea7a007..3f1aa97f2661c6 100644 --- a/homeassistant/components/axis/.translations/ko.json +++ b/homeassistant/components/axis/.translations/ko.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc5d0 \uc798\ubabb\ub41c \ub370\uc774\ud130\uac00 \uc788\uc2b5\ub2c8\ub2e4", "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4", "updated_configuration": "\uc0c8\ub85c\uc6b4 \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub85c \uc5c5\ub370\uc774\ud2b8\ub41c \uae30\uae30 \uad6c\uc131" diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json index 10fc8c02d66ad6..b512690e2a3649 100644 --- a/homeassistant/components/axis/.translations/nl.json +++ b/homeassistant/components/axis/.translations/nl.json @@ -4,7 +4,8 @@ "already_configured": "Apparaat is al geconfigureerd", "bad_config_file": "Slechte gegevens van het configuratiebestand", "link_local_address": "Link-lokale adressen worden niet ondersteund", - "not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat" + "not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat", + "updated_configuration": "Bijgewerkte apparaatconfiguratie met nieuw hostadres" }, "error": { "already_configured": "Apparaat is al geconfigureerd", diff --git a/homeassistant/components/axis/.translations/no.json b/homeassistant/components/axis/.translations/no.json index 60db56146fa389..32e1cc2fd403c5 100644 --- a/homeassistant/components/axis/.translations/no.json +++ b/homeassistant/components/axis/.translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "bad_config_file": "D\u00e5rlig data fra konfigurasjonsfilen", + "bad_config_file": "D\u00e5rlige data fra konfigurasjonsfilen", "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke", "not_axis_device": "Oppdaget enhet ikke en Axis enhet", "updated_configuration": "Oppdatert enhetskonfigurasjonen med ny vertsadresse" diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json index d5deb327a75581..9f7bb55147dd41 100644 --- a/homeassistant/components/axis/.translations/pl.json +++ b/homeassistant/components/axis/.translations/pl.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", "bad_config_file": "B\u0142\u0119dne dane z pliku konfiguracyjnego", "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane", "not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis", "updated_configuration": "Zaktualizowano konfiguracj\u0119 urz\u0105dzenia o nowy adres hosta" }, "error": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "already_in_progress": "Konfiguracja urz\u0105dzenia jest ju\u017c w toku.", "device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne", "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce" }, diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json index a38ef2ef745401..59761c7202fb73 100644 --- a/homeassistant/components/axis/.translations/sv.json +++ b/homeassistant/components/axis/.translations/sv.json @@ -2,9 +2,10 @@ "config": { "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", - "bad_config_file": "Felaktig data fr\u00e5n config fil", + "bad_config_file": "Felaktig data fr\u00e5n konfigurationsfilen", "link_local_address": "Link local addresses are not supported", - "not_axis_device": "Uppt\u00e4ckte enhet som inte \u00e4r en Axis enhet" + "not_axis_device": "Uppt\u00e4ckte enhet som inte \u00e4r en Axis enhet", + "updated_configuration": "Uppdaterad enhetskonfiguration med ny v\u00e4rdadress" }, "error": { "already_configured": "Enheten \u00e4r redan konfigurerad", @@ -12,6 +13,7 @@ "device_unavailable": "Enheten \u00e4r inte tillg\u00e4nglig", "faulty_credentials": "Felaktiga anv\u00e4ndaruppgifter" }, + "flow_title": "Axisenhet: {name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json index 751a75442024c3..ac552afe583a85 100644 --- a/homeassistant/components/axis/.translations/zh-Hant.json +++ b/homeassistant/components/axis/.translations/zh-Hant.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548", + "bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548\u932f\u8aa4", "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740", "not_axis_device": "\u6240\u767c\u73fe\u7684\u8a2d\u5099\u4e26\u975e Axis \u8a2d\u5099", "updated_configuration": "\u4f7f\u7528\u65b0\u4e3b\u6a5f\u7aef\u4f4d\u5740\u66f4\u65b0\u88dd\u7f6e\u8a2d\u5b9a" diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 5c928aa9f31de7..5294e30ed6f54f 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -1,15 +1,23 @@ """Support for Axis devices.""" +import logging + from homeassistant.const import ( CONF_DEVICE, + CONF_HOST, CONF_MAC, + CONF_PASSWORD, + CONF_PORT, CONF_TRIGGER_TIME, + CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN from .device import AxisNetworkDevice, get_device +LOGGER = logging.getLogger(__name__) + async def async_setup(hass, config): """Old way to set up Axis devices.""" @@ -35,7 +43,7 @@ async def async_setup_entry(hass, config_entry): config_entry, unique_id=device.api.vapix.params.system_serialnumber ) - hass.data[DOMAIN][device.serial] = device + hass.data[DOMAIN][config_entry.unique_id] = device await device.async_update_device_registry() @@ -52,7 +60,13 @@ async def async_unload_entry(hass, config_entry): async def async_populate_options(hass, config_entry): """Populate default options for device.""" - device = await get_device(hass, config_entry.data[CONF_DEVICE]) + device = await get_device( + hass, + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + ) supported_formats = device.vapix.params.image_format camera = bool(supported_formats) @@ -64,3 +78,18 @@ async def async_populate_options(hass, config_entry): } hass.config_entries.async_update_entry(config_entry, options=options) + + +async def async_migrate_entry(hass, config_entry): + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", config_entry.version) + + # Flatten configuration but keep old data if user rollbacks HASS + if config_entry.version == 1: + config_entry.data = {**config_entry.data, **config_entry.data[CONF_DEVICE]} + + config_entry.version = 2 + + LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index b3593179ffc417..d7551abebc13b8 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -79,8 +79,8 @@ def name(self): and self.event.id and self.device.api.vapix.ports[self.event.id].name ): - return "{} {}".format( - self.device.name, self.device.api.vapix.ports[self.event.id].name + return ( + f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}" ) return super().name diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 6b82c938a99674..3cf84ce2288545 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -9,7 +9,6 @@ ) from homeassistant.const import ( CONF_AUTHENTICATION, - CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -22,10 +21,6 @@ from .axis_base import AxisEntityBase from .const import DOMAIN as AXIS_DOMAIN -AXIS_IMAGE = "http://{}:{}/axis-cgi/jpg/image.cgi" -AXIS_VIDEO = "http://{}:{}/axis-cgi/mjpg/video.cgi" -AXIS_STREAM = "rtsp://{}:{}@{}/axis-media/media.amp?videocodec=h264" - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Axis camera video stream.""" @@ -35,15 +30,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config = { CONF_NAME: config_entry.data[CONF_NAME], - CONF_USERNAME: config_entry.data[CONF_DEVICE][CONF_USERNAME], - CONF_PASSWORD: config_entry.data[CONF_DEVICE][CONF_PASSWORD], - CONF_MJPEG_URL: AXIS_VIDEO.format( - config_entry.data[CONF_DEVICE][CONF_HOST], - config_entry.data[CONF_DEVICE][CONF_PORT], + CONF_USERNAME: config_entry.data[CONF_USERNAME], + CONF_PASSWORD: config_entry.data[CONF_PASSWORD], + CONF_MJPEG_URL: ( + f"http://{config_entry.data[CONF_HOST]}" + f":{config_entry.data[CONF_PORT]}/axis-cgi/mjpg/video.cgi" ), - CONF_STILL_IMAGE_URL: AXIS_IMAGE.format( - config_entry.data[CONF_DEVICE][CONF_HOST], - config_entry.data[CONF_DEVICE][CONF_PORT], + CONF_STILL_IMAGE_URL: ( + f"http://{config_entry.data[CONF_HOST]}" + f":{config_entry.data[CONF_PORT]}/axis-cgi/jpg/image.cgi" ), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } @@ -75,17 +70,19 @@ def supported_features(self): async def stream_source(self): """Return the stream source.""" - return AXIS_STREAM.format( - self.device.config_entry.data[CONF_DEVICE][CONF_USERNAME], - self.device.config_entry.data[CONF_DEVICE][CONF_PASSWORD], - self.device.host, + return ( + f"rtsp://{self.device.config_entry.data[CONF_USERNAME]}´" + f":{self.device.config_entry.data[CONF_PASSWORD]}" + f"@{self.device.host}/axis-media/media.amp?videocodec=h264" ) def _new_address(self): """Set new device address for video stream.""" - port = self.device.config_entry.data[CONF_DEVICE][CONF_PORT] - self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port) - self._still_image_url = AXIS_IMAGE.format(self.device.host, port) + port = self.device.config_entry.data[CONF_PORT] + self._mjpeg_url = (f"http://{self.device.host}:{port}/axis-cgi/mjpg/video.cgi",) + self._still_image_url = ( + f"http://{self.device.host}:{port}/axis-cgi/jpg/image.cgi" + ) @property def unique_id(self): diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 88c1cab98c1516..29658c19c5b7e3 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -4,7 +4,6 @@ from homeassistant import config_entries from homeassistant.const import ( - CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, @@ -33,16 +32,12 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Axis config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH def __init__(self): """Initialize the Axis config flow.""" self.device_config = {} - self.model = None - self.name = None - self.serial_number = None - self.discovery_schema = {} self.import_schema = {} @@ -55,24 +50,32 @@ async def async_step_user(self, user_input=None): if user_input is not None: try: + device = await get_device( + self.hass, + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + serial_number = device.vapix.params.system_serialnumber + await self.async_set_unique_id(serial_number) + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + self.device_config = { CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_MAC: serial_number, + CONF_MODEL: device.vapix.params.prodnbr, } - device = await get_device(self.hass, self.device_config) - - self.serial_number = device.vapix.params.system_serialnumber - config_entry = await self.async_set_unique_id(self.serial_number) - if config_entry: - return self._update_entry( - config_entry, - host=user_input[CONF_HOST], - port=user_input[CONF_PORT], - ) - - self.model = device.vapix.params.prodnbr return await self._create_entry() @@ -101,41 +104,23 @@ async def _create_entry(self): Generate a name to be used as a prefix for device entities. """ + model = self.device_config[CONF_MODEL] same_model = [ entry.data[CONF_NAME] for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_MODEL] == self.model + if entry.data[CONF_MODEL] == model ] - self.name = f"{self.model}" + name = model for idx in range(len(same_model) + 1): - self.name = f"{self.model} {idx}" - if self.name not in same_model: + name = f"{model} {idx}" + if name not in same_model: break - data = { - CONF_DEVICE: self.device_config, - CONF_NAME: self.name, - CONF_MAC: self.serial_number, - CONF_MODEL: self.model, - } - - title = f"{self.model} - {self.serial_number}" - return self.async_create_entry(title=title, data=data) - - def _update_entry(self, entry, host, port): - """Update existing entry.""" - if ( - entry.data[CONF_DEVICE][CONF_HOST] == host - and entry.data[CONF_DEVICE][CONF_PORT] == port - ): - return self.async_abort(reason="already_configured") - - entry.data[CONF_DEVICE][CONF_HOST] = host - entry.data[CONF_DEVICE][CONF_PORT] = port + self.device_config[CONF_NAME] = name - self.hass.config_entries.async_update_entry(entry) - return self.async_abort(reason="updated_configuration") + title = f"{model} - {self.device_config[CONF_MAC]}" + return self.async_create_entry(title=title, data=self.device_config) async def async_step_zeroconf(self, discovery_info): """Prepare configuration for a discovered Axis device.""" @@ -147,18 +132,19 @@ async def async_step_zeroconf(self, discovery_info): if discovery_info[CONF_HOST].startswith("169.254"): return self.async_abort(reason="link_local_address") - config_entry = await self.async_set_unique_id(serial_number) - if config_entry: - return self._update_entry( - config_entry, - host=discovery_info[CONF_HOST], - port=discovery_info[CONF_PORT], - ) + await self.async_set_unique_id(serial_number) + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: discovery_info[CONF_HOST], + CONF_PORT: discovery_info[CONF_PORT], + } + ) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = { - "name": discovery_info["hostname"][:-7], - "host": discovery_info[CONF_HOST], + CONF_NAME: discovery_info["hostname"][:-7], + CONF_HOST: discovery_info[CONF_HOST], } self.discovery_schema = { diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 85ad59268df27d..a204136e018c69 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -7,9 +7,7 @@ from axis.streammanager import SIGNAL_PLAYING from homeassistant.const import ( - CONF_DEVICE, CONF_HOST, - CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, @@ -42,7 +40,7 @@ def __init__(self, hass, config_entry): @property def host(self): """Return the host of this device.""" - return self.config_entry.data[CONF_DEVICE][CONF_HOST] + return self.config_entry.data[CONF_HOST] @property def model(self): @@ -75,7 +73,13 @@ async def async_update_device_registry(self): async def async_setup(self): """Set up the device.""" try: - self.api = await get_device(self.hass, self.config_entry.data[CONF_DEVICE]) + self.api = await get_device( + self.hass, + host=self.config_entry.data[CONF_HOST], + port=self.config_entry.data[CONF_PORT], + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + ) except CannotConnect: raise ConfigEntryNotReady @@ -126,7 +130,7 @@ async def async_new_address_callback(hass, entry): This is a static method because a class method (bound method), can not be used with weak references. """ - device = hass.data[DOMAIN][entry.data[CONF_MAC]] + device = hass.data[DOMAIN][entry.unique_id] device.api.config.host = device.host async_dispatcher_send(hass, device.event_new_address) @@ -197,15 +201,15 @@ async def async_reset(self): return True -async def get_device(hass, config): +async def get_device(hass, host, port, username, password): """Create a Axis device.""" device = axis.AxisDevice( loop=hass.loop, - host=config[CONF_HOST], - username=config[CONF_USERNAME], - password=config[CONF_PASSWORD], - port=config[CONF_PORT], + host=host, + port=port, + username=username, + password=password, web_proto="http", ) @@ -224,13 +228,11 @@ async def get_device(hass, config): return device except axis.Unauthorized: - LOGGER.warning( - "Connected to device at %s but not registered.", config[CONF_HOST] - ) + LOGGER.warning("Connected to device at %s but not registered.", host) raise AuthenticationRequired except (asyncio.TimeoutError, axis.RequestError): - LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST]) + LOGGER.error("Error connecting to the Axis device at %s", host) raise CannotConnect except axis.AxisException: diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 7facd7060adf61..04a9f9e388a915 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -1,30 +1,29 @@ { - "config": { - "title": "Axis device", - "flow_title": "Axis device: {name} ({host})", - "step": { - "user": { - "title": "Set up Axis device", - "data": { - "host": "Host", - "username": "Username", - "password": "Password", - "port": "Port" - } - } - }, - "error": { - "already_configured": "Device is already configured", - "already_in_progress": "Config flow for device is already in progress.", - "device_unavailable": "Device is not available", - "faulty_credentials": "Bad user credentials" - }, - "abort": { - "already_configured": "Device is already configured", - "bad_config_file": "Bad data from config file", - "link_local_address": "Link local addresses are not supported", - "not_axis_device": "Discovered device not an Axis device", - "updated_configuration": "Updated device configuration with new host address" + "config": { + "title": "Axis device", + "flow_title": "Axis device: {name} ({host})", + "step": { + "user": { + "title": "Set up Axis device", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port" } + } + }, + "error": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "device_unavailable": "Device is not available", + "faulty_credentials": "Bad user credentials" + }, + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from configuration file", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" } + } } diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py index a83460bc5296db..ed822543a00915 100644 --- a/homeassistant/components/axis/switch.py +++ b/homeassistant/components/axis/switch.py @@ -53,8 +53,8 @@ async def async_turn_off(self, **kwargs): def name(self): """Return the name of the event.""" if self.event.id and self.device.api.vapix.ports[self.event.id].name: - return "{} {}".format( - self.device.name, self.device.api.vapix.ports[self.event.id].name + return ( + f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}" ) return super().name diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 7e141cd8060cf8..cc59790b6464f1 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -47,8 +47,9 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Activate Azure EH component.""" config = yaml_config[DOMAIN] - event_hub_address = "amqps://{}.servicebus.windows.net/{}".format( - config[CONF_EVENT_HUB_NAMESPACE], config[CONF_EVENT_HUB_INSTANCE_NAME] + event_hub_address = ( + f"amqps://{config[CONF_EVENT_HUB_NAMESPACE]}" + f".servicebus.windows.net/{config[CONF_EVENT_HUB_INSTANCE_NAME]}" ) entities_filter = config[CONF_FILTER] diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 1d3720f6723d14..c2e9e220a20947 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -1,5 +1,6 @@ """Use Bayesian Inference to trigger a binary sensor.""" from collections import OrderedDict +from itertools import chain import voluptuous as vol @@ -21,6 +22,7 @@ from homeassistant.helpers.event import async_track_state_change ATTR_OBSERVATIONS = "observations" +ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities" ATTR_PROBABILITY = "probability" ATTR_PROBABILITY_THRESHOLD = "probability_threshold" @@ -126,6 +128,15 @@ def __init__(self, name, prior, observations, probability_threshold, device_clas self.probability = prior self.current_obs = OrderedDict({}) + self.entity_obs_dict = [] + + for obs in self._observations: + if "entity_id" in obs: + self.entity_obs_dict.append([obs.get("entity_id")]) + if "value_template" in obs: + self.entity_obs_dict.append( + list(obs.get(CONF_VALUE_TEMPLATE).extract_entities()) + ) to_observe = set() for obs in self._observations: @@ -251,6 +262,13 @@ def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_OBSERVATIONS: list(self.current_obs.values()), + ATTR_OCCURRED_OBSERVATION_ENTITIES: list( + set( + chain.from_iterable( + self.entity_obs_dict[obs] for obs in self.current_obs.keys() + ) + ) + ), ATTR_PROBABILITY: round(self.probability, 2), ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, } diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index f5e5865f6f00f1..259066d45618b8 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -11,6 +11,7 @@ ATTR_ATTRIBUTION, CONF_MONITORED_VARIABLES, CONF_NAME, + DATA_RATE_MEGABITS_PER_SECOND, DEVICE_CLASS_TIMESTAMP, ) import homeassistant.helpers.config_validation as cv @@ -20,8 +21,6 @@ _LOGGER = logging.getLogger(__name__) -BANDWIDTH_MEGABITS_SECONDS = "Mb/s" - ATTRIBUTION = "Powered by Bouygues Telecom" DEFAULT_NAME = "Bbox" @@ -32,22 +31,22 @@ SENSOR_TYPES = { "down_max_bandwidth": [ "Maximum Download Bandwidth", - BANDWIDTH_MEGABITS_SECONDS, + DATA_RATE_MEGABITS_PER_SECOND, "mdi:download", ], "up_max_bandwidth": [ "Maximum Upload Bandwidth", - BANDWIDTH_MEGABITS_SECONDS, + DATA_RATE_MEGABITS_PER_SECOND, "mdi:upload", ], "current_down_bandwidth": [ "Currently Used Download Bandwidth", - BANDWIDTH_MEGABITS_SECONDS, + DATA_RATE_MEGABITS_PER_SECOND, "mdi:download", ], "current_up_bandwidth": [ "Currently Used Upload Bandwidth", - BANDWIDTH_MEGABITS_SECONDS, + DATA_RATE_MEGABITS_PER_SECOND, "mdi:upload", ], "uptime": ["Uptime", None, "mdi:clock"], diff --git a/homeassistant/components/binary_sensor/.translations/es-419.json b/homeassistant/components/binary_sensor/.translations/es-419.json index f1c20e5346b510..e727e18775afa6 100644 --- a/homeassistant/components/binary_sensor/.translations/es-419.json +++ b/homeassistant/components/binary_sensor/.translations/es-419.json @@ -28,6 +28,12 @@ "is_not_occupied": "{entity_name} no est\u00e1 ocupado", "is_not_open": "{entity_name} est\u00e1 cerrado", "is_not_plugged_in": "{entity_name} est\u00e1 desconectado", + "is_not_unsafe": "{entity_name} es seguro", + "is_occupied": "{entity_name} est\u00e1 ocupado", + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 encendido", + "is_open": "{entity_name} est\u00e1 abierto", + "is_plugged_in": "{entity_name} est\u00e1 enchufado", "is_powered": "{entity_name} est\u00e1 encendido", "is_present": "{entity_name} est\u00e1 presente", "is_problem": "{entity_name} est\u00e1 detectando un problema", @@ -45,6 +51,7 @@ "hot": "{entity_name} se calent\u00f3", "light": "{entity_name} comenz\u00f3 a detectar luz", "locked": "{entity_name} bloqueado", + "moist": "{entity_name} se humedeci\u00f3", "moist\u00a7": "{entity_name} se humedeci\u00f3", "motion": "{entity_name} comenz\u00f3 a detectar movimiento", "moving": "{entity_name} comenz\u00f3 a moverse", @@ -59,7 +66,22 @@ "not_cold": "{entity_name} no se enfri\u00f3", "not_connected": "{entity_name} desconectado", "not_hot": "{entity_name} no se calent\u00f3", - "not_locked": "{entity_name} desbloqueado" + "not_locked": "{entity_name} desbloqueado", + "not_moist": "{entity_name} se sec\u00f3", + "not_moving": "{entity_name} dej\u00f3 de moverse", + "not_opened": "{entity_name} cerrado", + "not_plugged_in": "{entity_name} desconectado", + "not_present": "{entity_name} no presente", + "not_unsafe": "{entity_name} se volvi\u00f3 seguro", + "occupied": "{entity_name} se ocup\u00f3", + "opened": "{entity_name} abierto", + "plugged_in": "{entity_name} enchufado", + "present": "{entity_name} presente", + "problem": "{entity_name} comenz\u00f3 a detectar problemas", + "smoke": "{entity_name} comenz\u00f3 a detectar humo", + "sound": "{entity_name} comenz\u00f3 a detectar sonido", + "turned_off": "{entity_name} apagado", + "turned_on": "{entity_name} encendido" } } } \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/sv.json b/homeassistant/components/binary_sensor/.translations/sv.json new file mode 100644 index 00000000000000..5df2ce17c92b70 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/sv.json @@ -0,0 +1,94 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name}-batteriet \u00e4r l\u00e5gt", + "is_cold": "{entity_name} \u00e4r kall", + "is_connected": "{entity_name} \u00e4r ansluten", + "is_gas": "{entity_name} detekterar gas", + "is_hot": "{entity_name} \u00e4r varm", + "is_light": "{entity_name} uppt\u00e4cker ljus", + "is_locked": "{entity_name} \u00e4r l\u00e5st", + "is_moist": "{entity_name} \u00e4r fuktig", + "is_motion": "{entity_name} detekterar r\u00f6relse", + "is_moving": "{entity_name} r\u00f6r sig", + "is_no_gas": "{entity_name} uppt\u00e4cker inte gas", + "is_no_light": "{entity_name} uppt\u00e4cker inte ljus", + "is_no_motion": "{entity_name} detekterar inte r\u00f6relse", + "is_no_problem": "{entity_name} uppt\u00e4cker inte problem", + "is_no_smoke": "{entity_name} detekterar inte r\u00f6k", + "is_no_sound": "{entity_name} uppt\u00e4cker inte ljud", + "is_no_vibration": "{entity_name} uppt\u00e4cker inte vibrationer", + "is_not_bat_low": "{entity_name} batteri \u00e4r normalt", + "is_not_cold": "{entity_name} \u00e4r inte kall", + "is_not_connected": "{entity_name} \u00e4r fr\u00e5nkopplad", + "is_not_hot": "{entity_name} \u00e4r inte varm", + "is_not_locked": "{entity_name} \u00e4r ol\u00e5st", + "is_not_moist": "{entity_name} \u00e4r torr", + "is_not_moving": "{entity_name} r\u00f6r sig inte", + "is_not_occupied": "{entity_name} \u00e4r inte upptagen", + "is_not_open": "{entity_name} \u00e4r st\u00e4ngd", + "is_not_plugged_in": "{entity_name} \u00e4r urkopplad", + "is_not_powered": "{entity_name} \u00e4r inte str\u00f6mf\u00f6rd", + "is_not_present": "{entity_name} finns inte", + "is_not_unsafe": "{entity_name} \u00e4r s\u00e4ker", + "is_occupied": "{entity_name} \u00e4r upptagen", + "is_off": "{entity_name} \u00e4r avst\u00e4ngd", + "is_on": "{entity_name} \u00e4r p\u00e5", + "is_open": "{entity_name} \u00e4r \u00f6ppen", + "is_plugged_in": "{entity_name} \u00e4r ansluten", + "is_powered": "{entity_name} \u00e4r str\u00f6mf\u00f6rd", + "is_present": "{entity_name} \u00e4r n\u00e4rvarande", + "is_problem": "{entity_name} uppt\u00e4cker problem", + "is_smoke": "{entity_name} detekterar r\u00f6k", + "is_sound": "{entity_name} uppt\u00e4cker ljud", + "is_unsafe": "{entity_name} \u00e4r os\u00e4ker", + "is_vibration": "{entity_name} uppt\u00e4cker vibrationer" + }, + "trigger_type": { + "bat_low": "{entity_name} batteri l\u00e5gt", + "closed": "{entity_name} st\u00e4ngd", + "cold": "{entity_name} blev kall", + "connected": "{entity_name} ansluten", + "gas": "{entity_name} b\u00f6rjade detektera gas", + "hot": "{entity_name} blev varm", + "light": "{entity_name} b\u00f6rjade uppt\u00e4cka ljus", + "locked": "{entity_name} l\u00e5st", + "moist": "{entity_name} blev fuktig", + "moist\u00a7": "{entity_name} blev fuktig", + "motion": "{entity_name} b\u00f6rjade detektera r\u00f6relse", + "moving": "{entity_name} b\u00f6rjade r\u00f6ra sig", + "no_gas": "{entity_name} slutade uppt\u00e4cka gas", + "no_light": "{entity_name} slutade uppt\u00e4cka ljus", + "no_motion": "{entity_name} slutade uppt\u00e4cka r\u00f6relse", + "no_problem": "{entity_name} slutade uppt\u00e4cka problem", + "no_smoke": "{entity_name} slutade detektera r\u00f6k", + "no_sound": "{entity_name} slutade uppt\u00e4cka ljud", + "no_vibration": "{entity_name} slutade uppt\u00e4cka vibrationer", + "not_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} blev inte kall", + "not_connected": "{entity_name} fr\u00e5nkopplad", + "not_hot": "{entity_name} blev inte varm", + "not_locked": "{entity_name} ol\u00e5st", + "not_moist": "{entity_name} blev torr", + "not_moving": "{entity_name} slutade r\u00f6ra sig", + "not_occupied": "{entity_name} blev inte upptagen", + "not_opened": "{entity_name} st\u00e4ngd", + "not_plugged_in": "{entity_name} urkopplad", + "not_powered": "{entity_name} inte str\u00f6mf\u00f6rd", + "not_present": "{entity_name} inte n\u00e4rvarande", + "not_unsafe": "{entity_name} blev s\u00e4ker", + "occupied": "{entity_name} blev upptagen", + "opened": "{entity_name} \u00f6ppnades", + "plugged_in": "{entity_name} ansluten", + "powered": "{entity_name} str\u00f6mf\u00f6rd", + "present": "{entity_name} n\u00e4rvarande", + "problem": "{entity_name} b\u00f6rjade uppt\u00e4cka problem", + "smoke": "{entity_name} b\u00f6rjade detektera r\u00f6k", + "sound": "{entity_name} b\u00f6rjade uppt\u00e4cka ljud", + "turned_off": "{entity_name} st\u00e4ngdes av", + "turned_on": "{entity_name} slogs p\u00e5", + "unsafe": "{entity_name} blev os\u00e4ker", + "vibration": "{entity_name} b\u00f6rjade detektera vibrationer" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/zh-Hant.json b/homeassistant/components/binary_sensor/.translations/zh-Hant.json index 046b999cb8c08c..712c0fbd7c1bf2 100644 --- a/homeassistant/components/binary_sensor/.translations/zh-Hant.json +++ b/homeassistant/components/binary_sensor/.translations/zh-Hant.json @@ -1,94 +1,94 @@ { "device_automation": { "condition_type": { - "is_bat_low": "{entity_name} \u96fb\u91cf\u904e\u4f4e", - "is_cold": "{entity_name} \u51b7", - "is_connected": "{entity_name} \u5df2\u9023\u7dda", - "is_gas": "{entity_name} \u5075\u6e2c\u5230\u6c23\u9ad4", - "is_hot": "{entity_name} \u71b1", - "is_light": "{entity_name} \u5075\u6e2c\u5230\u5149\u7dda\u4e2d", - "is_locked": "{entity_name} \u5df2\u4e0a\u9396", - "is_moist": "{entity_name} \u6f6e\u6fd5", - "is_motion": "{entity_name} \u5075\u6e2c\u5230\u52d5\u4f5c\u4e2d", - "is_moving": "{entity_name} \u79fb\u52d5\u4e2d", - "is_no_gas": "{entity_name} \u672a\u5075\u6e2c\u5230\u6c23\u9ad4", - "is_no_light": "{entity_name} \u672a\u5075\u6e2c\u5230\u5149\u7dda", - "is_no_motion": "{entity_name} \u672a\u5075\u6e2c\u5230\u52d5\u4f5c", - "is_no_problem": "{entity_name} \u672a\u5075\u6e2c\u5230\u554f\u984c", - "is_no_smoke": "{entity_name} \u672a\u5075\u6e2c\u5230\u7159\u9727", - "is_no_sound": "{entity_name} \u672a\u5075\u6e2c\u5230\u8072\u97f3", - "is_no_vibration": "{entity_name} \u672a\u5075\u6e2c\u5230\u9707\u52d5", - "is_not_bat_low": "{entity_name} \u96fb\u91cf\u6b63\u5e38", - "is_not_cold": "{entity_name} \u4e0d\u51b7", - "is_not_connected": "{entity_name} \u65b7\u7dda", - "is_not_hot": "{entity_name} \u4e0d\u71b1", - "is_not_locked": "{entity_name} \u89e3\u9396", - "is_not_moist": "{entity_name} \u4e7e\u71e5", - "is_not_moving": "{entity_name} \u672a\u5728\u79fb\u52d5", - "is_not_occupied": "{entity_name} \u672a\u6709\u4eba", - "is_not_open": "{entity_name} \u95dc\u9589", - "is_not_plugged_in": "{entity_name} \u672a\u63d2\u5165", - "is_not_powered": "{entity_name} \u672a\u901a\u96fb", - "is_not_present": "{entity_name} \u672a\u51fa\u73fe", - "is_not_unsafe": "{entity_name} \u5b89\u5168", - "is_occupied": "{entity_name} \u6709\u4eba", - "is_off": "{entity_name} \u95dc\u9589", - "is_on": "{entity_name} \u958b\u555f", - "is_open": "{entity_name} \u958b\u555f", - "is_plugged_in": "{entity_name} \u63d2\u5165", - "is_powered": "{entity_name} \u901a\u96fb", - "is_present": "{entity_name} \u51fa\u73fe", - "is_problem": "{entity_name} \u6b63\u5075\u6e2c\u5230\u554f\u984c", - "is_smoke": "{entity_name} \u6b63\u5075\u6e2c\u5230\u7159\u9727", - "is_sound": "{entity_name} \u6b63\u5075\u6e2c\u5230\u8072\u97f3", - "is_unsafe": "{entity_name} \u4e0d\u5b89\u5168", - "is_vibration": "{entity_name} \u6b63\u5075\u6e2c\u5230\u9707\u52d5" + "is_bat_low": "{entity_name}\u96fb\u91cf\u904e\u4f4e", + "is_cold": "{entity_name}\u51b7", + "is_connected": "{entity_name}\u5df2\u9023\u7dda", + "is_gas": "{entity_name}\u5075\u6e2c\u5230\u6c23\u9ad4", + "is_hot": "{entity_name}\u71b1", + "is_light": "{entity_name}\u5075\u6e2c\u5230\u5149\u7dda\u4e2d", + "is_locked": "{entity_name}\u5df2\u4e0a\u9396", + "is_moist": "{entity_name}\u6f6e\u6fd5", + "is_motion": "{entity_name}\u5075\u6e2c\u5230\u52d5\u4f5c\u4e2d", + "is_moving": "{entity_name}\u79fb\u52d5\u4e2d", + "is_no_gas": "{entity_name}\u672a\u5075\u6e2c\u5230\u6c23\u9ad4", + "is_no_light": "{entity_name}\u672a\u5075\u6e2c\u5230\u5149\u7dda", + "is_no_motion": "{entity_name}\u672a\u5075\u6e2c\u5230\u52d5\u4f5c", + "is_no_problem": "{entity_name}\u672a\u5075\u6e2c\u5230\u554f\u984c", + "is_no_smoke": "{entity_name}\u672a\u5075\u6e2c\u5230\u7159\u9727", + "is_no_sound": "{entity_name}\u672a\u5075\u6e2c\u5230\u8072\u97f3", + "is_no_vibration": "{entity_name}\u672a\u5075\u6e2c\u5230\u9707\u52d5", + "is_not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", + "is_not_cold": "{entity_name}\u4e0d\u51b7", + "is_not_connected": "{entity_name}\u65b7\u7dda", + "is_not_hot": "{entity_name}\u4e0d\u71b1", + "is_not_locked": "{entity_name}\u89e3\u9396", + "is_not_moist": "{entity_name}\u4e7e\u71e5", + "is_not_moving": "{entity_name}\u672a\u5728\u79fb\u52d5", + "is_not_occupied": "{entity_name}\u672a\u6709\u4eba", + "is_not_open": "{entity_name}\u95dc\u9589", + "is_not_plugged_in": "{entity_name}\u672a\u63d2\u5165", + "is_not_powered": "{entity_name}\u672a\u901a\u96fb", + "is_not_present": "{entity_name}\u672a\u51fa\u73fe", + "is_not_unsafe": "{entity_name}\u5b89\u5168", + "is_occupied": "{entity_name}\u6709\u4eba", + "is_off": "{entity_name}\u95dc\u9589", + "is_on": "{entity_name}\u958b\u555f", + "is_open": "{entity_name}\u958b\u555f", + "is_plugged_in": "{entity_name}\u63d2\u5165", + "is_powered": "{entity_name}\u901a\u96fb", + "is_present": "{entity_name}\u51fa\u73fe", + "is_problem": "{entity_name}\u6b63\u5075\u6e2c\u5230\u554f\u984c", + "is_smoke": "{entity_name}\u6b63\u5075\u6e2c\u5230\u7159\u9727", + "is_sound": "{entity_name}\u6b63\u5075\u6e2c\u5230\u8072\u97f3", + "is_unsafe": "{entity_name}\u4e0d\u5b89\u5168", + "is_vibration": "{entity_name}\u6b63\u5075\u6e2c\u5230\u9707\u52d5" }, "trigger_type": { - "bat_low": "{entity_name} \u96fb\u91cf\u4f4e", - "closed": "{entity_name} \u5df2\u95dc\u9589", - "cold": "{entity_name} \u5df2\u8b8a\u51b7", - "connected": "{entity_name} \u5df2\u9023\u7dda", - "gas": "{entity_name} \u5df2\u958b\u59cb\u5075\u6e2c\u6c23\u9ad4", - "hot": "{entity_name} \u5df2\u8b8a\u71b1", - "light": "{entity_name} \u5df2\u958b\u59cb\u5075\u6e2c\u5149\u7dda", - "locked": "{entity_name} \u5df2\u4e0a\u9396", - "moist": "{entity_name} \u5df2\u8b8a\u6f6e\u6fd5", - "moist\u00a7": "{entity_name} \u5df2\u8b8a\u6f6e\u6fd5", - "motion": "{entity_name} \u5df2\u5075\u6e2c\u5230\u52d5\u4f5c", - "moving": "{entity_name} \u958b\u59cb\u79fb\u52d5", - "no_gas": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u6c23\u9ad4", - "no_light": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u5149\u7dda", - "no_motion": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u52d5\u4f5c", - "no_problem": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", - "no_smoke": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", - "no_sound": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", - "no_vibration": "{entity_name} \u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", - "not_bat_low": "{entity_name} \u96fb\u91cf\u6b63\u5e38", - "not_cold": "{entity_name} \u5df2\u4e0d\u51b7", - "not_connected": "{entity_name} \u5df2\u65b7\u7dda", - "not_hot": "{entity_name} \u5df2\u4e0d\u71b1", - "not_locked": "{entity_name} \u5df2\u89e3\u9396", - "not_moist": "{entity_name} \u5df2\u8b8a\u4e7e", - "not_moving": "{entity_name} \u505c\u6b62\u79fb\u52d5", - "not_occupied": "{entity_name} \u672a\u6709\u4eba", - "not_opened": "{entity_name} \u5df2\u95dc\u9589", - "not_plugged_in": "{entity_name} \u672a\u63d2\u5165", - "not_powered": "{entity_name} \u672a\u901a\u96fb", - "not_present": "{entity_name} \u672a\u51fa\u73fe", - "not_unsafe": "{entity_name} \u5df2\u5b89\u5168", - "occupied": "{entity_name} \u8b8a\u6210\u6709\u4eba", - "opened": "{entity_name} \u5df2\u958b\u555f", - "plugged_in": "{entity_name} \u5df2\u63d2\u5165", - "powered": "{entity_name} \u5df2\u901a\u96fb", - "present": "{entity_name} \u5df2\u51fa\u73fe", - "problem": "{entity_name} \u5df2\u5075\u6e2c\u5230\u554f\u984c", - "smoke": "{entity_name} \u5df2\u5075\u6e2c\u5230\u7159\u9727", - "sound": "{entity_name} \u5df2\u5075\u6e2c\u5230\u8072\u97f3", - "turned_off": "{entity_name} \u5df2\u95dc\u9589", - "turned_on": "{entity_name} \u5df2\u958b\u555f", - "unsafe": "{entity_name} \u5df2\u4e0d\u5b89\u5168", - "vibration": "{entity_name} \u5df2\u5075\u6e2c\u5230\u9707\u52d5" + "bat_low": "{entity_name}\u96fb\u91cf\u4f4e", + "closed": "{entity_name}\u5df2\u95dc\u9589", + "cold": "{entity_name}\u5df2\u8b8a\u51b7", + "connected": "{entity_name}\u5df2\u9023\u7dda", + "gas": "{entity_name}\u5df2\u958b\u59cb\u5075\u6e2c\u6c23\u9ad4", + "hot": "{entity_name}\u5df2\u8b8a\u71b1", + "light": "{entity_name}\u5df2\u958b\u59cb\u5075\u6e2c\u5149\u7dda", + "locked": "{entity_name}\u5df2\u4e0a\u9396", + "moist": "{entity_name}\u5df2\u8b8a\u6f6e\u6fd5", + "moist\u00a7": "{entity_name}\u5df2\u8b8a\u6f6e\u6fd5", + "motion": "{entity_name}\u5df2\u5075\u6e2c\u5230\u52d5\u4f5c", + "moving": "{entity_name}\u958b\u59cb\u79fb\u52d5", + "no_gas": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u6c23\u9ad4", + "no_light": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u5149\u7dda", + "no_motion": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u52d5\u4f5c", + "no_problem": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", + "no_smoke": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", + "no_sound": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", + "no_vibration": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", + "not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", + "not_cold": "{entity_name}\u5df2\u4e0d\u51b7", + "not_connected": "{entity_name}\u5df2\u65b7\u7dda", + "not_hot": "{entity_name}\u5df2\u4e0d\u71b1", + "not_locked": "{entity_name}\u5df2\u89e3\u9396", + "not_moist": "{entity_name}\u5df2\u8b8a\u4e7e", + "not_moving": "{entity_name}\u505c\u6b62\u79fb\u52d5", + "not_occupied": "{entity_name}\u672a\u6709\u4eba", + "not_opened": "{entity_name}\u5df2\u95dc\u9589", + "not_plugged_in": "{entity_name}\u672a\u63d2\u5165", + "not_powered": "{entity_name}\u672a\u901a\u96fb", + "not_present": "{entity_name}\u672a\u51fa\u73fe", + "not_unsafe": "{entity_name}\u5df2\u5b89\u5168", + "occupied": "{entity_name}\u8b8a\u6210\u6709\u4eba", + "opened": "{entity_name}\u5df2\u958b\u555f", + "plugged_in": "{entity_name}\u5df2\u63d2\u5165", + "powered": "{entity_name}\u5df2\u901a\u96fb", + "present": "{entity_name}\u5df2\u51fa\u73fe", + "problem": "{entity_name}\u5df2\u5075\u6e2c\u5230\u554f\u984c", + "smoke": "{entity_name}\u5df2\u5075\u6e2c\u5230\u7159\u9727", + "sound": "{entity_name}\u5df2\u5075\u6e2c\u5230\u8072\u97f3", + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f", + "unsafe": "{entity_name}\u5df2\u4e0d\u5b89\u5168", + "vibration": "{entity_name}\u5df2\u5075\u6e2c\u5230\u9707\u52d5" } } } \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index aa9a9d25e7210a..cb98ec90b5d681 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -1,11 +1,11 @@ -"""Implemenet device conditions for binary sensor.""" +"""Implement device conditions for binary sensor.""" from typing import Dict, List import voluptuous as vol from homeassistant.components.device_automation.const import CONF_IS_OFF, CONF_IS_ON from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_registry import ( async_entries_for_device, @@ -232,6 +232,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index bc8394d51a5806..a488fa1e2fafbf 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -1,4 +1,4 @@ -"""Bitcoin information service that uses blockchain.info.""" +"""Bitcoin information service that uses blockchain.com.""" from datetime import timedelta import logging @@ -6,13 +6,19 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_OPTIONS +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_CURRENCY, + CONF_DISPLAY_OPTIONS, + TIME_MINUTES, + TIME_SECONDS, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by blockchain.info" +ATTRIBUTION = "Data provided by blockchain.com" DEFAULT_CURRENCY = "USD" @@ -27,9 +33,9 @@ "btc_mined": ["Mined", "BTC"], "trade_volume_usd": ["Trade volume", "USD"], "difficulty": ["Difficulty", None], - "minutes_between_blocks": ["Time between Blocks", "min"], + "minutes_between_blocks": ["Time between Blocks", TIME_MINUTES], "number_of_transactions": ["No. of Transactions", None], - "hash_rate": ["Hash rate", "PH/s"], + "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"], "timestamp": ["Timestamp", None], "mined_blocks": ["Mined Blocks", None], "blocks_size": ["Block size", None], @@ -118,45 +124,45 @@ def update(self): self._state = ticker[self._currency].p15min self._unit_of_measurement = self._currency elif self.type == "trade_volume_btc": - self._state = "{0:.1f}".format(stats.trade_volume_btc) + self._state = f"{stats.trade_volume_btc:.1f}" elif self.type == "miners_revenue_usd": - self._state = "{0:.0f}".format(stats.miners_revenue_usd) + self._state = f"{stats.miners_revenue_usd:.0f}" elif self.type == "btc_mined": - self._state = "{}".format(stats.btc_mined * 0.00000001) + self._state = str(stats.btc_mined * 0.00000001) elif self.type == "trade_volume_usd": - self._state = "{0:.1f}".format(stats.trade_volume_usd) + self._state = f"{stats.trade_volume_usd:.1f}" elif self.type == "difficulty": - self._state = "{0:.0f}".format(stats.difficulty) + self._state = f"{stats.difficulty:.0f}" elif self.type == "minutes_between_blocks": - self._state = "{0:.2f}".format(stats.minutes_between_blocks) + self._state = f"{stats.minutes_between_blocks:.2f}" elif self.type == "number_of_transactions": - self._state = "{}".format(stats.number_of_transactions) + self._state = str(stats.number_of_transactions) elif self.type == "hash_rate": - self._state = "{0:.1f}".format(stats.hash_rate * 0.000001) + self._state = f"{stats.hash_rate * 0.000001:.1f}" elif self.type == "timestamp": self._state = stats.timestamp elif self.type == "mined_blocks": - self._state = "{}".format(stats.mined_blocks) + self._state = str(stats.mined_blocks) elif self.type == "blocks_size": - self._state = "{0:.1f}".format(stats.blocks_size) + self._state = f"{stats.blocks_size:.1f}" elif self.type == "total_fees_btc": - self._state = "{0:.2f}".format(stats.total_fees_btc * 0.00000001) + self._state = f"{stats.total_fees_btc * 0.00000001:.2f}" elif self.type == "total_btc_sent": - self._state = "{0:.2f}".format(stats.total_btc_sent * 0.00000001) + self._state = f"{stats.total_btc_sent * 0.00000001:.2f}" elif self.type == "estimated_btc_sent": - self._state = "{0:.2f}".format(stats.estimated_btc_sent * 0.00000001) + self._state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" elif self.type == "total_btc": - self._state = "{0:.2f}".format(stats.total_btc * 0.00000001) + self._state = f"{stats.total_btc * 0.00000001:.2f}" elif self.type == "total_blocks": - self._state = "{0:.0f}".format(stats.total_blocks) + self._state = f"{stats.total_blocks:.0f}" elif self.type == "next_retarget": - self._state = "{0:.2f}".format(stats.next_retarget) + self._state = f"{stats.next_retarget:.2f}" elif self.type == "estimated_transaction_volume_usd": - self._state = "{0:.2f}".format(stats.estimated_transaction_volume_usd) + self._state = f"{stats.estimated_transaction_volume_usd:.2f}" elif self.type == "miners_revenue_btc": - self._state = "{0:.1f}".format(stats.miners_revenue_btc * 0.00000001) + self._state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" elif self.type == "market_price_usd": - self._state = "{0:.2f}".format(stats.market_price_usd) + self._state = f"{stats.market_price_usd:.2f}" class BitcoinData: @@ -168,7 +174,7 @@ def __init__(self): self.ticker = None def update(self): - """Get the latest data from blockchain.info.""" + """Get the latest data from blockchain.com.""" self.stats = statistics.get() self.ticker = exchangerates.get_ticker() diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 931fbbb834ddc1..c58873473d5c39 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -62,7 +62,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of the sensor.""" - return "minutes" + return TIME_MINUTES def update(self): """Get the latest data from the webservice.""" diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json index f57e91a92624ea..324abf792dfdd4 100644 --- a/homeassistant/components/blockchain/manifest.json +++ b/homeassistant/components/blockchain/manifest.json @@ -1,6 +1,6 @@ { "domain": "blockchain", - "name": "Blockchain.info", + "name": "Blockchain.com", "documentation": "https://www.home-assistant.io/integrations/blockchain", "requirements": ["python-blockchain-api==0.0.2"], "dependencies": [], diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 6d17484bdd7f43..acf86957957809 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -1,4 +1,4 @@ -"""Support for Blockchain.info sensors.""" +"""Support for Blockchain.com sensors.""" from datetime import timedelta import logging @@ -12,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by blockchain.info" +ATTRIBUTION = "Data provided by blockchain.com" CONF_ADDRESSES = "addresses" @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Blockchain.info sensors.""" + """Set up the Blockchain.com sensors.""" addresses = config.get(CONF_ADDRESSES) name = config.get(CONF_NAME) @@ -45,7 +45,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlockchainSensor(Entity): - """Representation of a Blockchain.info sensor.""" + """Representation of a Blockchain.com sensor.""" def __init__(self, name, addresses): """Initialize the sensor.""" diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index cc6562a0bc1250..516fa42cb5c85d 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -40,7 +40,7 @@ def __init__(self, bs, device, sensor_name): self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) + self._name = f"{device['DeviceName']} {sensor_name}" self._state = None self._unique_id = f"{self._device_id}-{self._sensor_name}" diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 84871b7b30e5d8..2ffdb8efab0f78 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -70,7 +70,7 @@ def __init__(self, bs, device, sensor_name): self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) + self._name = f"{device['DeviceName']} {sensor_name}" self._state = None self._unique_id = f"{self._device_id}-{self._sensor_name}" diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 04ba21555d4848..3ca9cb1f6231cf 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -421,7 +421,7 @@ async def async_update_status(self): # sync_status. We will force an update if the player is # grouped this isn't a foolproof solution. A better # solution would be to fetch sync_status more often when - # the device is playing. This would solve alot of + # the device is playing. This would solve a lot of # problems. This change will be done when the # communication is moved to a separate library await self.force_update_sync_status() @@ -500,7 +500,7 @@ def _create_preset_item(item): "image": item.get("@image", ""), "is_raw_url": True, "url2": item.get("@url", ""), - "url": "Preset?id={}".format(item.get("@id", "")), + "url": f"Preset?id={item.get('@id', '')}", } ) @@ -934,9 +934,7 @@ async def async_select_source(self, source): return selected_source = items[0] - url = "Play?url={}&preset_id&image={}".format( - selected_source["url"], selected_source["image"] - ) + url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}" if "is_raw_url" in selected_source and selected_source["is_raw_url"]: url = selected_source["url"] @@ -1002,7 +1000,7 @@ async def async_media_seek(self, position): if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command("Play?seek={}".format(float(position))) + return await self.send_bluesound_command(f"Play?seek={float(position)}") async def async_play_media(self, media_type, media_id, **kwargs): """ diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 65c87890242ed9..43430f724bba6f 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -1,7 +1,7 @@ """Support for BME680 Sensor over SMBus.""" import logging import threading -from time import sleep, time +from time import monotonic, sleep import bme680 # pylint: disable=import-error from smbus import SMBus # pylint: disable=import-error @@ -240,15 +240,15 @@ def _run_gas_sensor(self, burn_in_time): # Pause to allow initial data read for device validation. sleep(1) - start_time = time() - curr_time = time() + start_time = monotonic() + curr_time = monotonic() burn_in_data = [] _LOGGER.info( "Beginning %d second gas sensor burn in for Air Quality", burn_in_time ) while curr_time - start_time < burn_in_time: - curr_time = time() + curr_time = monotonic() if self._sensor.get_sensor_data() and self._sensor.data.heat_stable: gas_resistance = self._sensor.data.gas_resistance burn_in_data.append(gas_resistance) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c6e6ab9d89aec3..6b6311c6b0d5ba 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.6.2"], + "requirements": ["bimmer_connected==0.7.1"], "dependencies": [], "codeowners": ["@gerard33"] } diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 3c40900bed8df6..7fb3da8b88336b 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -7,6 +7,7 @@ CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, + TIME_HOURS, VOLUME_GALLONS, VOLUME_LITERS, ) @@ -24,7 +25,7 @@ "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_KILOMETERS], "max_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], "remaining_fuel": ["mdi:gas-station", VOLUME_LITERS], - "charging_time_remaining": ["mdi:update", "h"], + "charging_time_remaining": ["mdi:update", TIME_HOURS], "charging_status": ["mdi:battery-charging", None], # No icon as this is dealt with directly as a special case in icon() "charging_level_hv": [None, "%"], @@ -37,7 +38,7 @@ "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_MILES], "max_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], "remaining_fuel": ["mdi:gas-station", VOLUME_GALLONS], - "charging_time_remaining": ["mdi:update", "h"], + "charging_time_remaining": ["mdi:update", TIME_HOURS], "charging_status": ["mdi:battery-charging", None], # No icon as this is dealt with directly as a special case in icon() "charging_level_hv": [None, "%"], diff --git a/homeassistant/components/bom/camera.py b/homeassistant/components/bom/camera.py index 7460b84f73438a..3bbd9e391645e4 100644 --- a/homeassistant/components/bom/camera.py +++ b/homeassistant/components/bom/camera.py @@ -75,16 +75,12 @@ def _validate_schema(config): if config.get(CONF_LOCATION) is None: if not all(config.get(x) for x in (CONF_ID, CONF_DELTA, CONF_FRAMES)): raise vol.Invalid( - "Specify '{}', '{}' and '{}' when '{}' is unspecified".format( - CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_LOCATION - ) + f"Specify '{CONF_ID}', '{CONF_DELTA}' and '{CONF_FRAMES}' when '{CONF_LOCATION}' is unspecified" ) return config -LOCATIONS_MSG = "Set '{}' to one of: {}".format( - CONF_LOCATION, ", ".join(sorted(LOCATIONS)) -) +LOCATIONS_MSG = f"Set '{CONF_LOCATION}' to one of: {', '.join(sorted(LOCATIONS))}" XOR_MSG = f"Specify exactly one of '{CONF_ID}' or '{CONF_LOCATION}'" PLATFORM_SCHEMA = vol.All( @@ -106,7 +102,7 @@ def _validate_schema(config): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up BOM radar-loop camera component.""" - location = config.get(CONF_LOCATION) or "ID {}".format(config.get(CONF_ID)) + location = config.get(CONF_LOCATION) or f"ID {config.get(CONF_ID)}" name = config.get(CONF_NAME) or f"BOM Radar Loop - {location}" args = [ config.get(x) diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index 7d951968cb2bea..836a2a795094b6 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -19,6 +19,7 @@ CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv @@ -26,7 +27,6 @@ from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -_RESOURCE = "http://www.bom.gov.au/fwo/{}/{}.{}.json" _LOGGER = logging.getLogger(__name__) ATTR_LAST_UPDATE = "last_update" @@ -59,7 +59,7 @@ "cloud_type_id": ["Cloud Type ID", None], "cloud_type": ["Cloud Type", None], "delta_t": ["Delta Temp C", TEMP_CELSIUS], - "gust_kmh": ["Wind Gust kmh", "km/h"], + "gust_kmh": ["Wind Gust kmh", SPEED_KILOMETERS_PER_HOUR], "gust_kt": ["Wind Gust kt", "kt"], "air_temp": ["Air Temp C", TEMP_CELSIUS], "dewpt": ["Dew Point C", TEMP_CELSIUS], @@ -76,7 +76,7 @@ "vis_km": ["Visability km", "km"], "weather": ["Weather", None], "wind_dir": ["Wind Direction", None], - "wind_spd_kmh": ["Wind Speed kmh", "km/h"], + "wind_spd_kmh": ["Wind Speed kmh", SPEED_KILOMETERS_PER_HOUR], "wind_spd_kt": ["Wind Speed kt", "kt"], } @@ -112,7 +112,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if station is not None: if zone_id and wmo_id: _LOGGER.warning( - "Using config %s, not %s and %s for BOM sensor", + "Using configuration %s, not %s and %s for BOM sensor", CONF_STATION, CONF_ZONE_ID, CONF_WMO_ID, @@ -158,9 +158,9 @@ def __init__(self, bom_data, condition, stationname): def name(self): """Return the name of the sensor.""" if self.stationname is None: - return "BOM {}".format(SENSOR_TYPES[self._condition][0]) + return f"BOM {SENSOR_TYPES[self._condition][0]}" - return "BOM {} {}".format(self.stationname, SENSOR_TYPES[self._condition][0]) + return f"BOM {self.stationname} {SENSOR_TYPES[self._condition][0]}" @property def state(self): @@ -202,7 +202,10 @@ def __init__(self, station_id): def _build_url(self): """Build the URL for the requests.""" - url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id) + url = ( + f"http://www.bom.gov.au/fwo/{self._zone_id}" + f"/{self._zone_id}.{self._wmo_id}.json" + ) _LOGGER.debug("BOM URL: %s", url) return url @@ -281,7 +284,7 @@ def _get_bom_stations(): """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. This function does several MB of internet requests, so please use the - caching version to minimise latency and hit-count. + caching version to minimize latency and hit-count. """ latlon = {} with io.BytesIO() as file_obj: @@ -309,10 +312,10 @@ def _get_bom_stations(): r'(?P=zone)\.(?P\d\d\d\d\d).shtml">' ) for state in ("nsw", "vic", "qld", "wa", "tas", "nt"): - url = "http://www.bom.gov.au/{0}/observations/{0}all.shtml".format(state) + url = f"http://www.bom.gov.au/{state}/observations/{state}all.shtml" for zone_id, wmo_id in re.findall(pattern, requests.get(url).text): zones[wmo_id] = zone_id - return {"{}.{}".format(zones[k], k): latlon[k] for k in set(latlon) & set(zones)} + return {f"{zones[k]}.{k}": latlon[k] for k in set(latlon) & set(zones)} def bom_stations(cache_dir): diff --git a/homeassistant/components/bom/weather.py b/homeassistant/components/bom/weather.py index 2513c7c4c4051a..94b9960c851bce 100644 --- a/homeassistant/components/bom/weather.py +++ b/homeassistant/components/bom/weather.py @@ -49,7 +49,7 @@ def update(self): @property def name(self): """Return the name of the sensor.""" - return "BOM {}".format(self.stationname or "(unknown station)") + return f"BOM {self.stationname or '(unknown station)'}" @property def condition(self): diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index 1cb3efdd2ccd10..8bfa48b9195768 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -2,7 +2,7 @@ "domain": "braviatv", "name": "Sony Bravia TV", "documentation": "https://www.home-assistant.io/integrations/braviatv", - "requirements": ["braviarc-homeassistant==0.3.7.dev0", "getmac==0.8.1"], + "requirements": ["bravia-tv==1.0", "getmac==0.8.1"], "dependencies": ["configurator"], "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index ef0640c8e87257..67feb8bfc48c1e 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -2,7 +2,7 @@ import ipaddress import logging -from braviarc.braviarc import BraviaRC +from bravia_tv import BraviaRC from getmac import get_mac_address import voluptuous as vol diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 3f9b5cd4597c19..be6aa2664912d5 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow @@ -64,67 +65,69 @@ def mac_address(value): SERVICE_LEARN_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string}) +@callback def async_setup_service(hass, host, device): """Register a device for given host for use in services.""" hass.data.setdefault(DOMAIN, {})[host] = device - if not hass.services.has_service(DOMAIN, SERVICE_LEARN): + if hass.services.has_service(DOMAIN, SERVICE_LEARN): + return - async def _learn_command(call): - """Learn a packet from remote.""" - device = hass.data[DOMAIN][call.data[CONF_HOST]] + async def _learn_command(call): + """Learn a packet from remote.""" + device = hass.data[DOMAIN][call.data[CONF_HOST]] + + for retry in range(DEFAULT_RETRY): try: - auth = await hass.async_add_executor_job(device.auth) - except socket.timeout: - _LOGGER.error("Failed to connect to device, timeout") - return - if not auth: - _LOGGER.error("Failed to connect to device") + await hass.async_add_executor_job(device.enter_learning) + break + except (socket.timeout, ValueError): + try: + await hass.async_add_executor_job(device.auth) + except socket.timeout: + if retry == DEFAULT_RETRY - 1: + _LOGGER.error("Failed to enter learning mode") + return + + _LOGGER.info("Press the key you want Home Assistant to learn") + start_time = utcnow() + while (utcnow() - start_time) < timedelta(seconds=20): + packet = await hass.async_add_executor_job(device.check_data) + if packet: + data = b64encode(packet).decode("utf8") + log_msg = f"Received packet is: {data}" + _LOGGER.info(log_msg) + hass.components.persistent_notification.async_create( + log_msg, title="Broadlink switch" + ) return - - await hass.async_add_executor_job(device.enter_learning) - - _LOGGER.info("Press the key you want Home Assistant to learn") - start_time = utcnow() - while (utcnow() - start_time) < timedelta(seconds=20): - packet = await hass.async_add_executor_job(device.check_data) - if packet: - data = b64encode(packet).decode("utf8") - log_msg = f"Received packet is: {data}" - _LOGGER.info(log_msg) - hass.components.persistent_notification.async_create( - log_msg, title="Broadlink switch" - ) - return - await asyncio.sleep(1) - _LOGGER.error("No signal was received") - hass.components.persistent_notification.async_create( - "No signal was received", title="Broadlink switch" - ) - - hass.services.async_register( - DOMAIN, SERVICE_LEARN, _learn_command, schema=SERVICE_LEARN_SCHEMA + await asyncio.sleep(1) + _LOGGER.error("No signal was received") + hass.components.persistent_notification.async_create( + "No signal was received", title="Broadlink switch" ) - if not hass.services.has_service(DOMAIN, SERVICE_SEND): - - async def _send_packet(call): - """Send a packet.""" - device = hass.data[DOMAIN][call.data[CONF_HOST]] - packets = call.data[CONF_PACKET] - for packet in packets: - for retry in range(DEFAULT_RETRY): + hass.services.async_register( + DOMAIN, SERVICE_LEARN, _learn_command, schema=SERVICE_LEARN_SCHEMA + ) + + async def _send_packet(call): + """Send a packet.""" + device = hass.data[DOMAIN][call.data[CONF_HOST]] + packets = call.data[CONF_PACKET] + for packet in packets: + for retry in range(DEFAULT_RETRY): + try: + await hass.async_add_executor_job(device.send_data, packet) + break + except (socket.timeout, ValueError): try: - await hass.async_add_executor_job(device.send_data, packet) - break - except (socket.timeout, ValueError): - try: - await hass.async_add_executor_job(device.auth) - except socket.timeout: - if retry == DEFAULT_RETRY - 1: - _LOGGER.error("Failed to send packet to device") - - hass.services.async_register( - DOMAIN, SERVICE_SEND, _send_packet, schema=SERVICE_SEND_SCHEMA - ) + await hass.async_add_executor_job(device.auth) + except socket.timeout: + if retry == DEFAULT_RETRY - 1: + _LOGGER.error("Failed to send packet to device") + + hass.services.async_register( + DOMAIN, SERVICE_SEND, _send_packet, schema=SERVICE_SEND_SCHEMA + ) diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 0b9d10b1e74bd0..714b5dfec342d8 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -44,9 +44,7 @@ SCAN_INTERVAL = timedelta(minutes=2) -CODE_STORAGE_KEY = "broadlink_{}_codes" CODE_STORAGE_VERSION = 1 -FLAG_STORAGE_KEY = "broadlink_{}_flags" FLAG_STORAGE_VERSION = 1 FLAG_SAVE_DELAY = 15 @@ -96,8 +94,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= api = broadlink.rm((host, DEFAULT_PORT), mac_addr, None) api.timeout = timeout - code_storage = Store(hass, CODE_STORAGE_VERSION, CODE_STORAGE_KEY.format(unique_id)) - flag_storage = Store(hass, FLAG_STORAGE_VERSION, FLAG_STORAGE_KEY.format(unique_id)) + code_storage = Store(hass, CODE_STORAGE_VERSION, f"broadlink_{unique_id}_codes") + flag_storage = Store(hass, FLAG_STORAGE_VERSION, f"broadlink_{unique_id}_flags") remote = BroadlinkRemote(name, unique_id, api, code_storage, flag_storage) connected, loaded = (False, False) @@ -270,7 +268,7 @@ async def async_learn_command(self, **kwargs): async def _async_learn_code(self, command, device, toggle, timeout): """Learn a code from a remote. - Capture an aditional code for toggle commands. + Capture an additional code for toggle commands. """ try: if not toggle: diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 9f3087335c892e..408593e337d427 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -67,7 +67,7 @@ class BroadlinkSensor(Entity): def __init__(self, name, broadlink_data, sensor_type): """Initialize the sensor.""" - self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0]) + self._name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self._state = None self._is_available = False self._type = sensor_type diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 78738870aaa32f..9b986ae75d4b65 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -7,11 +7,7 @@ import broadlink import voluptuous as vol -from homeassistant.components.switch import ( - ENTITY_ID_FORMAT, - PLATFORM_SCHEMA, - SwitchDevice, -) +from homeassistant.components.switch import DOMAIN, PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, @@ -159,7 +155,7 @@ def __init__( self, name, friendly_name, device, command_on, command_off, retry_times ): """Initialize the switch.""" - self.entity_id = ENTITY_ID_FORMAT.format(slugify(name)) + self.entity_id = f"{DOMAIN}.{slugify(name)}" self._name = friendly_name self._state = False self._command_on = command_on diff --git a/homeassistant/components/brother/.translations/ca.json b/homeassistant/components/brother/.translations/ca.json index 62dd1807676f2f..bf592396094951 100644 --- a/homeassistant/components/brother/.translations/ca.json +++ b/homeassistant/components/brother/.translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Aquesta impressora ja est\u00e0 configurada.", "unsupported_model": "Aquest model d'impressora no \u00e9s compatible." }, "error": { @@ -8,6 +9,7 @@ "snmp_error": "El servidor SNMP s'ha tancat o la impressora no \u00e9s compatible.", "wrong_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids." }, + "flow_title": "Impressora Brother: {model} {serial_number}", "step": { "user": { "data": { @@ -16,6 +18,13 @@ }, "description": "Configura la integraci\u00f3 d'impressora Brother. Si tens problemes amb la configuraci\u00f3, visita: https://www.home-assistant.io/integrations/brother", "title": "Impressora Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Tipus d'impressora" + }, + "description": "Vols afegir la impressora Brother {model} amb n\u00famero de s\u00e8rie `{serial_number}` a Home Assistant?", + "title": "Impressora Brother descoberta" } }, "title": "Impressora Brother" diff --git a/homeassistant/components/brother/.translations/cs.json b/homeassistant/components/brother/.translations/cs.json new file mode 100644 index 00000000000000..716b62c6c705a7 --- /dev/null +++ b/homeassistant/components/brother/.translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "flow_title": "Tisk\u00e1rna Brother: {model} {serial_number}", + "step": { + "zeroconf_confirm": { + "data": { + "type": "Typ tisk\u00e1rny" + }, + "description": "Chcete p\u0159idat tisk\u00e1rnu Brother {model} se s\u00e9riov\u00fdm \u010d\u00edslem \"{serial_number}\" do Home Assistant?", + "title": "Objeven\u00e1 tisk\u00e1rna Brother" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/de.json b/homeassistant/components/brother/.translations/de.json index 92c8d22148fdcc..f99681d6d7b529 100644 --- a/homeassistant/components/brother/.translations/de.json +++ b/homeassistant/components/brother/.translations/de.json @@ -9,6 +9,7 @@ "snmp_error": "SNMP-Server deaktiviert oder Drucker nicht unterst\u00fctzt.", "wrong_host": " Ung\u00fcltiger Hostname oder IP-Adresse" }, + "flow_title": "Brother-Drucker: {model} {serial_number}", "step": { "user": { "data": { @@ -17,6 +18,13 @@ }, "description": "Einrichten der Brother-Drucker-Integration. Wenn Du Probleme mit der Konfiguration hast, gehe zu: https://www.home-assistant.io/integrations/brother", "title": "Brother Drucker" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ des Druckers" + }, + "description": "M\u00f6chten Sie den Brother Drucker {model} mit der Seriennummer `{serial_number}` zum Home Assistant hinzuf\u00fcgen?", + "title": "Brother-Drucker entdeckt" } }, "title": "Brother Drucker" diff --git a/homeassistant/components/brother/.translations/es-419.json b/homeassistant/components/brother/.translations/es-419.json new file mode 100644 index 00000000000000..49b77b829b5701 --- /dev/null +++ b/homeassistant/components/brother/.translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Impresora Brother" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/es.json b/homeassistant/components/brother/.translations/es.json index f4e53e20793367..d41d09634d86f7 100644 --- a/homeassistant/components/brother/.translations/es.json +++ b/homeassistant/components/brother/.translations/es.json @@ -9,6 +9,7 @@ "snmp_error": "El servidor SNMP est\u00e1 apagado o la impresora no es compatible.", "wrong_host": "Nombre del host o direcci\u00f3n IP no v\u00e1lidos." }, + "flow_title": "Impresora Brother: {model} {serial_number}", "step": { "user": { "data": { @@ -17,6 +18,13 @@ }, "description": "Configure la integraci\u00f3n de impresoras Brother. Si tiene problemas con la configuraci\u00f3n, vaya a: https://www.home-assistant.io/integrations/brother", "title": "Impresora Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Tipo de impresora" + }, + "description": "\u00bfQuiere a\u00f1adir la Impresora Brother {model} con el n\u00famero de serie `{serial_number}` a Home Assistant?", + "title": "Impresora Brother encontrada" } }, "title": "Impresora Brother" diff --git a/homeassistant/components/brother/.translations/fr.json b/homeassistant/components/brother/.translations/fr.json index db3c7f48ce7ee6..788d0c740037e1 100644 --- a/homeassistant/components/brother/.translations/fr.json +++ b/homeassistant/components/brother/.translations/fr.json @@ -9,6 +9,7 @@ "snmp_error": "Serveur SNMP d\u00e9sactiv\u00e9 ou imprimante non prise en charge.", "wrong_host": "Nom d'h\u00f4te ou adresse IP invalide." }, + "flow_title": "Imprimante Brother: {model} {serial_number}", "step": { "user": { "data": { @@ -17,6 +18,13 @@ }, "description": "Configurez l'int\u00e9gration de l'imprimante Brother. Si vous avez des probl\u00e8mes avec la configuration, allez \u00e0 : https://www.home-assistant.io/integrations/brother", "title": "Imprimante Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Type d'imprimante" + }, + "description": "Voulez-vous ajouter l'imprimante Brother {model} avec le num\u00e9ro de s\u00e9rie `{serial_number}` \u00e0 Home Assistant ?", + "title": "Imprimante Brother d\u00e9couverte" } }, "title": "Imprimante Brother" diff --git a/homeassistant/components/brother/.translations/hu.json b/homeassistant/components/brother/.translations/hu.json new file mode 100644 index 00000000000000..1907d65f28925c --- /dev/null +++ b/homeassistant/components/brother/.translations/hu.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Ez a nyomtat\u00f3 m\u00e1r konfigur\u00e1lva van.", + "unsupported_model": "Ez a nyomtat\u00f3modell nem t\u00e1mogatott." + }, + "error": { + "connection_error": "Csatlakoz\u00e1si hiba.", + "snmp_error": "Az SNMP szerver ki van kapcsolva, vagy a nyomtat\u00f3 nem t\u00e1mogatott.", + "wrong_host": "\u00c9rv\u00e9nytelen \u00e1llom\u00e1sn\u00e9v vagy IP-c\u00edm." + }, + "flow_title": "Brother nyomtat\u00f3: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nyomtat\u00f3 \u00e1llom\u00e1sneve vagy IP-c\u00edme", + "type": "A nyomtat\u00f3 t\u00edpusa" + }, + "description": "A Brother nyomtat\u00f3 integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha probl\u00e9m\u00e1id vannak a konfigur\u00e1ci\u00f3val, l\u00e1togass el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/brother", + "title": "Brother nyomtat\u00f3" + }, + "zeroconf_confirm": { + "data": { + "type": "A nyomtat\u00f3 t\u00edpusa" + }, + "description": "Hozz\u00e1 akarja adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: {serial_number} `, a Home Assistant-hoz?", + "title": "Felfedezett Brother nyomtat\u00f3" + } + }, + "title": "Brother nyomtat\u00f3" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/ko.json b/homeassistant/components/brother/.translations/ko.json index 8ec7497296c88b..ec0f0d2453f315 100644 --- a/homeassistant/components/brother/.translations/ko.json +++ b/homeassistant/components/brother/.translations/ko.json @@ -9,6 +9,7 @@ "snmp_error": "SNMP \uc11c\ubc84\uac00 \uaebc\uc838 \uc788\uac70\ub098 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \ud504\ub9b0\ud130\uc785\ub2c8\ub2e4.", "wrong_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, + "flow_title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130: {model} {serial_number}", "step": { "user": { "data": { @@ -17,6 +18,13 @@ }, "description": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. \uad6c\uc131\uc5d0 \ubb38\uc81c\uac00\uc788\ub294 \uacbd\uc6b0 https://www.home-assistant.io/integrations/brother \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", "title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130" + }, + "zeroconf_confirm": { + "data": { + "type": "\ud504\ub9b0\ud130\uc758 \uc885\ub958" + }, + "description": "\uc2dc\ub9ac\uc5bc \ubc88\ud638 `{serial_number}` \ub85c \ube0c\ub77c\ub354 \ud504\ub9b0\ud130 {model} \uc744(\ub97c) Home Assistant \uc5d0 \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\ubc1c\uacac\ub41c \ube0c\ub77c\ub354 \ud504\ub9b0\ud130" } }, "title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130" diff --git a/homeassistant/components/brother/.translations/nl.json b/homeassistant/components/brother/.translations/nl.json index ed7d3980f47fe8..c72aab46801986 100644 --- a/homeassistant/components/brother/.translations/nl.json +++ b/homeassistant/components/brother/.translations/nl.json @@ -1,18 +1,32 @@ { "config": { "abort": { + "already_configured": "Deze printer is al geconfigureerd.", "unsupported_model": "Dit printermodel wordt niet ondersteund." }, "error": { "connection_error": "Verbindingsfout.", + "snmp_error": "SNMP-server uitgeschakeld of printer wordt niet ondersteund.", "wrong_host": "Ongeldige hostnaam of IP-adres." }, + "flow_title": "Brother Printer: {model} {serial_number}", "step": { "user": { "data": { - "host": "Printerhostnaam of IP-adres" - } + "host": "Printerhostnaam of IP-adres", + "type": "Type printer" + }, + "description": "Zet Brother printerintegratie op. Als u problemen heeft met de configuratie ga dan naar: https://www.home-assistant.io/integrations/brother", + "title": "Brother Printer" + }, + "zeroconf_confirm": { + "data": { + "type": "Type printer" + }, + "description": "Wilt u het Brother Printer {model} met serienummer {serial_number}' toevoegen aan Home Assistant?", + "title": "Ontdekte Brother Printer" } - } + }, + "title": "Brother Printer" } } \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/pl.json b/homeassistant/components/brother/.translations/pl.json index 14fe4024f34897..1417720714ea2b 100644 --- a/homeassistant/components/brother/.translations/pl.json +++ b/homeassistant/components/brother/.translations/pl.json @@ -9,6 +9,7 @@ "snmp_error": "Serwer SNMP wy\u0142\u0105czony lub drukarka nie jest obs\u0142ugiwana.", "wrong_host": "Niepoprawna nazwa hosta lub adres IP drukarki." }, + "flow_title": "Drukarka Brother: {model} {serial_number}", "step": { "user": { "data": { @@ -17,6 +18,13 @@ }, "description": "Konfiguracja integracji drukarek Brother. Je\u015bli masz problemy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/brother", "title": "Drukarka Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ drukarki" + }, + "description": "Czy chcesz doda\u0107 drukark\u0119 Brother {model} o numerze seryjnym `{serial_number}` do Home Assistant'a?", + "title": "Wykryto drukark\u0119 Brother" } }, "title": "Drukarka Brother" diff --git a/homeassistant/components/brother/.translations/sl.json b/homeassistant/components/brother/.translations/sl.json index 99caf69a86fe01..d22f128ffbe608 100644 --- a/homeassistant/components/brother/.translations/sl.json +++ b/homeassistant/components/brother/.translations/sl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Ta tiskalnik je \u017ee konfiguriran.", "unsupported_model": "Ta model tiskalnika ni podprt." }, "error": { @@ -8,6 +9,7 @@ "snmp_error": "Stre\u017enik SNMP je izklopljen ali tiskalnik ni podprt.", "wrong_host": "Neveljavno ime gostitelja ali IP naslov." }, + "flow_title": "Tiskalnik Brother: {model} {serial_number}", "step": { "user": { "data": { @@ -16,6 +18,13 @@ }, "description": "Nastavite integracijo tiskalnika Brother. \u010ce imate te\u017eave s konfiguracijo, pojdite na: https://www.home-assistant.io/integrations/brother", "title": "Brother Tiskalnik" + }, + "zeroconf_confirm": { + "data": { + "type": "Vrsta tiskalnika" + }, + "description": "Ali \u017eelite dodati Brother tiskalnik {model} s serijsko \u0161tevilko ' {serial_number} ' v Home Assistant?", + "title": "Odkriti Brother tiskalniki" } }, "title": "Brother Tiskalnik" diff --git a/homeassistant/components/brother/.translations/sv.json b/homeassistant/components/brother/.translations/sv.json new file mode 100644 index 00000000000000..774863d4f081ca --- /dev/null +++ b/homeassistant/components/brother/.translations/sv.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r skrivaren \u00e4r redan konfigurerad.", + "unsupported_model": "Den h\u00e4r skrivarmodellen st\u00f6ds inte." + }, + "error": { + "connection_error": "Anslutningsfel.", + "snmp_error": "SNMP-servern har st\u00e4ngts av eller s\u00e5 st\u00f6ds inte skrivaren.", + "wrong_host": "Ogiltigt v\u00e4rdnamn eller IP-adress." + }, + "flow_title": "Brother-skrivare: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Skrivarens v\u00e4rdnamn eller IP-adress", + "type": "Typ av skrivare" + }, + "description": "St\u00e4ll in Brother-skrivarintegration. Om du har problem med konfigurationen g\u00e5r du till: https://www.home-assistant.io/integrations/brother", + "title": "Brother-skrivare" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ av skrivare" + }, + "description": "Vill du l\u00e4gga till Brother-skrivaren {model} med serienumret {serial_number} i Home Assistant?", + "title": "Uppt\u00e4ckte Brother-skrivare" + } + }, + "title": "Brother-skrivare" + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/.translations/zh-Hant.json b/homeassistant/components/brother/.translations/zh-Hant.json index cff89ea38caec4..0ef813dffeacdb 100644 --- a/homeassistant/components/brother/.translations/zh-Hant.json +++ b/homeassistant/components/brother/.translations/zh-Hant.json @@ -9,6 +9,7 @@ "snmp_error": "SNMP \u4f3a\u670d\u5668\u70ba\u95dc\u9589\u72c0\u614b\u6216\u5370\u8868\u6a5f\u4e0d\u652f\u63f4\u3002", "wrong_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740" }, + "flow_title": "Brother \u5370\u8868\u6a5f\uff1a{model} {serial_number}", "step": { "user": { "data": { @@ -17,6 +18,13 @@ }, "description": "\u8a2d\u5b9a Brother \u5370\u8868\u6a5f\u6574\u5408\u3002\u5047\u5982\u9700\u8981\u5354\u52a9\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/brother", "title": "Brother \u5370\u8868\u6a5f" + }, + "zeroconf_confirm": { + "data": { + "type": "\u5370\u8868\u6a5f\u985e\u578b" + }, + "description": "\u662f\u5426\u8981\u5c07\u5e8f\u865f {serial_number} \u4e4bBrother \u5370\u8868\u6a5f {model} \u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u767c\u73fe Brother \u5370\u8868\u6a5f" } }, "title": "Brother \u5370\u8868\u6a5f" diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index fdb7cd82b9cae0..d3b7c5e2a7858a 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -1,4 +1,6 @@ """Constants for Brother integration.""" +from homeassistant.const import TIME_DAYS + ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" ATTR_BLACK_INK_REMAINING = "black_ink_remaining" ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" @@ -28,7 +30,6 @@ DOMAIN = "brother" UNIT_PAGES = "p" -UNIT_DAYS = "days" UNIT_PERCENT = "%" PRINTER_TYPES = ["laser", "ink"] @@ -127,6 +128,6 @@ ATTR_UPTIME: { ATTR_ICON: "mdi:timer", ATTR_LABEL: ATTR_UPTIME.title(), - ATTR_UNIT: UNIT_DAYS, + ATTR_UNIT: TIME_DAYS, }, } diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index e63fb9b0d7c3ed..51e6c3284ff249 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/brother", "dependencies": [], "codeowners": ["@bieniu"], - "requirements": ["brother==0.1.4"], + "requirements": ["brother==0.1.6"], "zeroconf": ["_printer._tcp.local."], "config_flow": true } diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 282433aa7a46ff..feb066a6f3f906 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -69,7 +69,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Every Home Assistant instance should have their own unique # app parameter: https://brottsplatskartan.se/sida/api - app = "ha-{}".format(uuid.getnode()) + app = f"ha-{uuid.getnode()}" bpk = brottsplatskartan.BrottsplatsKartan( app=app, area=area, latitude=latitude, longitude=longitude diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 373c33394413c8..b3a007277c3c3b 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -56,9 +56,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index b41b3220b40521..b685bdb5c7319b 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -17,8 +17,6 @@ CONF_DELTA = "delta" CONF_COUNTRY = "country_code" -RADAR_MAP_URL_TEMPLATE = "https://api.buienradar.nl/image/1.0/RadarMap{c}?w={w}&h={h}" - _LOG = logging.getLogger(__name__) # Maximum range according to docs @@ -112,8 +110,9 @@ async def __retrieve_radar_image(self) -> bool: """Retrieve new radar image and return whether this succeeded.""" session = async_get_clientsession(self.hass) - url = RADAR_MAP_URL_TEMPLATE.format( - c=self._country, w=self._dimension, h=self._dimension + url = ( + f"https://api.buienradar.nl/image/1.0/RadarMap{self._country}" + f"?w={self._dimension}&h={self._dimension}" ) if self._last_modified: diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index f642fc2e24977c..32ecf50ed9d9a6 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -27,7 +27,10 @@ CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + IRRADIATION_WATTS_PER_SQUARE_METER, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + TIME_HOURS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -67,18 +70,18 @@ "humidity": ["Humidity", "%", "mdi:water-percent"], "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], - "windspeed": ["Wind speed", "km/h", "mdi:weather-windy"], + "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windforce": ["Wind force", "Bft", "mdi:weather-windy"], "winddirection": ["Wind direction", None, "mdi:compass-outline"], "windazimuth": ["Wind direction azimuth", "°", "mdi:compass-outline"], "pressure": ["Pressure", "hPa", "mdi:gauge"], "visibility": ["Visibility", "km", None], - "windgust": ["Wind gust", "km/h", "mdi:weather-windy"], - "precipitation": ["Precipitation", "mm/h", "mdi:weather-pouring"], - "irradiance": ["Irradiance", "W/m2", "mdi:sunglasses"], + "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "precipitation": ["Precipitation", f"mm/{TIME_HOURS}", "mdi:weather-pouring"], + "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"], "precipitation_forecast_average": [ "Precipitation forecast average", - "mm/h", + f"mm/{TIME_HOURS}", "mdi:weather-pouring", ], "precipitation_forecast_total": [ @@ -132,11 +135,11 @@ "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], - "windspeed_1d": ["Wind speed 1d", "km/h", "mdi:weather-windy"], - "windspeed_2d": ["Wind speed 2d", "km/h", "mdi:weather-windy"], - "windspeed_3d": ["Wind speed 3d", "km/h", "mdi:weather-windy"], - "windspeed_4d": ["Wind speed 4d", "km/h", "mdi:weather-windy"], - "windspeed_5d": ["Wind speed 5d", "km/h", "mdi:weather-windy"], + "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 98cbb2f5e43e42..32e8babde9097d 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -113,8 +113,8 @@ def attribution(self): @property def name(self): """Return the name of the sensor.""" - return self._stationname or "BR {}".format( - self._data.stationname or "(unknown station)" + return ( + self._stationname or f"BR {self._data.stationname or '(unknown station)'}" ) @property diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index ad9dac1f7274f8..579755709d1ddf 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -88,9 +88,7 @@ def setup_platform(hass, config, add_entities, disc_info=None): continue name = cust_calendar[CONF_NAME] - device_id = "{} {}".format( - cust_calendar[CONF_CALENDAR], cust_calendar[CONF_NAME] - ) + device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) calendar_devices.append( WebDavCalendarEventDevice( @@ -193,27 +191,50 @@ async def async_get_events(self, hass, start_date, end_date): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" + start_of_today = dt.start_of_local_day() + start_of_tomorrow = dt.start_of_local_day() + timedelta(days=1) + # We have to retrieve the results for the whole day as the server # won't return events that have already started - results = self.calendar.date_search( - dt.start_of_local_day(), dt.start_of_local_day() + timedelta(days=1) - ) + results = self.calendar.date_search(start_of_today, start_of_tomorrow) + + # Create new events for each recurrence of an event that happens today. + # For recurring events, some servers return the original event with recurrence rules + # and they would not be properly parsed using their original start/end dates. + new_events = [] + for event in results: + vevent = event.instance.vevent + for start_dt in vevent.getrruleset() or []: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow + if self.is_all_day(vevent): + start_dt = start_dt.date() + _start_of_today = _start_of_today.date() + _start_of_tomorrow = _start_of_tomorrow.date() + if _start_of_today <= start_dt < _start_of_tomorrow: + new_event = event.copy() + new_vevent = new_event.instance.vevent + if hasattr(new_vevent, "dtend"): + dur = new_vevent.dtend.value - new_vevent.dtstart.value + new_vevent.dtend.value = start_dt + dur + new_vevent.dtstart.value = start_dt + new_events.append(new_event) + elif _start_of_tomorrow <= start_dt: + break + vevents = [event.instance.vevent for event in results + new_events] # dtstart can be a date or datetime depending if the event lasts a # whole day. Convert everything to datetime to be able to sort it - results.sort(key=lambda x: self.to_datetime(x.instance.vevent.dtstart.value)) + vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) vevent = next( ( - event.instance.vevent - for event in results + vevent + for vevent in vevents if ( - self.is_matching(event.instance.vevent, self.search) - and ( - not self.is_all_day(event.instance.vevent) - or self.include_all_day - ) - and not self.is_over(event.instance.vevent) + self.is_matching(vevent, self.search) + and (not self.is_all_day(vevent) or self.include_all_day) + and not self.is_over(vevent) ) ), None, @@ -223,7 +244,7 @@ def update(self): if vevent is None: _LOGGER.debug( "No matching event found in the %d results for %s", - len(results), + len(vevents), self.calendar.name, ) self.event = None diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 2f48fb5fc273ac..85dc005a6a8999 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -1,6 +1,6 @@ { "domain": "caldav", - "name": "CalDav", + "name": "CalDAV", "documentation": "https://www.home-assistant.io/integrations/caldav", "requirements": ["caldav==0.6.1"], "dependencies": [], diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4fe52a7d164535..647e54556c4b05 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -364,19 +364,12 @@ def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() - @callback - def async_camera_image(self): - """Return bytes of camera image. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.camera_image) + async def async_camera_image(self): + """Return bytes of camera image.""" + return await self.hass.async_add_job(self.camera_image) async def handle_async_still_stream(self, request, interval): - """Generate an HTTP MJPEG stream from camera images. - - This method must be run in the event loop. - """ + """Generate an HTTP MJPEG stream from camera images.""" return await async_get_still_stream( request, self.async_camera_image, self.content_type, interval ) @@ -384,9 +377,8 @@ async def handle_async_still_stream(self, request, interval): async def handle_async_mjpeg_stream(self, request): """Serve an HTTP MJPEG stream from the camera. - This method can be overridden by camera plaforms to proxy + This method can be overridden by camera platforms to proxy a direct stream from the camera. - This method must be run in the event loop. """ return await self.handle_async_still_stream(request, self.frame_interval) @@ -408,19 +400,17 @@ def turn_off(self): """Turn off camera.""" raise NotImplementedError() - @callback - def async_turn_off(self): + async def async_turn_off(self): """Turn off camera.""" - return self.hass.async_add_job(self.turn_off) + await self.hass.async_add_job(self.turn_off) def turn_on(self): """Turn off camera.""" raise NotImplementedError() - @callback - def async_turn_on(self): + async def async_turn_on(self): """Turn off camera.""" - return self.hass.async_add_job(self.turn_on) + await self.hass.async_add_job(self.turn_on) def enable_motion_detection(self): """Enable motion detection in the camera.""" diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 0317413450203c..4e259038f14eef 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -66,7 +66,7 @@ _LOGGER = logging.getLogger(__name__) CONF_IGNORE_CEC = "ignore_cec" -CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png" +CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" SUPPORT_CAST = ( SUPPORT_PAUSE diff --git a/homeassistant/components/cert_expiry/.translations/es-419.json b/homeassistant/components/cert_expiry/.translations/es-419.json index 392dbf35f5ae13..e350faffcb3dd0 100644 --- a/homeassistant/components/cert_expiry/.translations/es-419.json +++ b/homeassistant/components/cert_expiry/.translations/es-419.json @@ -1,5 +1,26 @@ { "config": { + "abort": { + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada" + }, + "error": { + "certificate_error": "El certificado no pudo ser validado", + "certificate_fetch_failed": "No se puede recuperar el certificado de esta combinaci\u00f3n de host y puerto", + "connection_timeout": "Tiempo de espera al conectarse a este host", + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", + "resolve_failed": "Este host no puede resolverse", + "wrong_host": "El certificado no coincide con el nombre de host" + }, + "step": { + "user": { + "data": { + "host": "El nombre de host del certificado", + "name": "El nombre del certificado", + "port": "El puerto del certificado" + }, + "title": "Definir el certificado para probar" + } + }, "title": "Expiraci\u00f3n del certificado" } } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/pl.json b/homeassistant/components/cert_expiry/.translations/pl.json index 671cbfcd1ffd5c..2e50a9f8cbcf7b 100644 --- a/homeassistant/components/cert_expiry/.translations/pl.json +++ b/homeassistant/components/cert_expiry/.translations/pl.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "host_port_exists": "Ta kombinacja hosta i portu jest ju\u017c skonfigurowana" + "host_port_exists": "Ten host z tym portem jest ju\u017c skonfigurowany." }, "error": { "certificate_error": "Nie mo\u017cna zweryfikowa\u0107 certyfikatu", "certificate_fetch_failed": "Nie mo\u017cna pobra\u0107 certyfikatu z tej kombinacji hosta i portu", "connection_timeout": "Przekroczono limit czasu po\u0142\u0105czenia z hostem.", - "host_port_exists": "Ta kombinacja hosta i portu jest ju\u017c skonfigurowana", + "host_port_exists": "Ten host z tym portem jest ju\u017c skonfigurowany.", "resolve_failed": "Tego hosta nie mo\u017cna rozwi\u0105za\u0107", "wrong_host": "Certyfikat nie pasuje do nazwy hosta" }, diff --git a/homeassistant/components/cert_expiry/.translations/sv.json b/homeassistant/components/cert_expiry/.translations/sv.json new file mode 100644 index 00000000000000..bdccf51b2cd155 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/sv.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "host_port_exists": "Denna v\u00e4rd- och portkombination \u00e4r redan konfigurerad" + }, + "error": { + "certificate_error": "Certifikatet kunde inte valideras", + "certificate_fetch_failed": "Kan inte h\u00e4mta certifikat fr\u00e5n denna v\u00e4rd- och portkombination", + "connection_timeout": "Timeout vid anslutning till den h\u00e4r v\u00e4rden", + "host_port_exists": "Denna v\u00e4rd- och portkombination \u00e4r redan konfigurerad", + "resolve_failed": "Denna v\u00e4rd kan inte resolveras", + "wrong_host": "Certifikatet matchar inte v\u00e4rdnamnet" + }, + "step": { + "user": { + "data": { + "host": "Certifikatets v\u00e4rdnamn", + "name": "Certifikatets namn", + "port": "Certifikatets port" + }, + "title": "Definiera certifikatet som ska testas" + } + }, + "title": "Certifikatets utg\u00e5ng" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index 14532aea65f9ba..f3bd2f07d63689 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -69,7 +69,7 @@ async def _test_connection(self, user_input=None): return False async def async_step_user(self, user_input=None): - """Step when user intializes a integration.""" + """Step when user initializes a integration.""" self._errors = {} if user_input is not None: # set some defaults in case we need to return to the form diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 3a76575dfddd35..b4437ca5834fd4 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -13,6 +13,7 @@ CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_START, + TIME_DAYS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -85,7 +86,7 @@ def unique_id(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "days" + return TIME_DAYS @property def state(self): diff --git a/homeassistant/components/climate/.translations/es-419.json b/homeassistant/components/climate/.translations/es-419.json new file mode 100644 index 00000000000000..f3b861b91956a8 --- /dev/null +++ b/homeassistant/components/climate/.translations/es-419.json @@ -0,0 +1,10 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Cambiar el modo HVAC en {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est\u00e1 configurado en un modo HVAC espec\u00edfico" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/hu.json b/homeassistant/components/climate/.translations/hu.json new file mode 100644 index 00000000000000..38d6cc68822cf4 --- /dev/null +++ b/homeassistant/components/climate/.translations/hu.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "F\u0171t\u00e9s- \u00e9s l\u00e9gtechnikai (HVAC) \u00fczemm\u00f3d m\u00f3dos\u00edt\u00e1sa a k\u00f6vetkez\u0151n: {entity_name}", + "set_preset_mode": "A(z) {entity_name} be\u00e1ll\u00edt\u00e1s\u00e1nak v\u00e1lt\u00e1sa" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} speci\u00e1lis f\u0171t\u00e9s, szell\u0151z\u00e9s \u00e9s l\u00e9gkondicion\u00e1l\u00e1s (HVAC) \u00fczemm\u00f3dra van be\u00e1ll\u00edtva", + "is_preset_mode": "A(z) {entity_name} el\u0151re be\u00e1ll\u00edtott m\u00f3dja van kiv\u00e1lasztva" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} m\u00e9rt p\u00e1ratartalma megv\u00e1ltozott", + "current_temperature_changed": "{entity_name} m\u00e9rt h\u0151m\u00e9rs\u00e9klete megv\u00e1ltozott", + "hvac_mode_changed": "{entity_name} f\u0171t\u00e9s, szell\u0151z\u00e9s \u00e9s l\u00e9gkondicion\u00e1l\u00e1s (HVAC) \u00fczemm\u00f3d megv\u00e1ltozott" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/sv.json b/homeassistant/components/climate/.translations/sv.json new file mode 100644 index 00000000000000..51fe05405499f0 --- /dev/null +++ b/homeassistant/components/climate/.translations/sv.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u00c4ndra HVAC-l\u00e4ge p\u00e5 {entity_name}", + "set_preset_mode": "\u00c4ndra f\u00f6rinst\u00e4llning p\u00e5 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u00e4r inst\u00e4lld p\u00e5 ett specifikt HVAC-l\u00e4ge", + "is_preset_mode": "{entity_name} \u00e4r inst\u00e4lld p\u00e5 ett specifikt f\u00f6rinst\u00e4llt l\u00e4ge" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} uppm\u00e4tt fuktighet har \u00e4ndrats", + "current_temperature_changed": "{entity_name} uppm\u00e4tt temperatur har \u00e4ndrats", + "hvac_mode_changed": "{entity_name} HVAC-l\u00e4ge har \u00e4ndrats" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/.translations/zh-Hant.json b/homeassistant/components/climate/.translations/zh-Hant.json index 17e6c955046bfb..28ff10f09f01a6 100644 --- a/homeassistant/components/climate/.translations/zh-Hant.json +++ b/homeassistant/components/climate/.translations/zh-Hant.json @@ -1,16 +1,16 @@ { "device_automation": { "action_type": { - "set_hvac_mode": "\u8b8a\u66f4 {entity_name} HVAC \u6a21\u5f0f", - "set_preset_mode": "\u8b8a\u66f4 {entity_name} \u8a2d\u5b9a\u6a21\u5f0f" + "set_hvac_mode": "\u8b8a\u66f4{entity_name} HVAC \u6a21\u5f0f", + "set_preset_mode": "\u8b8a\u66f4{entity_name}\u8a2d\u5b9a\u6a21\u5f0f" }, "condition_type": { - "is_hvac_mode": "{entity_name} \u8a2d\u5b9a\u70ba\u6307\u5b9a HVAC \u6a21\u5f0f", - "is_preset_mode": "{entity_name} \u8a2d\u5b9a\u70ba\u6307\u5b9a\u8a2d\u5b9a\u6a21\u5f0f" + "is_hvac_mode": "{entity_name}\u8a2d\u5b9a\u70ba\u6307\u5b9a HVAC \u6a21\u5f0f", + "is_preset_mode": "{entity_name}\u8a2d\u5b9a\u70ba\u6307\u5b9a\u8a2d\u5b9a\u6a21\u5f0f" }, "trigger_type": { - "current_humidity_changed": "{entity_name} \u91cf\u6e2c\u6fd5\u5ea6\u5df2\u8b8a\u66f4", - "current_temperature_changed": "{entity_name} \u91cf\u6e2c\u6eab\u5ea6\u5df2\u8b8a\u66f4", + "current_humidity_changed": "{entity_name}\u91cf\u6e2c\u6fd5\u5ea6\u5df2\u8b8a\u66f4", + "current_temperature_changed": "{entity_name}\u91cf\u6e2c\u6eab\u5ea6\u5df2\u8b8a\u66f4", "hvac_mode_changed": "{entity_name} HVAC \u6a21\u5f0f\u5df2\u8b8a\u66f4" } } diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index cf393a035ec9e5..8a5b9ceede8f9d 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -11,7 +11,7 @@ CONF_ENTITY_ID, CONF_TYPE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -77,6 +77,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 34e89d57346899..815df57f342c5c 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -30,7 +30,7 @@ set_temperature: description: New target temperature for HVAC. example: 25 target_temp_high: - description: New target high tempereature for HVAC. + description: New target high temperature for HVAC. example: 26 target_temp_low: description: New target low temperature for HVAC. diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 24947ed795216c..ef73d4356d5cec 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -11,7 +11,7 @@ errors as alexa_errors, smart_home as alexa_sh, ) -from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.components.google_assistant import const as gc, smart_home as ga from homeassistant.core import Context, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import HomeAssistantType @@ -160,7 +160,7 @@ async def async_google_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: gconf = await self.get_google_config() return await ga.async_handle_message( - self._hass, gconf, gconf.cloud_user, payload + self._hass, gconf, gconf.cloud_user, payload, gc.SOURCE_CLOUD ) async def async_webhook_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 7160d140b3f812..31a06c94120792 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -65,9 +65,7 @@ def __init__(self, token, country_code, lat, lon): if country_code is not None: device_name = country_code else: - device_name = "{lat}/{lon}".format( - lat=round(self._latitude, 2), lon=round(self._longitude, 2) - ) + device_name = f"{round(self._latitude, 2)}/{round(self._longitude, 2)}" self._friendly_name = f"CO2 intensity - {device_name}" diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 4a3e85d5e4313d..a13dfef11da88a 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -75,9 +75,7 @@ def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_NATIVE_BALANCE: "{} {}".format( - self._native_balance, self._native_currency - ), + ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}", } def update(self): diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 3e3507ea48ddc6..1d189508960364 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -31,6 +31,8 @@ DEVICE_CLASS_TEMPERATURE, POWER_WATT, TEMP_CELSIUS, + TIME_DAYS, + TIME_HOURS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -156,14 +158,14 @@ ATTR_AIR_FLOW_SUPPLY: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Supply airflow", - ATTR_UNIT: "m³/h", + ATTR_UNIT: f"m³/{TIME_HOURS}", ATTR_ICON: "mdi:fan", ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, }, ATTR_AIR_FLOW_EXHAUST: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Exhaust airflow", - ATTR_UNIT: "m³/h", + ATTR_UNIT: f"m³/{TIME_HOURS}", ATTR_ICON: "mdi:fan", ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, }, @@ -177,7 +179,7 @@ ATTR_DAYS_TO_REPLACE_FILTER: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Days to replace filter", - ATTR_UNIT: "days", + ATTR_UNIT: TIME_DAYS, ATTR_ICON: "mdi:calendar", ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, }, @@ -194,7 +196,7 @@ { vol.Optional(CONF_RESOURCES, default=[]): vol.All( cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), + ) } ) @@ -229,7 +231,7 @@ def __init__(self, name, ccb: ComfoConnectBridge, sensor_type) -> None: async def async_added_to_hass(self): """Register for sensor updates.""" _LOGGER.debug( - "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id, + "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id ) async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 5873cdc32712dd..682e23dd14c7c8 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -28,6 +28,8 @@ "scene", ) ON_DEMAND = ("zwave",) +ACTION_CREATE_UPDATE = "create_update" +ACTION_DELETE = "delete" async def async_setup(hass, config): @@ -92,6 +94,7 @@ def __init__( self.data_schema = data_schema self.post_write_hook = post_write_hook self.data_validator = data_validator + self.mutation_lock = asyncio.Lock() def _empty_config(self): """Empty config if file not found.""" @@ -112,8 +115,9 @@ def _delete_value(self, hass, data, config_key): async def get(self, request, config_key): """Fetch device specific config.""" hass = request.app["hass"] - current = await self.read_config(hass) - value = self._get_value(hass, current, config_key) + async with self.mutation_lock: + current = await self.read_config(hass) + value = self._get_value(hass, current, config_key) if value is None: return self.json_message("Resource not found", 404) @@ -146,31 +150,35 @@ async def post(self, request, config_key): path = hass.config.path(self.path) - current = await self.read_config(hass) - self._write_value(hass, current, config_key, data) + async with self.mutation_lock: + current = await self.read_config(hass) + self._write_value(hass, current, config_key, data) - await hass.async_add_executor_job(_write, path, current) + await hass.async_add_executor_job(_write, path, current) if self.post_write_hook is not None: - hass.async_create_task(self.post_write_hook(hass)) + hass.async_create_task( + self.post_write_hook(ACTION_CREATE_UPDATE, config_key) + ) return self.json({"result": "ok"}) async def delete(self, request, config_key): """Remove an entry.""" hass = request.app["hass"] - current = await self.read_config(hass) - value = self._get_value(hass, current, config_key) - path = hass.config.path(self.path) + async with self.mutation_lock: + current = await self.read_config(hass) + value = self._get_value(hass, current, config_key) + path = hass.config.path(self.path) - if value is None: - return self.json_message("Resource not found", 404) + if value is None: + return self.json_message("Resource not found", 404) - self._delete_value(hass, current, config_key) - await hass.async_add_executor_job(_write, path, current) + self._delete_value(hass, current, config_key) + await hass.async_add_executor_job(_write, path, current) if self.post_write_hook is not None: - hass.async_create_task(self.post_write_hook(hass)) + hass.async_create_task(self.post_write_hook(ACTION_DELETE, config_key)) return self.json({"result": "ok"}) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index d7bb1ef9883160..6216a52fc130ae 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -6,18 +6,30 @@ from homeassistant.components.automation.config import async_validate_config_item from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry -from . import EditIdBasedConfigView +from . import ACTION_DELETE, EditIdBasedConfigView async def async_setup(hass): """Set up the Automation config API.""" - async def hook(hass): + async def hook(action, config_key): """post_write_hook for Config View that reloads automations.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + if action != ACTION_DELETE: + return + + ent_reg = await entity_registry.async_get_registry(hass) + + entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) + + if entity_id is None: + return + + ent_reg.async_remove(entity_id) + hass.http.register_view( EditAutomationConfigView( DOMAIN, diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 22df26cce4e6c4..c69a3ed57391c6 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -8,6 +8,7 @@ from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.exceptions import Unauthorized +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, @@ -45,7 +46,9 @@ def _prepare_json(result): if schema is None: data["data_schema"] = [] else: - data["data_schema"] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert( + schema, custom_serializer=cv.custom_serializer + ) return data diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py index ed75a8a04a6d1d..3b1122fc3a57bd 100644 --- a/homeassistant/components/config/customize.py +++ b/homeassistant/components/config/customize.py @@ -12,7 +12,7 @@ async def async_setup(hass): """Set up the Customize config API.""" - async def hook(hass): + async def hook(action, config_key): """post_write_hook for Config View that reloads groups.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD_CORE_CONFIG) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 458a9dd3ecb741..f024f146a601da 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -57,7 +57,9 @@ async def websocket_get_entity(hass, connection, msg): ) return - connection.send_message(websocket_api.result_message(msg["id"], _entry_dict(entry))) + connection.send_message( + websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) + ) @require_admin @@ -68,6 +70,7 @@ async def websocket_get_entity(hass, connection, msg): vol.Required("entity_id"): cv.entity_id, # If passed in, we update value. Passing None will remove old value. vol.Optional("name"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(str, None), vol.Optional("new_entity_id"): str, # We only allow setting disabled_by user via API. vol.Optional("disabled_by"): vol.Any("user", None), @@ -88,11 +91,9 @@ async def websocket_update_entity(hass, connection, msg): changes = {} - if "name" in msg: - changes["name"] = msg["name"] - - if "disabled_by" in msg: - changes["disabled_by"] = msg["disabled_by"] + for key in ("name", "icon", "disabled_by"): + if key in msg: + changes[key] = msg[key] if "new_entity_id" in msg and msg["new_entity_id"] != msg["entity_id"]: changes["new_entity_id"] = msg["new_entity_id"] @@ -113,7 +114,7 @@ async def websocket_update_entity(hass, connection, msg): ) else: connection.send_message( - websocket_api.result_message(msg["id"], _entry_dict(entry)) + websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) ) @@ -151,5 +152,17 @@ def _entry_dict(entry): "disabled_by": entry.disabled_by, "entity_id": entry.entity_id, "name": entry.name, + "icon": entry.icon, "platform": entry.platform, } + + +@callback +def _entry_ext_dict(entry): + """Convert entry to API format.""" + data = _entry_dict(entry) + data["original_name"] = entry.original_name + data["original_icon"] = entry.original_icon + data["unique_id"] = entry.unique_id + data["capabilities"] = entry.capabilities + return data diff --git a/homeassistant/components/config/group.py b/homeassistant/components/config/group.py index d95891af6556c1..e26b2b80bc13f1 100644 --- a/homeassistant/components/config/group.py +++ b/homeassistant/components/config/group.py @@ -10,7 +10,7 @@ async def async_setup(hass): """Set up the Group config API.""" - async def hook(hass): + async def hook(action, config_key): """post_write_hook for Config View that reloads groups.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD) diff --git a/homeassistant/components/config/manifest.json b/homeassistant/components/config/manifest.json index 809db4ffecc7a1..5d5db4b07417b9 100644 --- a/homeassistant/components/config/manifest.json +++ b/homeassistant/components/config/manifest.json @@ -1,6 +1,6 @@ { "domain": "config", - "name": "Config", + "name": "Configuration", "documentation": "https://www.home-assistant.io/integrations/config", "requirements": [], "dependencies": ["http"], diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index 79a30177e470fd..b380656c5418fe 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -5,18 +5,31 @@ from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA from homeassistant.config import SCENE_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -import homeassistant.helpers.config_validation as cv +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.helpers import config_validation as cv, entity_registry -from . import EditIdBasedConfigView +from . import ACTION_DELETE, EditIdBasedConfigView async def async_setup(hass): """Set up the Scene config API.""" - async def hook(hass): + async def hook(action, config_key): """post_write_hook for Config View that reloads scenes.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + if action != ACTION_DELETE: + return + + ent_reg = await entity_registry.async_get_registry(hass) + + entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key) + + if entity_id is None: + return + + ent_reg.async_remove(entity_id) + hass.http.register_view( EditSceneConfigView( DOMAIN, diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py index 032774de47343d..de9c25b223ff65 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -10,7 +10,7 @@ async def async_setup(hass): """Set up the script config API.""" - async def hook(hass): + async def hook(action, config_key): """post_write_hook for Config View that reloads scripts.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index 78333d96355a83..e1e6181d8ca275 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -209,7 +209,7 @@ def async_request_done(self, request_id): entity_id = self._requests.pop(request_id)[0] # If we remove the state right away, it will not be included with - # the result fo the service call (current design limitation). + # the result of the service call (current design limitation). # Instead, we will set it to configured to give as feedback but delete # it shortly after so that it is deleted when the client updates. self.hass.states.async_set(entity_id, STATE_CONFIGURED) @@ -237,7 +237,7 @@ async def async_handle_service_call(self, call): def _generate_unique_id(self): """Generate a unique configurator ID.""" self._cur_id += 1 - return "{}-{}".format(id(self), self._cur_id) + return f"{id(self)}-{self._cur_id}" def _validate_request_id(self, request_id): """Validate that the request belongs to this instance.""" diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 158a365981b838..91031c141ddc2d 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -131,9 +131,22 @@ async def post(self, request, data): """Send a request for processing.""" hass = request.app["hass"] - intent_result = await _async_converse( - hass, data["text"], data.get("conversation_id"), self.context(request) - ) + try: + intent_result = await _async_converse( + hass, data["text"], data.get("conversation_id"), self.context(request) + ) + except intent.IntentError as err: + _LOGGER.error("Error handling intent: %s", err) + return self.json( + { + "success": False, + "error": { + "code": str(err.__class__.__name__).lower(), + "message": str(err), + }, + }, + status_code=500, + ) return self.json(intent_result) diff --git a/homeassistant/components/coolmaster/.translations/es-419.json b/homeassistant/components/coolmaster/.translations/es-419.json new file mode 100644 index 00000000000000..2bcdecb2aeca26 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/es-419.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "off": "Puede ser apagado" + }, + "title": "Configure los detalles de su conexi\u00f3n CoolMasterNet." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/hu.json b/homeassistant/components/coolmaster/.translations/hu.json new file mode 100644 index 00000000000000..cbf055e2fba477 --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Hoszt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/.translations/sv.json b/homeassistant/components/coolmaster/.translations/sv.json new file mode 100644 index 00000000000000..89e2ab32863dfd --- /dev/null +++ b/homeassistant/components/coolmaster/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "connection_error": "Det gick inte att ansluta till CoolMasterNet-instansen. Kontrollera din v\u00e4rd.", + "no_units": "Det gick inte att hitta n\u00e5gra HVAC-enheter i CoolMasterNet-v\u00e4rden." + }, + "step": { + "user": { + "data": { + "cool": "St\u00f6d svalt l\u00e4ge", + "dry": "St\u00f6d torrl\u00e4ge", + "fan_only": "St\u00f6d endast fl\u00e4ktl\u00e4ge", + "heat": "St\u00f6d v\u00e4rmel\u00e4ge", + "heat_cool": "St\u00f6d automatiskt v\u00e4rme/kyl-l\u00e4ge", + "host": "V\u00e4rd", + "off": "Kan st\u00e4ngas av" + }, + "title": "St\u00e4ll in dina CoolMasterNet-anslutningsdetaljer." + } + }, + "title": "CoolMasterNet" + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py index e9cef562647ff2..c267b2831181f3 100644 --- a/homeassistant/components/coolmaster/config_flow.py +++ b/homeassistant/components/coolmaster/config_flow.py @@ -26,6 +26,7 @@ class CoolmasterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + @core.callback def _async_get_entry(self, data): supported_modes = [ key for (key, value) in data.items() if key in AVAILABLE_MODES and value diff --git a/homeassistant/components/cover/.translations/sv.json b/homeassistant/components/cover/.translations/sv.json new file mode 100644 index 00000000000000..906768d3eb330e --- /dev/null +++ b/homeassistant/components/cover/.translations/sv.json @@ -0,0 +1,20 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u00e4r st\u00e4ngd", + "is_closing": "{entity_name} st\u00e4ngs", + "is_open": "{entity_name} \u00e4r \u00f6ppen", + "is_opening": "{entity_name} \u00f6ppnas", + "is_position": "Aktuell position f\u00f6r {entity_name} \u00e4r", + "is_tilt_position": "Aktuell {entity_name} lutningsposition \u00e4r" + }, + "trigger_type": { + "closed": "{entity_name} st\u00e4ngd", + "closing": "{entity_name} st\u00e4nger", + "opened": "{entity_name} \u00f6ppnades", + "opening": "{entity_name} \u00f6ppnas", + "position": "{entity_name} position \u00e4ndras", + "tilt_position": "{entity_name} lutningsposition \u00e4ndras" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/zh-Hant.json b/homeassistant/components/cover/.translations/zh-Hant.json index f2880a72e614e2..790df01d9fc507 100644 --- a/homeassistant/components/cover/.translations/zh-Hant.json +++ b/homeassistant/components/cover/.translations/zh-Hant.json @@ -1,20 +1,20 @@ { "device_automation": { "condition_type": { - "is_closed": "{entity_name} \u5df2\u95dc\u9589", - "is_closing": "{entity_name} \u6b63\u5728\u95dc\u9589", - "is_open": "{entity_name} \u5df2\u958b\u555f", - "is_opening": "{entity_name} \u6b63\u5728\u958b\u555f", - "is_position": "\u76ee\u524d {entity_name} \u4f4d\u7f6e\u70ba", - "is_tilt_position": "\u76ee\u524d {entity_name} \u6a19\u984c\u4f4d\u7f6e\u70ba" + "is_closed": "{entity_name}\u5df2\u95dc\u9589", + "is_closing": "{entity_name}\u6b63\u5728\u95dc\u9589", + "is_open": "{entity_name}\u5df2\u958b\u555f", + "is_opening": "{entity_name}\u6b63\u5728\u958b\u555f", + "is_position": "\u76ee\u524d{entity_name}\u4f4d\u7f6e\u70ba", + "is_tilt_position": "\u76ee\u524d{entity_name}\u6a19\u984c\u4f4d\u7f6e\u70ba" }, "trigger_type": { - "closed": "{entity_name} \u5df2\u95dc\u9589", - "closing": "{entity_name} \u6b63\u5728\u95dc\u9589", - "opened": "{entity_name} \u5df2\u958b\u555f", - "opening": "{entity_name} \u6b63\u5728\u958b\u555f", - "position": "{entity_name} \u4f4d\u7f6e\u8b8a\u66f4", - "tilt_position": "{entity_name} \u6a19\u984c\u4f4d\u7f6e\u8b8a\u66f4" + "closed": "{entity_name}\u5df2\u95dc\u9589", + "closing": "{entity_name}\u6b63\u5728\u95dc\u9589", + "opened": "{entity_name}\u5df2\u958b\u555f", + "opening": "{entity_name}\u6b63\u5728\u958b\u555f", + "position": "{entity_name}\u4f4d\u7f6e\u8b8a\u66f4", + "tilt_position": "{entity_name}\u6a19\u984c\u4f4d\u7f6e\u8b8a\u66f4" } } } \ No newline at end of file diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 2fe4022fb39426..abefd3263bc258 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -236,23 +236,17 @@ def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" raise NotImplementedError() - def async_open_cover(self, **kwargs): - """Open the cover. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) def close_cover(self, **kwargs: Any) -> None: """Close cover.""" raise NotImplementedError() - def async_close_cover(self, **kwargs): - """Close cover. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) + async def async_close_cover(self, **kwargs): + """Close cover.""" + await self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) def toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" @@ -261,69 +255,52 @@ def toggle(self, **kwargs: Any) -> None: else: self.close_cover(**kwargs) - def async_toggle(self, **kwargs): - """Toggle the entity. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_toggle(self, **kwargs): + """Toggle the entity.""" if self.is_closed: - return self.async_open_cover(**kwargs) - return self.async_close_cover(**kwargs) + await self.async_open_cover(**kwargs) + else: + await self.async_close_cover(**kwargs) def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" pass - def async_set_cover_position(self, **kwargs): - """Move the cover to a specific position. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.set_cover_position, **kwargs)) + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + await self.hass.async_add_job(ft.partial(self.set_cover_position, **kwargs)) def stop_cover(self, **kwargs): """Stop the cover.""" pass - def async_stop_cover(self, **kwargs): - """Stop the cover. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + await self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" pass - def async_open_cover_tilt(self, **kwargs): - """Open the cover tilt. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.open_cover_tilt, **kwargs)) + async def async_open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + await self.hass.async_add_job(ft.partial(self.open_cover_tilt, **kwargs)) def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" pass - def async_close_cover_tilt(self, **kwargs): - """Close the cover tilt. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.close_cover_tilt, **kwargs)) + async def async_close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + await self.hass.async_add_job(ft.partial(self.close_cover_tilt, **kwargs)) def set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" pass - def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job( + async def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + await self.hass.async_add_job( ft.partial(self.set_cover_tilt_position, **kwargs) ) @@ -331,12 +308,9 @@ def stop_cover_tilt(self, **kwargs): """Stop the cover.""" pass - def async_stop_cover_tilt(self, **kwargs): - """Stop the cover. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.stop_cover_tilt, **kwargs)) + async def async_stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + await self.hass.async_add_job(ft.partial(self.stop_cover_tilt, **kwargs)) def toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" @@ -345,11 +319,9 @@ def toggle_tilt(self, **kwargs: Any) -> None: else: self.close_cover_tilt(**kwargs) - def async_toggle_tilt(self, **kwargs): - """Toggle the entity. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_toggle_tilt(self, **kwargs): + """Toggle the entity.""" if self.current_cover_tilt_position == 0: - return self.async_open_cover_tilt(**kwargs) - return self.async_close_cover_tilt(**kwargs) + await self.async_open_cover_tilt(**kwargs) + else: + await self.async_close_cover_tilt(**kwargs) diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index ec6da84e5f6ee0..7c6dc5fed722a3 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -18,7 +18,7 @@ STATE_OPEN, STATE_OPENING, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( condition, config_validation as cv, @@ -163,6 +163,7 @@ async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> } +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: @@ -196,6 +197,7 @@ def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: f"{{{{ state.attributes.{position} }}}}" ) + @callback def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate template based if-condition.""" value_template.hass = hass diff --git a/homeassistant/components/daikin/.translations/pl.json b/homeassistant/components/daikin/.translations/pl.json index 5d5448a93dbff9..3caea70c4de8ea 100644 --- a/homeassistant/components/daikin/.translations/pl.json +++ b/homeassistant/components/daikin/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", "device_fail": "Nieoczekiwany b\u0142\u0105d tworzenia urz\u0105dzenia.", "device_timeout": "Przekroczono limit czasu \u0142\u0105czenia z urz\u0105dzeniem." }, diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index f83566e66e8a7f..e3e2e6a0f27fc2 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -47,9 +47,9 @@ def __init__(self, api, monitored_state, units: UnitSystem, name=None) -> None: self._api = api self._sensor = SENSOR_TYPES.get(monitored_state) if name is None: - name = "{} {}".format(self._sensor[CONF_NAME], api.name) + name = f"{self._sensor[CONF_NAME]} {api.name}" - self._name = "{} {}".format(name, monitored_state.replace("_", " ")) + self._name = f"{name} {monitored_state.replace('_', ' ')}" self._device_attribute = monitored_state if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 4d3b0d3eadea7f..e22c0b04995daf 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -54,7 +54,7 @@ def icon(self): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._api.name, self._api.device.zones[self._zone_id][0]) + return f"{self._api.name} {self._api.device.zones[self._zone_id][0]}" @property def is_on(self): diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index ea0002d0ac3c43..247eb955154285 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -48,10 +48,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ["Danfoss Air Remaining Filter", "%", ReadCommand.filterPercent, None], ["Danfoss Air Humidity", "%", ReadCommand.humidity, DEVICE_CLASS_HUMIDITY], ["Danfoss Air Fan Step", "%", ReadCommand.fan_step, None], - ["Dandoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], - ["Dandoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], + ["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], + ["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], [ - "Dandoss Air Dial Battery", + "Danfoss Air Dial Battery", "%", ReadCommand.battery_percent, DEVICE_CLASS_BATTERY, diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 9f99b37a2013c5..46741e3aca7e39 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -15,6 +15,10 @@ CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TIME_HOURS, UNIT_UV_INDEX, ) import homeassistant.helpers.config_validation as cv @@ -99,11 +103,11 @@ ], "precip_intensity": [ "Precip Intensity", - "mm/h", + f"mm/{TIME_HOURS}", "in", - "mm/h", - "mm/h", - "mm/h", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", "mdi:weather-rainy", ["currently", "minutely", "hourly", "daily"], ], @@ -159,11 +163,11 @@ ], "wind_speed": [ "Wind Speed", - "m/s", - "mph", - "km/h", - "mph", - "mph", + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, "mdi:weather-windy", ["currently", "hourly", "daily"], ], @@ -179,11 +183,11 @@ ], "wind_gust": [ "Wind Gust", - "m/s", - "mph", - "km/h", - "mph", - "mph", + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, "mdi:weather-windy-variant", ["currently", "hourly", "daily"], ], @@ -319,11 +323,11 @@ ], "precip_intensity_max": [ "Daily Max Precip Intensity", - "mm/h", + f"mm/{TIME_HOURS}", "in", - "mm/h", - "mm/h", - "mm/h", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", "mdi:thermometer", ["daily"], ], diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index adb8bb1f95c795..52cbe906402923 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -61,8 +61,8 @@ def logbook_entry_listener(event): title="Home Assistant", text=f"%%% \n **{name}** {message} \n %%%", tags=[ - "entity:{}".format(event.data.get("entity_id")), - "domain:{}".format(event.data.get("domain")), + f"entity:{event.data.get('entity_id')}", + f"domain:{event.data.get('domain')}", ], ) @@ -84,7 +84,7 @@ def state_changed_listener(event): for key, value in states.items(): if isinstance(value, (float, int)): - attribute = "{}.{}".format(metric, key.replace(" ", "_")) + attribute = f"{metric}.{key.replace(' ', '_')}" statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) _LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index a51bfa056f64e5..e690d597dce847 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -66,26 +66,32 @@ }, "trigger_type": { "remote_awakened": "Dispositiu despertat", - "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades consecutives", - "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut continuament", + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut cont\u00ednuament", "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", - "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades consecutives", - "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades consecutives", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades", "remote_button_rotated": "Bot\u00f3 \"{subtype}\" girat", "remote_button_rotation_stopped": "La rotaci\u00f3 del bot\u00f3 \"{subtype}\" s'ha aturat", "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", - "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades consecutives", + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades", "remote_double_tap": "Dispositiu \"{subtype}\" tocat dues vegades", + "remote_double_tap_any_side": "Dispositiu tocat dues vegades a alguna cara", "remote_falling": "Dispositiu en caiguda lliure", + "remote_flip_180_degrees": "Dispositiu voltejat 180 graus", + "remote_flip_90_degrees": "Dispositiu voltejat 90 graus", "remote_gyro_activated": "Dispositiu sacsejat", "remote_moved": "Dispositiu mogut amb la \"{subtype}\" amunt", + "remote_moved_any_side": "Dispositiu mogut amb alguna cara amunt", "remote_rotate_from_side_1": "Dispositiu rotat de la \"cara 1\" a la \"{subtype}\"", "remote_rotate_from_side_2": "Dispositiu rotat de la \"cara 2\" a la \"{subtype}\"", "remote_rotate_from_side_3": "Dispositiu rotat de la \"cara 3\" a la \"{subtype}\"", "remote_rotate_from_side_4": "Dispositiu rotat de la \"cara 4\" a la \"{subtype}\"", "remote_rotate_from_side_5": "Dispositiu rotat de la \"cara 5\" a la \"{subtype}\"", - "remote_rotate_from_side_6": "Dispositiu rotat de la \"cara 6\" a la \"{subtype}\"" + "remote_rotate_from_side_6": "Dispositiu rotat de la \"cara 6\" a la \"{subtype}\"", + "remote_turned_clockwise": "Dispositiu girat en sentit horari", + "remote_turned_counter_clockwise": "Dispositiu girat en sentit antihorari" } }, "options": { @@ -102,7 +108,8 @@ "allow_clip_sensor": "Permet sensors deCONZ CLIP", "allow_deconz_groups": "Permet grups de llums deCONZ" }, - "description": "Configura la visibilitat dels tipus dels dispositius deCONZ" + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ", + "title": "Opcions de deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json index c665690796dd4e..954d1c8eb6efaf 100644 --- a/homeassistant/components/deconz/.translations/cs.json +++ b/homeassistant/components/deconz/.translations/cs.json @@ -10,6 +10,10 @@ }, "flow_title": "Br\u00e1na deCONZ ZigBee ({host})", "step": { + "hassio_confirm": { + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k deCONZ br\u00e1n\u011b pomoc\u00ed hass.io {addon}?", + "title": "deCONZ Zigbee br\u00e1na prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + }, "init": { "data": { "host": "Hostitel", diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json index ed1f0b06e643ae..d1af7e1f4ba18a 100644 --- a/homeassistant/components/deconz/.translations/da.json +++ b/homeassistant/components/deconz/.translations/da.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Tillad deCONZ CLIP-sensorer", "allow_deconz_groups": "Tillad deCONZ-lysgrupper" }, - "description": "Konfigurer synligheden af deCONZ-enhedstyper" + "description": "Konfigurer synligheden af deCONZ-enhedstyper", + "title": "deCONZ-indstillinger" } } } diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 479e645173bc08..c3ad3cd24c83b7 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen", "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" }, - "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren" + "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren", + "title": "deCONZ-Optionen" } } } diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index b3d9e00bfe6f5a..756636ad90a41a 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Allow deCONZ CLIP sensors", "allow_deconz_groups": "Allow deCONZ light groups" }, - "description": "Configure visibility of deCONZ device types" + "description": "Configure visibility of deCONZ device types", + "title": "deCONZ options" } } } diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json index 6f5513d9729b5b..cfff05b1e02d18 100644 --- a/homeassistant/components/deconz/.translations/es.json +++ b/homeassistant/components/deconz/.translations/es.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Permitir sensores deCONZ CLIP", "allow_deconz_groups": "Permitir grupos de luz deCONZ" }, - "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ" + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ", + "title": "Opciones deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json index c900bdab6abebf..214c887cc34cff 100644 --- a/homeassistant/components/deconz/.translations/fr.json +++ b/homeassistant/components/deconz/.translations/fr.json @@ -77,6 +77,7 @@ "remote_button_short_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9", "remote_button_triple_press": "Bouton \"{subtype}\" triple cliqu\u00e9", "remote_double_tap": "Appareil \"{subtype}\" tapot\u00e9 deux fois", + "remote_double_tap_any_side": "Appareil double tap\u00e9 de n\u2019importe quel c\u00f4t\u00e9", "remote_falling": "Appareil en chute libre", "remote_flip_180_degrees": "Dispositif retourn\u00e9 \u00e0 180 degr\u00e9s", "remote_flip_90_degrees": "Dispositif retourn\u00e9 \u00e0 90 degr\u00e9s", diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json index 9e8109107436df..c5bf9718127a31 100644 --- a/homeassistant/components/deconz/.translations/hu.json +++ b/homeassistant/components/deconz/.translations/hu.json @@ -2,13 +2,23 @@ "config": { "abort": { "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "Az \u00e1tj\u00e1r\u00f3 konfigur\u00e1ci\u00f3s folyamata m\u00e1r folyamatban van.", "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", - "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat" + "not_deconz_bridge": "Nem egy deCONZ \u00e1tj\u00e1r\u00f3", + "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat", + "updated_instance": "A deCONZ-p\u00e9ld\u00e1ny \u00faj \u00e1llom\u00e1sc\u00edmmel friss\u00edtve" }, "error": { "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" }, + "flow_title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 ({host})", "step": { + "hassio_confirm": { + "data": { + "allow_clip_sensor": "Virtu\u00e1lis \u00e9rz\u00e9kel\u0151k import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se" + }, + "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 a Hass.io kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + }, "init": { "data": { "host": "Hoszt", @@ -32,7 +42,72 @@ }, "device_automation": { "trigger_subtype": { - "close": "Bez\u00e1r\u00e1s" + "both_buttons": "Mindk\u00e9t gomb", + "button_1": "Els\u0151 gomb", + "button_2": "M\u00e1sodik gomb", + "button_3": "Harmadik gomb", + "button_4": "Negyedik gomb", + "close": "Bez\u00e1r\u00e1s", + "dim_down": "S\u00f6t\u00e9t\u00edt", + "dim_up": "Vil\u00e1gos\u00edt", + "left": "Balra", + "open": "Nyitva", + "right": "Jobbra", + "side_1": "1. oldal", + "side_2": "2. oldal", + "side_3": "3. oldal", + "side_4": "4. oldal", + "side_5": "5. oldal", + "side_6": "6. oldal", + "turn_off": "Kikapcsolva", + "turn_on": "Bekapcsolva" + }, + "trigger_type": { + "remote_awakened": "A k\u00e9sz\u00fcl\u00e9k fel\u00e9bredt", + "remote_button_double_press": "\" {subtype} \" gombra k\u00e9tszer kattintottak", + "remote_button_long_press": "A \" {subtype} \" gomb folyamatosan lenyomva", + "remote_button_long_release": "A \" {subtype} \" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", + "remote_button_quadruple_press": "\" {subtype} \" gombra n\u00e9gyszer kattintottak", + "remote_button_quintuple_press": "\" {subtype} \" gombra \u00f6tsz\u00f6r kattintottak", + "remote_button_rotated": "A gomb elforgatva: \" {subtype} \"", + "remote_button_rotation_stopped": "A (z) \" {subtype} \" gomb forg\u00e1sa le\u00e1llt", + "remote_button_short_press": "\" {subtype} \" gomb lenyomva", + "remote_button_short_release": "\"{alt\u00edpus}\" gomb elengedve", + "remote_button_triple_press": "\" {subtype} \" gombra h\u00e1romszor kattintottak", + "remote_double_tap": "Az \" {subtype} \" eszk\u00f6z dupla kattint\u00e1sa", + "remote_double_tap_any_side": "A k\u00e9sz\u00fcl\u00e9k b\u00e1rmelyik oldal\u00e1n dupl\u00e1n koppint.", + "remote_falling": "K\u00e9sz\u00fcl\u00e9k szabades\u00e9sben", + "remote_flip_180_degrees": "180 fokkal megd\u00f6nt\u00f6tt eszk\u00f6z", + "remote_flip_90_degrees": "90 fokkal megd\u00f6nt\u00f6tt eszk\u00f6z", + "remote_gyro_activated": "A k\u00e9sz\u00fcl\u00e9k meg lett r\u00e1zva", + "remote_moved": "Az eszk\u00f6z a \" {subtype} \"-lal felfel\u00e9 mozgatva", + "remote_moved_any_side": "A k\u00e9sz\u00fcl\u00e9k valamelyik oldal\u00e1val felfel\u00e9 mozogott", + "remote_rotate_from_side_1": "Az eszk\u00f6z a \"1. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_2": "Az eszk\u00f6z a \"2. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_3": "Az eszk\u00f6z a \"3. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_4": "Az eszk\u00f6z a \"4. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_5": "Az eszk\u00f6z a \"5. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_6": "Az eszk\u00f6z a \"6. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_turned_clockwise": "A k\u00e9sz\u00fcl\u00e9k az \u00f3ramutat\u00f3 j\u00e1r\u00e1s\u00e1val megegyez\u0151en fordult", + "remote_turned_counter_clockwise": "A k\u00e9sz\u00fcl\u00e9k az \u00f3ramutat\u00f3 j\u00e1r\u00e1s\u00e1val ellent\u00e9tes ir\u00e1nyban fordult" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Enged\u00e9lyezze a deCONZ CLIP \u00e9rz\u00e9kel\u0151ket", + "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se" + }, + "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Enged\u00e9lyezze a deCONZ CLIP \u00e9rz\u00e9kel\u0151ket", + "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se" + }, + "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" + } } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json index 980409d6987aeb..f6223cec6c14aa 100644 --- a/homeassistant/components/deconz/.translations/it.json +++ b/homeassistant/components/deconz/.translations/it.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Consentire sensori CLIP deCONZ", "allow_deconz_groups": "Consentire gruppi luce deCONZ" }, - "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ" + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ", + "title": "Opzioni deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index d526d706a8bb76..1b72545bc0946c 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" }, - "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131" + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131", + "title": "deCONZ \uc635\uc158" } } } diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 4b04cfa03ce086..42fd840524f300 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" }, - "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren" + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren", + "title": "deCONZ Optiounen" } } } diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index c0ee391b0c7673..585c09c5339ee8 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -77,15 +77,21 @@ "remote_button_short_release": "\"{subtype}\" knop losgelaten", "remote_button_triple_press": "\" {subtype} \" knop driemaal geklikt", "remote_double_tap": "Apparaat \"{subtype}\" dubbel getikt", + "remote_double_tap_any_side": "Apparaat dubbel getikt aan elke kant", "remote_falling": "Apparaat in vrije val", + "remote_flip_180_degrees": "Apparaat 180 graden omgedraaid", + "remote_flip_90_degrees": "Apparaat 90 graden omgedraaid", "remote_gyro_activated": "Apparaat geschud", "remote_moved": "Apparaat verplaatst met \"{subtype}\" omhoog", + "remote_moved_any_side": "Apparaat gedraaid met elke kant naar boven", "remote_rotate_from_side_1": "Apparaat gedraaid van \"zijde 1\" naar \"{subtype}\"\".", "remote_rotate_from_side_2": "Apparaat gedraaid van \"zijde 2\" naar \"{subtype}\"\".", "remote_rotate_from_side_3": "Apparaat gedraaid van \"zijde 3\" naar \" {subtype} \"", "remote_rotate_from_side_4": "Apparaat gedraaid van \"zijde 4\" naar \" {subtype} \"", "remote_rotate_from_side_5": "Apparaat gedraaid van \"zijde 5\" naar \" {subtype} \"", - "remote_rotate_from_side_6": "Apparaat gedraaid van \"zijde 6\" naar \" {subtype} \"" + "remote_rotate_from_side_6": "Apparaat gedraaid van \"zijde 6\" naar \" {subtype} \"", + "remote_turned_clockwise": "Apparaat met de klok mee gedraaid", + "remote_turned_counter_clockwise": "Apparaat tegen de klok in gedraaid" } }, "options": { diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index d6133542c64e30..3387c993ae0240 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer", "allow_deconz_groups": "Tillat deCONZ lys grupper" }, - "description": "Konfigurere synlighet av deCONZ enhetstyper" + "description": "Konfigurere synlighet av deCONZ enhetstyper", + "title": "deCONZ alternativer" } } } diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index df85e7b8d1de7a..d12e633bf23a24 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Mostek jest ju\u017c skonfigurowany", - "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", + "already_configured": "Mostek jest ju\u017c skonfigurowany.", + "already_in_progress": "Konfiguracja mostka jest ju\u017c w toku.", "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", "not_deconz_bridge": "To nie jest mostek deCONZ", "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ", @@ -108,7 +108,8 @@ "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP", "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" }, - "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ" + "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ", + "title": "Opcje deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index 3c61e447bcaf89..054c85f595acfb 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -15,7 +15,7 @@ "step": { "hassio_confirm": { "data": { - "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432", "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" }, "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?", @@ -34,7 +34,7 @@ }, "options": { "data": { - "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432", "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" }, "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 deCONZ" @@ -98,17 +98,18 @@ "step": { "async_step_deconz_devices": { "data": { - "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b deCONZ CLIP", "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" }, "deconz_devices": { "data": { - "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b deCONZ CLIP", "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json index a7b5160e8a3c50..3d74d6cb9443c5 100644 --- a/homeassistant/components/deconz/.translations/sv.json +++ b/homeassistant/components/deconz/.translations/sv.json @@ -11,13 +11,14 @@ "error": { "no_key": "Det gick inte att ta emot en API-nyckel" }, + "flow_title": "deCONZ Zigbee gateway ({host})", "step": { "hassio_confirm": { "data": { "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer", "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper" }, - "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till deCONZ gateway som tillhandah\u00e5lls av hass.io till\u00e4gg {addon}?", + "description": "Vill du konfigurera Home Assistant att ansluta till den deCONZ-gateway som tillhandah\u00e5lls av Hass.io-till\u00e4gget {addon}?", "title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg" }, "init": { @@ -40,5 +41,75 @@ } }, "title": "deCONZ Zigbee Gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "B\u00e5da knapparna", + "button_1": "F\u00f6rsta knappen", + "button_2": "Andra knappen", + "button_3": "Tredje knappen", + "button_4": "Fj\u00e4rde knappen", + "close": "St\u00e4ng", + "dim_down": "Dimma ned", + "dim_up": "Dimma upp", + "left": "V\u00e4nster", + "open": "\u00d6ppen", + "right": "H\u00f6ger", + "side_1": "Sida 1", + "side_2": "Sida 2", + "side_3": "Sida 3", + "side_4": "Sida 4", + "side_5": "Sida 5", + "side_6": "Sida 6", + "turn_off": "St\u00e4ng av", + "turn_on": "Starta" + }, + "trigger_type": { + "remote_awakened": "Enheten v\u00e4cktes", + "remote_button_double_press": "\"{subtype}\"-knappen dubbelklickades", + "remote_button_long_press": "\"{subtype}\"-knappen kontinuerligt nedtryckt", + "remote_button_long_release": "\"{subtype}\"-knappen sl\u00e4pptes efter ett l\u00e5ngttryck", + "remote_button_quadruple_press": "\"{subtype}\"-knappen klickades \nfyrfaldigt", + "remote_button_quintuple_press": "\"{subtype}\"-knappen klickades \nfemfaldigt", + "remote_button_rotated": "Knappen roterade \"{subtype}\"", + "remote_button_rotation_stopped": "Knapprotationen \"{subtype}\" stoppades", + "remote_button_short_press": "\"{subtype}\"-knappen trycktes in", + "remote_button_short_release": "\"{subtype}\"-knappen sl\u00e4ppt", + "remote_button_triple_press": "\"{subtype}\"-knappen trippelklickad", + "remote_double_tap": "Enheten \"{subtype}\" dubbeltryckt", + "remote_double_tap_any_side": "Enheten dubbeltryckt p\u00e5 valfri sida", + "remote_falling": "Enhet i fritt fall", + "remote_flip_180_degrees": "Enheten v\u00e4nd 180 grader", + "remote_flip_90_degrees": "Enheten v\u00e4nd 90 grader", + "remote_gyro_activated": "Enhet skakad", + "remote_moved": "Enheten flyttades med \"{subtype}\" upp", + "remote_moved_any_side": "Enheten flyttades med valfri sida upp\u00e5t", + "remote_rotate_from_side_1": "Enheten roterades fr\u00e5n \"sida 1\" till \"{subtype}\"", + "remote_rotate_from_side_2": "Enheten roterades fr\u00e5n \"sida 2\" till \"{subtype}\"", + "remote_rotate_from_side_3": "Enheten roterades fr\u00e5n \"sida 3\" till \"{subtype}\"", + "remote_rotate_from_side_4": "Enheten roterades fr\u00e5n \"sida 4\" till \"{subtype}\"", + "remote_rotate_from_side_5": "Enheten roterades fr\u00e5n \"sida 5\" till \"{subtype}\"", + "remote_rotate_from_side_6": "Enheten roterades fr\u00e5n \"sida 6\" till \"{subtype}\"", + "remote_turned_clockwise": "Enheten vriden medurs", + "remote_turned_counter_clockwise": "Enheten v\u00e4nde moturs" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Till\u00e5t deCONZ CLIP-sensorer", + "allow_deconz_groups": "Till\u00e5t deCONZ ljusgrupper" + }, + "description": "Konfigurera synlighet f\u00f6r deCONZ-enhetstyper" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Till\u00e5t deCONZ CLIP-sensorer", + "allow_deconz_groups": "Till\u00e5t deCONZ ljusgrupper" + }, + "description": "Konfigurera synlighet f\u00f6r deCONZ-enhetstyper" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json index 37b82cff29c604..ce51a54ac77cd6 100644 --- a/homeassistant/components/deconz/.translations/zh-Hans.json +++ b/homeassistant/components/deconz/.translations/zh-Hans.json @@ -52,5 +52,12 @@ "remote_rotate_from_side_5": "\u8bbe\u5907\u4ece\u201c\u7b2c 5 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", "remote_rotate_from_side_6": "\u8bbe\u5907\u4ece\u201c\u7b2c 6 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d" } + }, + "options": { + "step": { + "deconz_devices": { + "title": "deCONZ \u9009\u9879" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 96ab68a8dbb1f0..073ebd784c612e 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668", "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" }, - "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b" + "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b", + "title": "deCONZ \u9078\u9805" } } } diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 225a28f52f8816..2514a49f23c568 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -8,7 +8,7 @@ from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice -from .gateway import DeconzEntityHandler, get_gateway_from_config_entry +from .gateway import get_gateway_from_config_entry ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" @@ -23,8 +23,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ binary sensor.""" gateway = get_gateway_from_config_entry(hass, config_entry) - entity_handler = DeconzEntityHandler(gateway) - @callback def async_add_sensor(sensors, new=True): """Add binary sensor from deCONZ.""" @@ -32,10 +30,16 @@ def async_add_sensor(sensors, new=True): for sensor in sensors: - if new and sensor.BINARY: - new_sensor = DeconzBinarySensor(sensor, gateway) - entity_handler.add_entity(new_sensor) - entities.append(new_sensor) + if ( + new + and sensor.BINARY + and ( + gateway.option_allow_clip_sensor + or not sensor.type.startswith("CLIP") + ) + and sensor.deconz_id not in gateway.deconz_ids.values() + ): + entities.append(DeconzBinarySensor(sensor, gateway)) async_add_entities(entities, True) @@ -89,8 +93,10 @@ def device_state_attributes(self): if self._device.secondary_temperature is not None: attr[ATTR_TEMPERATURE] = self._device.secondary_temperature - if self._device.type in Presence.ZHATYPE and self._device.dark is not None: - attr[ATTR_DARK] = self._device.dark + if self._device.type in Presence.ZHATYPE: + + if self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark elif self._device.type in Vibration.ZHATYPE: attr[ATTR_ORIENTATION] = self._device.orientation diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index ba1f1ce846af69..34cc0e0b832a4e 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -37,7 +37,15 @@ def async_add_climate(sensors, new=True): for sensor in sensors: - if new and sensor.type in Thermostat.ZHATYPE: + if ( + new + and sensor.type in Thermostat.ZHATYPE + and ( + gateway.option_allow_clip_sensor + or not sensor.type.startswith("CLIP") + ) + and sensor.deconz_id not in gateway.deconz_ids.values() + ): entities.append(DeconzThermostat(sensor, gateway)) async_add_entities(entities, True) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 43c6cee9193668..3a38a67f0c6e3c 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure deCONZ component.""" import asyncio +from pprint import pformat from urllib.parse import urlparse import async_timeout @@ -26,6 +27,7 @@ DEFAULT_ALLOW_DECONZ_GROUPS, DEFAULT_PORT, DOMAIN, + LOGGER, ) DECONZ_MANUFACTURERURL = "http://www.dresden-elektronik.de" @@ -93,6 +95,8 @@ async def async_step_user(self, user_input=None): except (asyncio.TimeoutError, ResponseError): self.bridges = [] + LOGGER.debug("Discovered deCONZ gateways %s", pformat(self.bridges)) + if len(self.bridges) == 1: return await self.async_step_user(self.bridges[0]) @@ -121,6 +125,10 @@ async def async_step_link(self, user_input=None): """Attempt to link with the deCONZ bridge.""" errors = {} + LOGGER.debug( + "Preparing linking with deCONZ gateway %s", pformat(self.deconz_config) + ) + if user_input is not None: session = aiohttp_client.async_get_clientsession(self.hass) @@ -147,41 +155,21 @@ async def _create_entry(self): self.bridge_id = await async_get_bridge_id( session, **self.deconz_config ) - - for entry in self.hass.config_entries.async_entries(DOMAIN): - if self.bridge_id == entry.unique_id: - return self._update_entry( - entry, - host=self.deconz_config[CONF_HOST], - port=self.deconz_config[CONF_PORT], - api_key=self.deconz_config[CONF_API_KEY], - ) - await self.async_set_unique_id(self.bridge_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.deconz_config[CONF_HOST], + CONF_PORT: self.deconz_config[CONF_PORT], + CONF_API_KEY: self.deconz_config[CONF_API_KEY], + } + ) + except asyncio.TimeoutError: return self.async_abort(reason="no_bridges") return self.async_create_entry(title=self.bridge_id, data=self.deconz_config) - def _update_entry(self, entry, host, port, api_key=None): - """Update existing entry.""" - if ( - entry.data[CONF_HOST] == host - and entry.data[CONF_PORT] == port - and (api_key is None or entry.data[CONF_API_KEY] == api_key) - ): - return self.async_abort(reason="already_configured") - - entry.data[CONF_HOST] = host - entry.data[CONF_PORT] = port - - if api_key is not None: - entry.data[CONF_API_KEY] = api_key - - self.hass.config_entries.async_update_entry(entry) - return self.async_abort(reason="updated_instance") - async def async_step_ssdp(self, discovery_info): """Handle a discovered deCONZ bridge.""" if ( @@ -190,16 +178,19 @@ async def async_step_ssdp(self, discovery_info): ): return self.async_abort(reason="not_deconz_bridge") + LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) + self.bridge_id = normalize_bridge_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) - for entry in self.hass.config_entries.async_entries(DOMAIN): - if self.bridge_id == entry.unique_id: - if entry.source == "hassio": - return self.async_abort(reason="already_configured") - return self._update_entry(entry, parsed_url.hostname, parsed_url.port) + entry = await self.async_set_unique_id(self.bridge_id) + if entry and entry.source == "hassio": + return self.async_abort(reason="already_configured") + + self._abort_if_unique_id_configured( + updates={CONF_HOST: parsed_url.hostname, CONF_PORT: parsed_url.port} + ) - await self.async_set_unique_id(self.bridge_id) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context["title_placeholders"] = {"host": parsed_url.hostname} @@ -215,18 +206,19 @@ async def async_step_hassio(self, user_input=None): This flow is triggered by the discovery component. """ + LOGGER.debug("deCONZ HASSIO discovery %s", pformat(user_input)) + self.bridge_id = normalize_bridge_id(user_input[CONF_SERIAL]) + await self.async_set_unique_id(self.bridge_id) - for entry in self.hass.config_entries.async_entries(DOMAIN): - if self.bridge_id == entry.unique_id: - return self._update_entry( - entry, - user_input[CONF_HOST], - user_input[CONF_PORT], - user_input[CONF_API_KEY], - ) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_API_KEY: user_input[CONF_API_KEY], + } + ) - await self.async_set_unique_id(self.bridge_id) self._hassio_discovery = user_input return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e951e61fde7e47..cd125613f21f21 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -1,7 +1,7 @@ """Constants for the deCONZ component.""" import logging -_LOGGER = logging.getLogger(__package__) +LOGGER = logging.getLogger(__package__) DOMAIN = "deconz" @@ -31,13 +31,6 @@ NEW_SCENE = "scenes" NEW_SENSOR = "sensors" -NEW_DEVICE = { - NEW_GROUP: "deconz_new_group_{}", - NEW_LIGHT: "deconz_new_light_{}", - NEW_SCENE: "deconz_new_scene_{}", - NEW_SENSOR: "deconz_new_sensor_{}", -} - ATTR_DARK = "dark" ATTR_OFFSET = "offset" ATTR_ON = "on" @@ -47,7 +40,7 @@ WINDOW_COVERS = ["Window covering device"] COVER_TYPES = DAMPERS + WINDOW_COVERS -POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] +POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 06756bb49f6dfc..b3dedf6cf0033f 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -63,17 +63,6 @@ def entity_registry_enabled_default(self): Daylight is a virtual sensor from deCONZ that should never be enabled by default. """ - if not self.gateway.option_allow_clip_sensor and self._device.type.startswith( - "CLIP" - ): - return False - - if ( - not self.gateway.option_allow_deconz_groups - and self._device.type == "LightGroup" - ): - return False - if self._device.type == "Daylight": return False @@ -81,13 +70,18 @@ def entity_registry_enabled_default(self): async def async_added_to_hass(self): """Subscribe to device events.""" - self._device.register_async_callback(self.async_update_callback) + self._device.register_callback(self.async_update_callback) self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id self.listeners.append( async_dispatcher_connect( self.hass, self.gateway.signal_reachable, self.async_update_callback ) ) + self.listeners.append( + async_dispatcher_connect( + self.hass, self.gateway.signal_remove_entity, self.async_remove_self + ) + ) async def async_will_remove_from_hass(self) -> None: """Disconnect device object when removed.""" @@ -96,6 +90,15 @@ async def async_will_remove_from_hass(self) -> None: for unsub_dispatcher in self.listeners: unsub_dispatcher() + async def async_remove_self(self, deconz_ids: list) -> None: + """Schedule removal of this entity. + + Called by signal_remove_entity scheduled by async_added_to_hass. + """ + if self._device.deconz_id not in deconz_ids: + return + await self.async_remove() + @callback def async_update_callback(self, force_update=False, ignore_update=False): """Update the device's state.""" diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 527e8d2ab7a8c0..1009ae4e54c4d3 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -3,7 +3,7 @@ from homeassistant.core import callback from homeassistant.util import slugify -from .const import _LOGGER, CONF_GESTURE +from .const import CONF_GESTURE, LOGGER from .deconz_device import DeconzBase CONF_DECONZ_EVENT = "deconz_event" @@ -21,11 +21,11 @@ def __init__(self, device, gateway): """Register callback that will be used for signals.""" super().__init__(device, gateway) - self._device.register_async_callback(self.async_update_callback) + self._device.register_callback(self.async_update_callback) self.device_id = None self.event_id = slugify(self._device.name) - _LOGGER.debug("deCONZ event created: %s", self.event_id) + LOGGER.debug("deCONZ event created: %s", self.event_id) @property def device(self): @@ -50,7 +50,7 @@ def async_update_callback(self, force_update=False, ignore_update=False): CONF_EVENT: self._device.state, } - if self._device.gesture: + if self._device.gesture is not None: data[CONF_GESTURE] = self._device.gesture self.gateway.hass.bus.async_fire(CONF_DECONZ_EVENT, data) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 04452cc313cae1..b59c80a0dc83a1 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -9,24 +9,20 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity_registry import ( - DISABLED_CONFIG_ENTRY, - async_get_registry, -) +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( - _LOGGER, CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_DECONZ_GROUPS, CONF_MASTER_GATEWAY, DEFAULT_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_DECONZ_GROUPS, DOMAIN, - NEW_DEVICE, + LOGGER, + NEW_GROUP, + NEW_LIGHT, + NEW_SCENE, + NEW_SENSOR, SUPPORTED_PLATFORMS, ) from .errors import AuthenticationRequired, CannotConnect @@ -52,6 +48,9 @@ def __init__(self, hass, config_entry) -> None: self.events = [] self.listeners = [] + self._current_option_allow_clip_sensor = self.option_allow_clip_sensor + self._current_option_allow_deconz_groups = self.option_allow_deconz_groups + @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" @@ -103,7 +102,7 @@ async def async_setup(self) -> bool: raise ConfigEntryNotReady except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error connecting with deCONZ gateway: %s", err) + LOGGER.error("Error connecting with deCONZ gateway: %s", err) return False for component in SUPPORTED_PLATFORMS: @@ -115,23 +114,64 @@ async def async_setup(self) -> bool: self.api.start() - self.config_entry.add_update_listener(self.async_new_address) - self.config_entry.add_update_listener(self.async_options_updated) + self.config_entry.add_update_listener(self.async_config_entry_updated) return True @staticmethod - async def async_new_address(hass, entry) -> None: - """Handle signals of gateway getting new address. + async def async_config_entry_updated(hass, entry) -> None: + """Handle signals of config entry being updated. - This is a static method because a class method (bound method), - can not be used with weak references. + This is a static method because a class method (bound method), can not be used with weak references. + Causes for this is either discovery updating host address or config entry options changing. """ gateway = get_gateway_from_config_entry(hass, entry) if gateway.api.host != entry.data[CONF_HOST]: gateway.api.close() gateway.api.host = entry.data[CONF_HOST] gateway.api.start() + return + + await gateway.options_updated() + + async def options_updated(self): + """Manage entities affected by config entry options.""" + deconz_ids = [] + + if self._current_option_allow_clip_sensor != self.option_allow_clip_sensor: + self._current_option_allow_clip_sensor = self.option_allow_clip_sensor + + sensors = [ + sensor + for sensor in self.api.sensors.values() + if sensor.type.startswith("CLIP") + ] + + if self.option_allow_clip_sensor: + self.async_add_device_callback(NEW_SENSOR, sensors) + else: + deconz_ids += [sensor.deconz_id for sensor in sensors] + + if self._current_option_allow_deconz_groups != self.option_allow_deconz_groups: + self._current_option_allow_deconz_groups = self.option_allow_deconz_groups + + groups = list(self.api.groups.values()) + + if self.option_allow_deconz_groups: + self.async_add_device_callback(NEW_GROUP, groups) + else: + deconz_ids += [group.deconz_id for group in groups] + + if deconz_ids: + async_dispatcher_send(self.hass, self.signal_remove_entity, deconz_ids) + + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + + for entity_id, deconz_id in self.deconz_ids.items(): + if deconz_id in deconz_ids and entity_registry.async_is_registered( + entity_id + ): + entity_registry.async_remove(entity_id) @property def signal_reachable(self) -> str: @@ -144,23 +184,21 @@ def async_connection_status_callback(self, available) -> None: self.available = available async_dispatcher_send(self.hass, self.signal_reachable, True) - @property - def signal_options_update(self) -> str: - """Event specific per deCONZ entry to signal new options.""" - return f"deconz-options-{self.bridgeid}" - - @staticmethod - async def async_options_updated(hass, entry) -> None: - """Triggered by config entry options updates.""" - gateway = get_gateway_from_config_entry(hass, entry) - - registry = await async_get_registry(hass) - async_dispatcher_send(hass, gateway.signal_options_update, registry) - @callback def async_signal_new_device(self, device_type) -> str: """Gateway specific event to signal new device.""" - return NEW_DEVICE[device_type].format(self.bridgeid) + new_device = { + NEW_GROUP: f"deconz_new_group_{self.bridgeid}", + NEW_LIGHT: f"deconz_new_light_{self.bridgeid}", + NEW_SCENE: f"deconz_new_scene_{self.bridgeid}", + NEW_SENSOR: f"deconz_new_sensor_{self.bridgeid}", + } + return new_device[device_type] + + @property + def signal_remove_entity(self) -> str: + """Gateway specific event to signal removal of entity.""" + return f"deconz-remove-{self.bridgeid}" @callback def async_add_device_callback(self, device_type, device) -> None: @@ -221,44 +259,9 @@ async def get_gateway( return deconz except errors.Unauthorized: - _LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) + LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) raise AuthenticationRequired except (asyncio.TimeoutError, errors.RequestError): - _LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) + LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) raise CannotConnect - - -class DeconzEntityHandler: - """Platform entity handler to help with updating disabled by.""" - - def __init__(self, gateway) -> None: - """Create an entity handler.""" - self.gateway = gateway - self._entities = [] - - gateway.listeners.append( - async_dispatcher_connect( - gateway.hass, gateway.signal_options_update, self.update_entity_registry - ) - ) - - @callback - def add_entity(self, entity) -> None: - """Add a new entity to handler.""" - self._entities.append(entity) - - @callback - def update_entity_registry(self, entity_registry) -> None: - """Update entity registry disabled by status.""" - for entity in self._entities: - - if entity.entity_registry_enabled_default != entity.enabled: - disabled_by = None - - if entity.enabled: - disabled_by = DISABLED_CONFIG_ENTRY - - entity_registry.async_update_entity( - entity.registry_entry.entity_id, disabled_by=disabled_by - ) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 15d3b8287415b2..f62f9315c49aaa 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -30,7 +30,7 @@ SWITCH_TYPES, ) from .deconz_device import DeconzDevice -from .gateway import DeconzEntityHandler, get_gateway_from_config_entry +from .gateway import get_gateway_from_config_entry async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -41,8 +41,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" gateway = get_gateway_from_config_entry(hass, config_entry) - entity_handler = DeconzEntityHandler(gateway) - @callback def async_add_light(lights): """Add light from deCONZ.""" @@ -63,13 +61,14 @@ def async_add_light(lights): @callback def async_add_group(groups): """Add group from deCONZ.""" + if not gateway.option_allow_deconz_groups: + return + entities = [] for group in groups: - if group.lights: - new_group = DeconzGroup(group, gateway) - entity_handler.add_entity(new_group) - entities.append(new_group) + if group.lights and group.deconz_id not in gateway.deconz_ids.values(): + entities.append(DeconzGroup(group, gateway)) async_add_entities(entities, True) @@ -90,9 +89,12 @@ def __init__(self, device, gateway): """Set up light.""" super().__init__(device, gateway) - self._features = SUPPORT_BRIGHTNESS - self._features |= SUPPORT_FLASH - self._features |= SUPPORT_TRANSITION + self._features = 0 + + if self._device.brightness is not None: + self._features |= SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION if self._device.ct is not None: self._features |= SUPPORT_COLOR_TEMP @@ -153,7 +155,7 @@ async def async_turn_on(self, **kwargs): if ATTR_TRANSITION in kwargs: data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) - elif "IKEA" in (self._device.manufacturer or ""): + elif "IKEA" in self._device.manufacturer: data["transitiontime"] = 0 if ATTR_FLASH in kwargs: diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index f448e9105c8aff..425a44bf04241c 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==68" + "pydeconz==70" ], "ssdp": [ { diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 8261f03e90251c..6b88c414243dce 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -19,7 +19,7 @@ from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice from .deconz_event import DeconzEvent -from .gateway import DeconzEntityHandler, get_gateway_from_config_entry +from .gateway import get_gateway_from_config_entry ATTR_CURRENT = "current" ATTR_POWER = "power" @@ -37,7 +37,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): batteries = set() battery_handler = DeconzBatteryHandler(gateway) - entity_handler = DeconzEntityHandler(gateway) @callback def async_add_sensor(sensors, new=True): @@ -65,11 +64,13 @@ def async_add_sensor(sensors, new=True): new and sensor.BINARY is False and sensor.type not in Battery.ZHATYPE + Thermostat.ZHATYPE + and ( + gateway.option_allow_clip_sensor + or not sensor.type.startswith("CLIP") + ) + and sensor.deconz_id not in gateway.deconz_ids.values() ): - - new_sensor = DeconzSensor(sensor, gateway) - entity_handler.add_entity(new_sensor) - entities.append(new_sensor) + entities.append(DeconzSensor(sensor, gateway)) if sensor.battery is not None: new_battery = DeconzBattery(sensor, gateway) @@ -143,8 +144,13 @@ def device_state_attributes(self): elif self._device.type in Daylight.ZHATYPE: attr[ATTR_DAYLIGHT] = self._device.daylight - elif self._device.type in LightLevel.ZHATYPE and self._device.dark is not None: - attr[ATTR_DARK] = self._device.dark + elif self._device.type in LightLevel.ZHATYPE: + + if self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + + if self._device.daylight is not None: + attr[ATTR_DAYLIGHT] = self._device.daylight elif self._device.type in Power.ZHATYPE: attr[ATTR_CURRENT] = self._device.current @@ -211,7 +217,7 @@ def __init__(self, sensor, gateway): """Set up tracker.""" self.sensor = sensor self.gateway = gateway - sensor.register_async_callback(self.async_update_callback) + sensor.register_callback(self.async_update_callback) @callback def close(self): diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index f893b9880fdcf6..c85fa8073a3351 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,13 +1,14 @@ """deCONZ services.""" +from pydeconz.utils import normalize_bridge_id import voluptuous as vol from homeassistant.helpers import config_validation as cv from .config_flow import get_master_gateway from .const import ( - _LOGGER, CONF_BRIDGE_ID, DOMAIN, + LOGGER, NEW_GROUP, NEW_LIGHT, NEW_SCENE, @@ -97,20 +98,19 @@ async def async_configure_service(hass, data): See Dresden Elektroniks REST API documentation for details: http://dresden-elektronik.github.io/deconz-rest-doc/rest/ """ - bridgeid = data.get(CONF_BRIDGE_ID) + gateway = get_master_gateway(hass) + if CONF_BRIDGE_ID in data: + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + field = data.get(SERVICE_FIELD, "") entity_id = data.get(SERVICE_ENTITY) data = data[SERVICE_DATA] - gateway = get_master_gateway(hass) - if bridgeid: - gateway = hass.data[DOMAIN][bridgeid] - if entity_id: try: field = gateway.deconz_ids[entity_id] + field except KeyError: - _LOGGER.error("Could not find the entity %s", entity_id) + LOGGER.error("Could not find the entity %s", entity_id) return await gateway.api.request("put", field, json=data) @@ -120,7 +120,7 @@ async def async_refresh_devices_service(hass, data): """Refresh available devices from deCONZ.""" gateway = get_master_gateway(hass) if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][data[CONF_BRIDGE_ID]] + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] groups = set(gateway.api.groups.keys()) lights = set(gateway.api.lights.keys()) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index b61ea6236dafde..52cd90e54a13a6 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -14,20 +14,9 @@ "title": "Link with deCONZ", "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" }, - "options": { - "title": "Extra configuration options for deCONZ", - "data": { - "allow_clip_sensor": "Allow importing virtual sensors", - "allow_deconz_groups": "Allow importing deCONZ groups" - } - }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Hass.io add-on", - "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the Hass.io add-on {addon}?", - "data": { - "allow_clip_sensor": "Allow importing virtual sensors", - "allow_deconz_groups": "Allow importing deCONZ groups" - } + "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the Hass.io add-on {addon}?" } }, "error": { @@ -45,11 +34,12 @@ "options": { "step": { "deconz_devices": { - "description": "Configure visibility of deCONZ device types", "data": { "allow_clip_sensor": "Allow deCONZ CLIP sensors", "allow_deconz_groups": "Allow deCONZ light groups" - } + }, + "description": "Configure visibility of deCONZ device types", + "title": "deCONZ options" } } }, @@ -105,4 +95,4 @@ "side_6": "Side 6" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index c0a27b667c5792..be9cb8dcc97541 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -18,7 +18,13 @@ "sun", "system_health", "updater", - "zeroconf" + "zeroconf", + "zone", + "input_boolean", + "input_datetime", + "input_text", + "input_number", + "input_select" ], "codeowners": [] } diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 7df87490c60930..55309ea8b3129e 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -12,6 +12,7 @@ CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + DATA_RATE_KILOBYTES_PER_SECOND, STATE_IDLE, ) from homeassistant.exceptions import PlatformNotReady @@ -27,8 +28,8 @@ DHT_DOWNLOAD = 1000 SENSOR_TYPES = { "current_status": ["Status", None], - "download_speed": ["Down Speed", "kB/s"], - "upload_speed": ["Up Speed", "kB/s"], + "download_speed": ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], + "upload_speed": ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/demo/.translations/ca.json b/homeassistant/components/demo/.translations/ca.json index 944d358e739131..a29718fea7aa39 100644 --- a/homeassistant/components/demo/.translations/ca.json +++ b/homeassistant/components/demo/.translations/ca.json @@ -1,5 +1,22 @@ { "config": { "title": "Demostraci\u00f3" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Entrada booleana opcional", + "int": "Entrada num\u00e8rica" + } + }, + "options_2": { + "data": { + "multi": "Selecci\u00f3 m\u00faltiple", + "select": "Selecciona una opci\u00f3", + "string": "Valor de cadena (string)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/da.json b/homeassistant/components/demo/.translations/da.json index ef01fcb4f3c35e..fd2764e5ec9303 100644 --- a/homeassistant/components/demo/.translations/da.json +++ b/homeassistant/components/demo/.translations/da.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "en", + "other": "anden" + } + }, + "options_1": { + "data": { + "bool": "Valgfri boolsk", + "int": "Numerisk input" + } + }, + "options_2": { + "data": { + "multi": "Multimarkering", + "select": "V\u00e6lg en mulighed", + "string": "Strengv\u00e6rdi" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/de.json b/homeassistant/components/demo/.translations/de.json index ef01fcb4f3c35e..a600790d2fc6f0 100644 --- a/homeassistant/components/demo/.translations/de.json +++ b/homeassistant/components/demo/.translations/de.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Optionaler Boolescher Wert", + "int": "Numerische Eingabe" + } + }, + "options_2": { + "data": { + "multi": "Mehrfachauswahl", + "select": "W\u00e4hlen Sie eine Option", + "string": "String-Wert" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/en.json b/homeassistant/components/demo/.translations/en.json index ef01fcb4f3c35e..e49671c88c8255 100644 --- a/homeassistant/components/demo/.translations/en.json +++ b/homeassistant/components/demo/.translations/en.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Optional boolean", + "int": "Numeric input" + } + }, + "options_2": { + "data": { + "multi": "Multiselect", + "select": "Select an option", + "string": "String value" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/es-419.json b/homeassistant/components/demo/.translations/es-419.json new file mode 100644 index 00000000000000..ef01fcb4f3c35e --- /dev/null +++ b/homeassistant/components/demo/.translations/es-419.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demo" + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/es.json b/homeassistant/components/demo/.translations/es.json index ef01fcb4f3c35e..73ed9809d65e6c 100644 --- a/homeassistant/components/demo/.translations/es.json +++ b/homeassistant/components/demo/.translations/es.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Booleano opcional", + "int": "Entrada num\u00e9rica" + } + }, + "options_2": { + "data": { + "multi": "Multiselecci\u00f3n", + "select": "Selecciona una opci\u00f3n", + "string": "Valor de cadena" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/it.json b/homeassistant/components/demo/.translations/it.json index ef01fcb4f3c35e..7b299913c8e2e8 100644 --- a/homeassistant/components/demo/.translations/it.json +++ b/homeassistant/components/demo/.translations/it.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "uno", + "other": "altro" + } + }, + "options_1": { + "data": { + "bool": "Valore booleano facoltativo", + "int": "Input numerico" + } + }, + "options_2": { + "data": { + "multi": "Selezione multipla", + "select": "Selezionare un'opzione", + "string": "Valore stringa" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ko.json b/homeassistant/components/demo/.translations/ko.json index d20943c7b36354..efe69b575fb057 100644 --- a/homeassistant/components/demo/.translations/ko.json +++ b/homeassistant/components/demo/.translations/ko.json @@ -1,5 +1,22 @@ { "config": { "title": "\ub370\ubaa8" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "\ub17c\ub9ac \uc120\ud0dd", + "int": "\uc22b\uc790 \uc785\ub825" + } + }, + "options_2": { + "data": { + "multi": "\ub2e4\uc911 \uc120\ud0dd", + "select": "\uc635\uc158 \uc120\ud0dd", + "string": "\ubb38\uc790\uc5f4 \uac12" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/lb.json b/homeassistant/components/demo/.translations/lb.json index ef01fcb4f3c35e..d968b43af8bd5c 100644 --- a/homeassistant/components/demo/.translations/lb.json +++ b/homeassistant/components/demo/.translations/lb.json @@ -1,5 +1,14 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_2": { + "data": { + "select": "Eng Optioun auswielen" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/nl.json b/homeassistant/components/demo/.translations/nl.json index ef01fcb4f3c35e..cb932a0d9d6273 100644 --- a/homeassistant/components/demo/.translations/nl.json +++ b/homeassistant/components/demo/.translations/nl.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "Empty", + "other": "" + } + }, + "options_1": { + "data": { + "bool": "Optioneel Boolean", + "int": "Numerieke invoer" + } + }, + "options_2": { + "data": { + "multi": "Meerkeuze selectie", + "select": "Kies een optie", + "string": "String waarde" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/no.json b/homeassistant/components/demo/.translations/no.json index ef01fcb4f3c35e..a46606621b95c1 100644 --- a/homeassistant/components/demo/.translations/no.json +++ b/homeassistant/components/demo/.translations/no.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Valgfri boolean", + "int": "Numerisk inndata" + } + }, + "options_2": { + "data": { + "multi": "Flervalg", + "select": "Velg et alternativ", + "string": "Strengverdi" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/pl.json b/homeassistant/components/demo/.translations/pl.json index ef01fcb4f3c35e..f224d100929dec 100644 --- a/homeassistant/components/demo/.translations/pl.json +++ b/homeassistant/components/demo/.translations/pl.json @@ -1,5 +1,30 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "few": "kilka", + "many": "wiele", + "one": "jedena", + "other": "inne" + } + }, + "options_1": { + "data": { + "bool": "Warto\u015b\u0107 logiczna", + "int": "Warto\u015b\u0107 numeryczna" + } + }, + "options_2": { + "data": { + "multi": "Wielokrotny wyb\u00f3r", + "select": "Wybierz opcj\u0119", + "string": "Warto\u015b\u0107 tekstowa" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ru.json b/homeassistant/components/demo/.translations/ru.json index 0438252a42991b..22ea3d2e1962ff 100644 --- a/homeassistant/components/demo/.translations/ru.json +++ b/homeassistant/components/demo/.translations/ru.json @@ -1,5 +1,22 @@ { "config": { "title": "\u0414\u0435\u043c\u043e" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u041b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0438\u0439", + "int": "\u0427\u0438\u0441\u043b\u043e\u0432\u043e\u0439" + } + }, + "options_2": { + "data": { + "multi": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e", + "select": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u043e\u043f\u0446\u0438\u044e", + "string": "\u0421\u0442\u0440\u043e\u043a\u043e\u0432\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/sl.json b/homeassistant/components/demo/.translations/sl.json index ef01fcb4f3c35e..b67d4d56fb1da7 100644 --- a/homeassistant/components/demo/.translations/sl.json +++ b/homeassistant/components/demo/.translations/sl.json @@ -1,5 +1,30 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "few": "prazni", + "one": "prazen", + "other": "prazni", + "two": "prazna" + } + }, + "options_1": { + "data": { + "bool": "Izbirna logi\u010dna vrednost", + "int": "\u0160tevil\u010dni vnos" + } + }, + "options_2": { + "data": { + "multi": "Multiselect", + "select": "Izberite mo\u017enost", + "string": "Vrednost niza" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/sv.json b/homeassistant/components/demo/.translations/sv.json new file mode 100644 index 00000000000000..4c5f477cc1c521 --- /dev/null +++ b/homeassistant/components/demo/.translations/sv.json @@ -0,0 +1,28 @@ +{ + "config": { + "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "Tom", + "other": "Tomma" + } + }, + "options_1": { + "data": { + "bool": "Valfritt boolesk", + "int": "Numerisk inmatning" + } + }, + "options_2": { + "data": { + "multi": "Flera val", + "select": "V\u00e4lj ett alternativ", + "string": "Str\u00e4ngv\u00e4rde" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/zh-Hans.json b/homeassistant/components/demo/.translations/zh-Hans.json new file mode 100644 index 00000000000000..9155b5066c533f --- /dev/null +++ b/homeassistant/components/demo/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u5e03\u5c14\u9009\u9879", + "int": "\u6570\u503c\u8f93\u5165" + } + }, + "options_2": { + "data": { + "multi": "\u591a\u91cd\u9009\u62e9", + "select": "\u9009\u62e9\u9009\u9879", + "string": "\u5b57\u7b26\u4e32\u503c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/zh-Hant.json b/homeassistant/components/demo/.translations/zh-Hant.json index cfb0fced0c2db3..7f6ac42d6099e4 100644 --- a/homeassistant/components/demo/.translations/zh-Hant.json +++ b/homeassistant/components/demo/.translations/zh-Hant.json @@ -1,5 +1,22 @@ { "config": { "title": "\u5c55\u793a" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u9078\u9805\u5e03\u6797", + "int": "\u6578\u503c\u8f38\u5165" + } + }, + "options_2": { + "data": { + "multi": "\u591a\u91cd\u9078\u64c7", + "select": "\u9078\u64c7\u9078\u9805", + "string": "\u5b57\u4e32\u503c" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b6845d9d6a424a..f1e6d3df74f465 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -124,19 +124,6 @@ async def async_setup(hass, config): ) ) - # Set up weblink - tasks.append( - bootstrap.async_setup_component( - hass, - "weblink", - { - "weblink": { - "entities": [{"name": "Router", "url": "http://192.168.1.1"}] - } - }, - ) - ) - results = await asyncio.gather(*tasks) if any(not result for result in results): @@ -209,22 +196,6 @@ async def finish_setup(hass, config): switches = sorted(hass.states.async_entity_ids("switch")) lights = sorted(hass.states.async_entity_ids("light")) - # Set up history graph - await bootstrap.async_setup_component( - hass, - "history_graph", - { - "history_graph": { - "switches": { - "name": "Recent Switches", - "entities": switches, - "hours_to_show": 1, - "refresh": 60, - } - } - }, - ) - # Set up scripts await bootstrap.async_setup_component( hass, @@ -232,7 +203,7 @@ async def finish_setup(hass, config): { "script": { "demo": { - "alias": "Toggle {}".format(lights[0].split(".")[1]), + "alias": f"Toggle {lights[0].split('.')[1]}", "sequence": [ { "service": "light.turn_off", diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py index 9fe0f675d9d033..656e22259e1c93 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -27,7 +27,7 @@ def __init__(self, name, pm_2_5, pm_10, n2o): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format("Demo Air Quality", self._name) + return f"Demo Air Quality {self._name}" @property def should_poll(self): diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index e6b275920c8c16..1f3975d024195e 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -1,16 +1,97 @@ """Config flow to configure demo component.""" +import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv # pylint: disable=unused-import from . import DOMAIN +CONF_STRING = "string" +CONF_BOOLEAN = "bool" +CONF_INT = "int" +CONF_SELECT = "select" +CONF_MULTISELECT = "multi" + class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Demo configuration flow.""" VERSION = 1 + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + async def async_step_import(self, import_info): """Set the config entry up from yaml.""" return self.async_create_entry(title="Demo", data={}) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_options_1() + + async def async_step_options_1(self, user_input=None): + """Manage the options.""" + if user_input is not None: + self.options.update(user_input) + return await self.async_step_options_2() + + return self.async_show_form( + step_id="options_1", + data_schema=vol.Schema( + { + vol.Optional( + CONF_BOOLEAN, + default=self.config_entry.options.get(CONF_BOOLEAN, False), + ): bool, + vol.Optional( + CONF_INT, default=self.config_entry.options.get(CONF_INT, 10), + ): int, + } + ), + ) + + async def async_step_options_2(self, user_input=None): + """Manage the options 2.""" + if user_input is not None: + self.options.update(user_input) + return await self._update_options() + + return self.async_show_form( + step_id="options_2", + data_schema=vol.Schema( + { + vol.Optional( + CONF_STRING, + default=self.config_entry.options.get(CONF_STRING, "Default",), + ): str, + vol.Optional( + CONF_SELECT, + default=self.config_entry.options.get(CONF_SELECT, "default"), + ): vol.In(["default", "other"]), + vol.Optional( + CONF_MULTISELECT, + default=self.config_entry.options.get( + CONF_MULTISELECT, ["default"] + ), + ): cv.multi_select({"default": "Default", "other": "Other"}), + } + ), + ) + + async def _update_options(self): + """Update config entry options.""" + return self.async_create_entry(title="", data=self.options) diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 20e3a52aa8d255..ab95cc978b3f0e 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -6,7 +6,8 @@ SUPPORT_OPEN, CoverDevice, ) -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_utc_time_change from . import DOMAIN @@ -131,21 +132,21 @@ def supported_features(self): return self._supported_features return super().supported_features - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" if self._position == 0: return if self._position is None: self._closed = True - self.schedule_update_ha_state() + self.async_write_ha_state() return self._is_closing = True self._listen_cover() self._requested_closing = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Close the cover tilt.""" if self._tilt_position in (0, None): return @@ -153,21 +154,21 @@ def close_cover_tilt(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = True - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" if self._position == 100: return if self._position is None: self._closed = False - self.schedule_update_ha_state() + self.async_write_ha_state() return self._is_opening = True self._listen_cover() self._requested_closing = False - self.schedule_update_ha_state() + self.async_write_ha_state() - def open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Open the cover tilt.""" if self._tilt_position in (100, None): return @@ -175,7 +176,7 @@ def open_cover_tilt(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = False - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) self._set_position = round(position, -1) @@ -185,7 +186,7 @@ def set_cover_position(self, **kwargs): self._listen_cover() self._requested_closing = position < self._position - def set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover til to a specific position.""" tilt_position = kwargs.get(ATTR_TILT_POSITION) self._set_tilt_position = round(tilt_position, -1) @@ -195,7 +196,7 @@ def set_cover_tilt_position(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = tilt_position < self._tilt_position - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" self._is_closing = False self._is_opening = False @@ -206,7 +207,7 @@ def stop_cover(self, **kwargs): self._unsub_listener_cover = None self._set_position = None - def stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs): """Stop the cover tilt.""" if self._tilt_position is None: return @@ -216,14 +217,15 @@ def stop_cover_tilt(self, **kwargs): self._unsub_listener_cover_tilt = None self._set_tilt_position = None + @callback def _listen_cover(self): """Listen for changes in cover.""" if self._unsub_listener_cover is None: - self._unsub_listener_cover = track_utc_time_change( + self._unsub_listener_cover = async_track_utc_time_change( self.hass, self._time_changed_cover ) - def _time_changed_cover(self, now): + async def _time_changed_cover(self, now): """Track time changes.""" if self._requested_closing: self._position -= 10 @@ -231,20 +233,20 @@ def _time_changed_cover(self, now): self._position += 10 if self._position in (100, 0, self._set_position): - self.stop_cover() + await self.async_stop_cover() self._closed = self.current_cover_position <= 0 + self.async_write_ha_state() - self.schedule_update_ha_state() - + @callback def _listen_cover_tilt(self): """Listen for changes in cover tilt.""" if self._unsub_listener_cover_tilt is None: - self._unsub_listener_cover_tilt = track_utc_time_change( + self._unsub_listener_cover_tilt = async_track_utc_time_change( self.hass, self._time_changed_cover_tilt ) - def _time_changed_cover_tilt(self, now): + async def _time_changed_cover_tilt(self, now): """Track time changes.""" if self._requested_closing_tilt: self._tilt_position -= 10 @@ -252,6 +254,6 @@ def _time_changed_cover_tilt(self, now): self._tilt_position += 10 if self._tilt_position in (100, 0, self._set_tilt_position): - self.stop_cover_tilt() + await self.async_stop_cover_tilt() - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index 77030623c9d436..860524dfd7cc0f 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -26,7 +26,7 @@ def __init__(self, hass, name): txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " for idx in range(0, 10): msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) - msgtxt = "Message {}. {}".format(idx + 1, txt * (1 + idx * (idx % 2))) + msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}" msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() msg = { "info": { @@ -71,7 +71,7 @@ async def async_get_messages(self): reverse=True, ) - def async_delete(self, msgid): + async def async_delete(self, msgid): """Delete the specified messages.""" if msgid in self._messages: _LOGGER.info("Deleting: %s", msgid) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 33fe4ee3647091..7a8f4eb8fbe7e1 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -48,7 +48,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): await async_setup_platform(hass, {}, async_add_entities) -YOUTUBE_COVER_URL_FORMAT = "https://img.youtube.com/vi/{}/hqdefault.jpg" SOUND_MODE_LIST = ["Dummy Music", "Dummy Movie"] DEFAULT_SOUND_MODE = "Dummy Music" @@ -238,7 +237,7 @@ def media_duration(self): @property def media_image_url(self): """Return the image url of current playing media.""" - return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id) + return f"https://img.youtube.com/vi/{self.youtube_id}/hqdefault.jpg" @property def media_title(self): diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index a2c0103280bee1..33f3b4229dc4c9 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -1,5 +1,25 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": {} + }, + "options_1": { + "data": { + "bool": "Optional boolean", + "int": "Numeric input" + } + }, + "options_2": { + "data": { + "string": "String value", + "select": "Select an option", + "multi": "Multiselect" + } + } + } } } diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 8cc1b2f95fdfc4..b17c88fa828b51 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -106,7 +106,7 @@ def __init__( @property def name(self): """Return the name of the sensor.""" - return "{} {}".format("Demo Weather", self._name) + return f"Demo Weather {self._name}" @property def should_poll(self): diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 1ecbe5b939f432..1387875c02d97c 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -2,7 +2,7 @@ "domain": "denonavr", "name": "Denon AVR Network Receivers", "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.7.11"], + "requirements": ["denonavr==0.7.12"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 350d065f9d9260..b14592d1b78dd0 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -30,6 +30,7 @@ CONF_TIMEOUT, CONF_ZONE, ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, STATE_OFF, STATE_ON, STATE_PAUSED, @@ -201,6 +202,10 @@ async def async_added_to_hass(self): def signal_handler(self, data): """Handle domain-specific signal by calling appropriate method.""" entity_ids = data[ATTR_ENTITY_ID] + + if entity_ids == ENTITY_MATCH_NONE: + return + if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: params = { key: value diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 177d1258f3c514..202c5885887149 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -11,6 +11,10 @@ CONF_SOURCE, STATE_UNAVAILABLE, STATE_UNKNOWN, + TIME_DAYS, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -27,16 +31,32 @@ CONF_UNIT_PREFIX = "unit_prefix" CONF_UNIT_TIME = "unit_time" CONF_UNIT = "unit" +CONF_TIME_WINDOW = "time_window" # SI Metric prefixes -UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "G": 10 ** 6, "T": 10 ** 9} +UNIT_PREFIXES = { + None: 1, + "n": 1e-9, + "µ": 1e-6, + "m": 1e-3, + "k": 1e3, + "M": 1e6, + "G": 1e9, + "T": 1e12, +} # SI Time prefixes -UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60} +UNIT_TIME = { + TIME_SECONDS: 1, + TIME_MINUTES: 60, + TIME_HOURS: 60 * 60, + TIME_DAYS: 24 * 60 * 60, +} ICON = "mdi:chart-line" DEFAULT_ROUND = 3 +DEFAULT_TIME_WINDOW = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,8 +64,9 @@ vol.Required(CONF_SOURCE): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), - vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT): cv.string, + vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period, } ) @@ -53,12 +74,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the derivative sensor.""" derivative = DerivativeSensor( - config[CONF_SOURCE], - config.get(CONF_NAME), - config[CONF_ROUND_DIGITS], - config[CONF_UNIT_PREFIX], - config[CONF_UNIT_TIME], - config.get(CONF_UNIT), + source_entity=config[CONF_SOURCE], + name=config.get(CONF_NAME), + round_digits=config[CONF_ROUND_DIGITS], + unit_prefix=config[CONF_UNIT_PREFIX], + unit_time=config[CONF_UNIT_TIME], + unit_of_measurement=config.get(CONF_UNIT), + time_window=config[CONF_TIME_WINDOW], ) async_add_entities([derivative]) @@ -75,11 +97,13 @@ def __init__( unit_prefix, unit_time, unit_of_measurement, + time_window, ): """Initialize the derivative sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits self._state = 0 + self._state_list = [] # List of tuples with (timestamp, sensor_value) self._name = name if name is not None else f"{source_entity} derivative" @@ -93,6 +117,7 @@ def __init__( self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] + self._time_window = time_window.total_seconds() async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -114,6 +139,19 @@ def calc_derivative(entity, old_state, new_state): ): return + now = new_state.last_updated + # Filter out the tuples that are older than (and outside of the) `time_window` + self._state_list = [ + (timestamp, state) + for timestamp, state in self._state_list + if (now - timestamp).total_seconds() < self._time_window + ] + # It can happen that the list is now empty, in that case + # we use the old_state, because we cannot do anything better. + if len(self._state_list) == 0: + self._state_list.append((old_state.last_updated, old_state.state)) + self._state_list.append((new_state.last_updated, new_state.state)) + if self._unit_of_measurement is None: unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._unit_of_measurement = self._unit_template.format( @@ -122,13 +160,16 @@ def calc_derivative(entity, old_state, new_state): try: # derivative of previous measures. - gradient = 0 - elapsed_time = ( - new_state.last_updated - old_state.last_updated - ).total_seconds() - gradient = Decimal(new_state.state) - Decimal(old_state.state) - derivative = gradient / ( - Decimal(elapsed_time) * (self._unit_prefix * self._unit_time) + last_time, last_value = self._state_list[-1] + first_time, first_value = self._state_list[0] + + elapsed_time = (last_time - first_time).total_seconds() + delta_value = Decimal(last_value) - Decimal(first_value) + derivative = ( + delta_value + / Decimal(elapsed_time) + / Decimal(self._unit_prefix) + * Decimal(self._unit_time) ) assert isinstance(derivative, Decimal) except ValueError as err: diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 204518b2ce32b5..fd7496b1316965 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -82,7 +82,7 @@ def update(self): self.data.update() self._state = self.data.connections[0].get("departure", "Unknown") if self.data.connections[0].get("delay", 0) != 0: - self._state += " + {}".format(self.data.connections[0]["delay"]) + self._state += f" + {self.data.connections[0]['delay']}" class SchieneData: diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 56e087f0e5f814..95b3fc9fdb3697 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -1,5 +1,6 @@ """Helpers for device automations.""" import asyncio +from functools import wraps import logging from types import ModuleType from typing import Any, List, MutableMapping @@ -14,7 +15,7 @@ from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.loader import IntegrationNotFound, async_get_integration -from .exceptions import InvalidDeviceAutomationConfig +from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig # mypy: allow-untyped-calls, allow-untyped-defs @@ -117,6 +118,10 @@ async def _async_get_device_automations(hass, automation_type, device_id): domains = set() automations: List[MutableMapping[str, Any]] = [] device = device_registry.async_get(device_id) + + if device is None: + raise DeviceNotFound + for entry_id in device.config_entries: config_entry = hass.config_entries.async_get_entry(entry_id) domains.add(config_entry.domain) @@ -173,6 +178,21 @@ async def _async_get_device_automation_capabilities(hass, automation_type, autom return capabilities +def handle_device_errors(func): + """Handle device automation errors.""" + + @wraps(func) + async def with_error_handling(hass, connection, msg): + try: + await func(hass, connection, msg) + except DeviceNotFound: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Device not found" + ) + + return with_error_handling + + @websocket_api.websocket_command( { vol.Required("type"): "device_automation/action/list", @@ -180,6 +200,7 @@ async def _async_get_device_automation_capabilities(hass, automation_type, autom } ) @websocket_api.async_response +@handle_device_errors async def websocket_device_automation_list_actions(hass, connection, msg): """Handle request for device actions.""" device_id = msg["device_id"] @@ -194,6 +215,7 @@ async def websocket_device_automation_list_actions(hass, connection, msg): } ) @websocket_api.async_response +@handle_device_errors async def websocket_device_automation_list_conditions(hass, connection, msg): """Handle request for device conditions.""" device_id = msg["device_id"] @@ -208,6 +230,7 @@ async def websocket_device_automation_list_conditions(hass, connection, msg): } ) @websocket_api.async_response +@handle_device_errors async def websocket_device_automation_list_triggers(hass, connection, msg): """Handle request for device triggers.""" device_id = msg["device_id"] @@ -222,6 +245,7 @@ async def websocket_device_automation_list_triggers(hass, connection, msg): } ) @websocket_api.async_response +@handle_device_errors async def websocket_device_automation_get_action_capabilities(hass, connection, msg): """Handle request for device action capabilities.""" action = msg["action"] @@ -238,6 +262,7 @@ async def websocket_device_automation_get_action_capabilities(hass, connection, } ) @websocket_api.async_response +@handle_device_errors async def websocket_device_automation_get_condition_capabilities(hass, connection, msg): """Handle request for device condition capabilities.""" condition = msg["condition"] @@ -254,6 +279,7 @@ async def websocket_device_automation_get_condition_capabilities(hass, connectio } ) @websocket_api.async_response +@handle_device_errors async def websocket_device_automation_get_trigger_capabilities(hass, connection, msg): """Handle request for device trigger capabilities.""" trigger = msg["trigger"] diff --git a/homeassistant/components/device_automation/exceptions.py b/homeassistant/components/device_automation/exceptions.py index 2f7c0df01876f7..ad92696cb945df 100644 --- a/homeassistant/components/device_automation/exceptions.py +++ b/homeassistant/components/device_automation/exceptions.py @@ -4,3 +4,7 @@ class InvalidDeviceAutomationConfig(HomeAssistantError): """When device automation config is invalid.""" + + +class DeviceNotFound(HomeAssistantError): + """When referenced device not found.""" diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py index 7d84eb921e907c..a2dcd62db8cc35 100644 --- a/homeassistant/components/device_automation/toggle_entity.py +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -24,7 +24,7 @@ CONF_PLATFORM, CONF_TYPE, ) -from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -74,10 +74,12 @@ }, ] +DEVICE_ACTION_TYPES = [CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON] + ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_TYPE): vol.In([CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON]), + vol.Required(CONF_TYPE): vol.In(DEVICE_ACTION_TYPES), } ) @@ -121,6 +123,7 @@ async def async_call_action_from_config( ) +@callback def async_condition_from_config(config: ConfigType) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" condition_type = config[CONF_TYPE] diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index af6abf544c66f5..d7986fbb5b4b8d 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -113,29 +113,27 @@ def calc_time_for_light_when_sunset(): return None return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) - def async_turn_on_before_sunset(light_id): + async def async_turn_on_before_sunset(light_id): """Turn on lights.""" if not anyone_home() or light.is_on(light_id): return - hass.async_create_task( - hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: light_id, - ATTR_TRANSITION: LIGHT_TRANSITION_TIME.seconds, - ATTR_PROFILE: light_profile, - }, - ) + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: light_id, + ATTR_TRANSITION: LIGHT_TRANSITION_TIME.seconds, + ATTR_PROFILE: light_profile, + }, ) + @callback def async_turn_on_factory(light_id): """Generate turn on callbacks as factory.""" - @callback - def async_turn_on_light(now): + async def async_turn_on_light(now): """Turn on specific light.""" - async_turn_on_before_sunset(light_id) + await async_turn_on_before_sunset(light_id) return async_turn_on_light diff --git a/homeassistant/components/device_tracker/.translations/es-419.json b/homeassistant/components/device_tracker/.translations/es-419.json new file mode 100644 index 00000000000000..cfbf7bcfe3ebac --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/es-419.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est\u00e1 en casa", + "is_not_home": "{entity_name} no est\u00e1 en casa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/hu.json b/homeassistant/components/device_tracker/.translations/hu.json new file mode 100644 index 00000000000000..7302f40df9e66f --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/hu.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} otthon van", + "is_not_home": "{entity_name} nincs otthon" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/sv.json b/homeassistant/components/device_tracker/.translations/sv.json new file mode 100644 index 00000000000000..70287ad318afe6 --- /dev/null +++ b/homeassistant/components/device_tracker/.translations/sv.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u00e4r hemma", + "is_not_home": "{entity_name} \u00e4r inte hemma" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/.translations/zh-Hant.json b/homeassistant/components/device_tracker/.translations/zh-Hant.json index 456e09ebf0e8ad..6611cb0c279da0 100644 --- a/homeassistant/components/device_tracker/.translations/zh-Hant.json +++ b/homeassistant/components/device_tracker/.translations/zh-Hant.json @@ -1,8 +1,8 @@ { "device_automation": { "condition_type": { - "is_home": "{entity_name} \u5728\u5bb6", - "is_not_home": "{entity_name} \u4e0d\u5728\u5bb6" + "is_home": "{entity_name}\u5728\u5bb6", + "is_not_home": "{entity_name}\u4e0d\u5728\u5bb6" } } } \ No newline at end of file diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 6c5cacac591733..059c51989fec10 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -61,6 +61,11 @@ def state_attributes(self): class TrackerEntity(BaseTrackerEntity): """Represent a tracked device.""" + @property + def force_update(self): + """All updates need to be written to the state machine.""" + return True + @property def location_accuracy(self): """Return the location accuracy of the device. diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 1778a87b36a756..06313deccb6513 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -5,7 +5,6 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "device_tracker" -ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_TYPE_LEGACY = "legacy" PLATFORM_TYPE_ENTITY = "entity_platform" diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 9bdfc12db39910..9c102bfa745e9b 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -13,7 +13,7 @@ STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -65,6 +65,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: @@ -76,6 +77,7 @@ def async_condition_from_config( else: state = STATE_NOT_HOME + @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" return condition.state(hass, config[ATTR_ENTITY_ID], state) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 04ecad3b13d314..68908f8c79f32f 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -45,7 +45,6 @@ DEFAULT_CONSIDER_HOME, DEFAULT_TRACK_NEW, DOMAIN, - ENTITY_ID_FORMAT, LOGGER, SOURCE_TYPE_GPS, ) @@ -182,7 +181,7 @@ async def async_see( return # Guard from calling see on entity registry entities. - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" if registry.async_is_registered(entity_id): LOGGER.error( "The see service is not supported for this entity %s", entity_id @@ -197,7 +196,6 @@ async def async_see( self.track_new, dev_id, mac, - (host_name or dev_id).replace("_", " "), picture=picture, icon=icon, hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE), @@ -309,7 +307,7 @@ def __init__( ) -> None: """Initialize a device.""" self.hass = hass - self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + self.entity_id = f"{DOMAIN}.{dev_id}" # Timedelta object how long we consider a device home if it is not # detected anymore. @@ -342,7 +340,7 @@ def __init__( @property def name(self): """Return the name of the entity.""" - return self.config_name or self.host_name or DEVICE_DEFAULT_NAME + return self.config_name or self.host_name or self.dev_id or DEVICE_DEFAULT_NAME @property def state(self): @@ -393,7 +391,7 @@ async def async_seen( """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() - self.host_name = host_name + self.host_name = host_name or self.host_name self.location_name = location_name self.consider_home = consider_home or self.consider_home @@ -413,7 +411,6 @@ async def async_seen( self.gps_accuracy = 0 LOGGER.warning("Could not parse gps value for %s: %s", self.dev_id, gps) - # pylint: disable=not-an-iterable await self.async_update() def stale(self, now: dt_util.dt.datetime = None): @@ -491,34 +488,25 @@ def scan_devices(self) -> List[str]: """Scan for devices.""" raise NotImplementedError() - def async_scan_devices(self) -> Any: - """Scan for devices. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.scan_devices) + async def async_scan_devices(self) -> Any: + """Scan for devices.""" + return await self.hass.async_add_job(self.scan_devices) def get_device_name(self, device: str) -> str: """Get the name of a device.""" raise NotImplementedError() - def async_get_device_name(self, device: str) -> Any: - """Get the name of a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.get_device_name, device) + async def async_get_device_name(self, device: str) -> Any: + """Get the name of a device.""" + return await self.hass.async_add_job(self.get_device_name, device) def get_extra_attributes(self, device: str) -> dict: """Get the extra attributes of a device.""" raise NotImplementedError() - def async_get_extra_attributes(self, device: str) -> Any: - """Get the extra attributes of a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.get_extra_attributes, device) + async def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device.""" + return await self.hass.async_add_job(self.get_extra_attributes, device) async def async_load_config( @@ -528,24 +516,21 @@ async def async_load_config( This method is a coroutine. """ - dev_schema = vol.All( - cv.deprecated(CONF_AWAY_HIDE, invalidation_version="0.107.0"), - vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), - vol.Optional("track", default=False): cv.boolean, - vol.Optional(CONF_MAC, default=None): vol.Any( - None, vol.All(cv.string, vol.Upper) - ), - vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, - vol.Optional("gravatar", default=None): vol.Any(None, cv.string), - vol.Optional("picture", default=None): vol.Any(None, cv.string), - vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( - cv.time_period, cv.positive_timedelta - ), - } - ), + dev_schema = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), + vol.Optional("track", default=False): cv.boolean, + vol.Optional(CONF_MAC, default=None): vol.Any( + None, vol.All(cv.string, vol.Upper) + ), + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, + vol.Optional("gravatar", default=None): vol.Any(None, cv.string), + vol.Optional("picture", default=None): vol.Any(None, cv.string), + vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( + cv.time_period, cv.positive_timedelta + ), + } ) result = [] try: @@ -592,5 +577,7 @@ def get_gravatar_for_email(email: str): Async friendly. """ - url = "https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar" - return url.format(hashlib.md5(email.encode("utf-8").lower()).hexdigest()) + return ( + f"https://www.gravatar.com/avatar/" + f"{hashlib.md5(email.encode('utf-8').lower()).hexdigest()}.jpg?s=80&d=wavatar" + ) diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py index 42751b1a7845d9..595e36ef07c759 100644 --- a/homeassistant/components/device_tracker/setup.py +++ b/homeassistant/components/device_tracker/setup.py @@ -109,9 +109,7 @@ async def async_extract_config(hass, config): legacy.append(platform) else: raise ValueError( - "Unable to determine type for {}: {}".format( - platform.name, platform.type - ) + f"Unable to determine type for {platform.name}: {platform.type}" ) return legacy diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py new file mode 100644 index 00000000000000..8b3ae08c5263cb --- /dev/null +++ b/homeassistant/components/directv/const.py @@ -0,0 +1,12 @@ +"""Constants for the DirecTV integration.""" + +ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" +ATTR_MEDIA_RATING = "media_rating" +ATTR_MEDIA_RECORDED = "media_recorded" +ATTR_MEDIA_START_TIME = "media_start_time" + +DATA_DIRECTV = "data_directv" + +DEFAULT_DEVICE = "0" +DEFAULT_NAME = "DirecTV Receiver" +DEFAULT_PORT = 8080 diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index adf05621a2cec0..b0f0f8bb5ebb24 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -2,7 +2,7 @@ "domain": "directv", "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", - "requirements": ["directpy==0.5"], + "requirements": ["directpy==0.6"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index cd4f910c707277..673e97a18afbf4 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -31,16 +31,18 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) - -ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" -ATTR_MEDIA_RATING = "media_rating" -ATTR_MEDIA_RECORDED = "media_recorded" -ATTR_MEDIA_START_TIME = "media_start_time" +from .const import ( + ATTR_MEDIA_CURRENTLY_RECORDING, + ATTR_MEDIA_RATING, + ATTR_MEDIA_RECORDED, + ATTR_MEDIA_START_TIME, + DATA_DIRECTV, + DEFAULT_DEVICE, + DEFAULT_NAME, + DEFAULT_PORT, +) -DEFAULT_DEVICE = "0" -DEFAULT_NAME = "DirecTV Receiver" -DEFAULT_PORT = 8080 +_LOGGER = logging.getLogger(__name__) SUPPORT_DTV = ( SUPPORT_PAUSE @@ -62,8 +64,6 @@ | SUPPORT_PLAY ) -DATA_DIRECTV = "data_directv" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -77,32 +77,35 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DirecTV platform.""" known_devices = hass.data.get(DATA_DIRECTV, set()) - hosts = [] + entities = [] if CONF_HOST in config: + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] + device = config[CONF_DEVICE] + _LOGGER.debug( - "Adding configured device %s with client address %s ", - config.get(CONF_NAME), - config.get(CONF_DEVICE), - ) - hosts.append( - [ - config.get(CONF_NAME), - config.get(CONF_HOST), - config.get(CONF_PORT), - config.get(CONF_DEVICE), - ] + "Adding configured device %s with client address %s", name, device, ) + dtv = DIRECTV(host, port, device) + dtv_version = _get_receiver_version(dtv) + + entities.append(DirecTvDevice(name, device, dtv, dtv_version,)) + known_devices.add((host, device)) + elif discovery_info: host = discovery_info.get("host") - name = "DirecTV_{}".format(discovery_info.get("serial", "")) + name = f"DirecTV_{discovery_info.get('serial', '')}" # Attempt to discover additional RVU units _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) dtv = DIRECTV(host, DEFAULT_PORT) + try: + dtv_version = _get_receiver_version(dtv) resp = dtv.get_locations() except requests.exceptions.RequestException as ex: # Bail out and just go forward with uPnP data @@ -116,6 +119,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if "locationName" not in loc or "clientAddr" not in loc: continue + loc_name = str.title(loc["locationName"]) + # Make sure that this device is not already configured # Comparing based on host (IP) and clientAddr. if (host, loc["clientAddr"]) in known_devices: @@ -123,42 +128,47 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "Discovered device %s on host %s with " "client address %s is already " "configured", - str.title(loc["locationName"]), + loc_name, host, loc["clientAddr"], ) else: _LOGGER.debug( "Adding discovered device %s with client address %s", - str.title(loc["locationName"]), + loc_name, loc["clientAddr"], ) - hosts.append( - [ - str.title(loc["locationName"]), - host, - DEFAULT_PORT, + + entities.append( + DirecTvDevice( + loc_name, loc["clientAddr"], - ] + DIRECTV(host, DEFAULT_PORT, loc["clientAddr"]), + dtv_version, + ) ) + known_devices.add((host, loc["clientAddr"])) - dtvs = [] + add_entities(entities) - for host in hosts: - dtvs.append(DirecTvDevice(*host)) - hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3])) - add_entities(dtvs) +def _get_receiver_version(client): + """Return the version of the DirectTV receiver.""" + try: + return client.get_version() + except requests.exceptions.RequestException as ex: + _LOGGER.debug("Request exception %s trying to get receiver version", ex) + return None class DirecTvDevice(MediaPlayerDevice): """Representation of a DirecTV receiver on the network.""" - def __init__(self, name, host, port, device): + def __init__(self, name, device, dtv, version_info=None): """Initialize the device.""" - - self.dtv = DIRECTV(host, port, device) + self.dtv = dtv self._name = name + self._unique_id = None self._is_standby = True self._current = None self._last_update = None @@ -170,6 +180,11 @@ def __init__(self, name, host, port, device): self._available = False self._first_error_timestamp = None + if device != "0": + self._unique_id = device + elif version_info: + self._unique_id = "".join(version_info.get("receiverId").split()) + if self._is_client: _LOGGER.debug("Created DirecTV client %s for device %s", self._name, device) else: @@ -204,9 +219,7 @@ def update(self): else: # If an error is received then only set to unavailable if # this started at least 1 minute ago. - log_message = "{}: Invalid status {} received".format( - self.entity_id, self._current["status"]["code"] - ) + log_message = f"{self.entity_id}: Invalid status {self._current['status']['code']} received" if self._check_state_available(): _LOGGER.debug(log_message) else: @@ -257,6 +270,11 @@ def name(self): """Return the name of the device.""" return self._name + @property + def unique_id(self): + """Return a unique ID to use for this media player.""" + return self._unique_id + # MediaPlayerDevice properties and methods @property def state(self): @@ -350,7 +368,7 @@ def media_channel(self): if self._is_standby: return None - return "{} ({})".format(self._current["callsign"], self._current["major"]) + return f"{self._current['callsign']} ({self._current['major']})" @property def source(self): diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index a9aeea27aeffd3..e496ad0d532e63 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -2,7 +2,7 @@ "domain": "discord", "name": "Discord", "documentation": "https://www.home-assistant.io/integrations/discord", - "requirements": ["discord.py==1.2.5"], + "requirements": ["discord.py==1.3.1"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index 430878ca44f33f..9e56668eb3efcc 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -45,7 +45,7 @@ def __init__(self, camera_entity, name=None): if name: self._name = name else: - self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1]) + self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" @property def camera_entity(self): diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index d6fbf106b0c96b..32c2aa5868c009 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -59,7 +59,7 @@ def __init__(self, camera_entity, faces, name, tolerance): if name: self._name = name else: - self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1]) + self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" self._faces = {} for face_name, face_file in faces.items(): diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index fa6b60d0c194f0..1e3ba840d6fa94 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -99,10 +99,10 @@ def call_wrapper(func): """Call wrapper for decorator.""" @functools.wraps(func) - def wrapper(self, *args, **kwargs): + async def wrapper(self, *args, **kwargs): """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" try: - return func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error during call %s", func.__name__) diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 9525f9e8ddfd82..4130f67ec138d2 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -3,7 +3,7 @@ import logging import time -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, UnidentifiedImageError from pydoods import PyDOODS import voluptuous as vol @@ -274,7 +274,11 @@ def _save_image(self, image, matches, paths): def process_image(self, image): """Process the image.""" - img = Image.open(io.BytesIO(bytearray(image))) + try: + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + except UnidentifiedImageError: + _LOGGER.warning("Unable to process image, bad data") + return img_width, img_height = img.size if self._aspect and abs((img_width / img_height) - self._aspect) > 0.1: @@ -285,7 +289,7 @@ def process_image(self, image): ) # Run detection - start = time.time() + start = time.monotonic() response = self._doods.detect( image, dconfig=self._dconfig, detector_name=self._detector_name ) @@ -293,7 +297,7 @@ def process_image(self, image): "doods detect: %s response: %s duration: %s", self._dconfig, response, - time.time() - start, + time.monotonic() - start, ) matches = {} diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 551af839b5c475..1ac905feac2db6 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -2,7 +2,10 @@ "domain": "doods", "name": "DOODS - Distributed Outside Object Detection Service", "documentation": "https://www.home-assistant.io/integrations/doods", - "requirements": ["pydoods==1.0.2", "pillow==6.2.1"], + "requirements": [ + "pydoods==1.0.2", + "pillow==7.0.0" + ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index d82e27f0f9a363..049681a4aa6dd8 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -66,7 +66,7 @@ def setup(hass, config): custom_url = doorstation_config.get(CONF_CUSTOM_URL) events = doorstation_config.get(CONF_EVENTS) token = doorstation_config.get(CONF_TOKEN) - name = doorstation_config.get(CONF_NAME) or "DoorBird {}".format(index + 1) + name = doorstation_config.get(CONF_NAME) or f"DoorBird {index + 1}" try: device = DoorBird(device_ip, username, password) @@ -297,6 +297,6 @@ async def get(self, request, event): hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - log_entry(hass, "Doorbird {}".format(event), "event was fired.", DOMAIN) + log_entry(hass, f"Doorbird {event}", "event was fired.", DOMAIN) return web.Response(status=200, text="OK") diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index d9a802f071f5ca..4bf3a6e060faf0 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -12,9 +12,6 @@ from . import DOMAIN as DOORBIRD_DOMAIN -_CAMERA_LAST_VISITOR = "{} Last Ring" -_CAMERA_LAST_MOTION = "{} Last Motion" -_CAMERA_LIVE = "{} Live" _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) _LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) _LIVE_INTERVAL = datetime.timedelta(seconds=1) @@ -30,18 +27,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [ DoorBirdCamera( device.live_image_url, - _CAMERA_LIVE.format(doorstation.name), + f"{doorstation.name} Live", _LIVE_INTERVAL, device.rtsp_live_video_url, ), DoorBirdCamera( device.history_image_url(1, "doorbell"), - _CAMERA_LAST_VISITOR.format(doorstation.name), + f"{doorstation.name} Last Ring", _LAST_VISITOR_INTERVAL, ), DoorBirdCamera( device.history_image_url(1, "motionsensor"), - _CAMERA_LAST_MOTION.format(doorstation.name), + f"{doorstation.name} Last Motion", _LAST_MOTION_INTERVAL, ), ] diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index d3374c8d02ab60..5e3745b27ed52c 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_SENSORS +from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -26,8 +26,13 @@ SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), SENSOR_SIGNAL: ("signal strength", "Signal Strength", "%", "mdi:signal"), SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), - SENSOR_UPLOAD: ("traffic modem tx", "Sent", "GB", "mdi:cloud-upload"), - SENSOR_DOWNLOAD: ("traffic modem rx", "Received", "GB", "mdi:cloud-download"), + SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"), + SENSOR_DOWNLOAD: ( + "traffic modem rx", + "Received", + DATA_GIGABYTES, + "mdi:cloud-download", + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -80,7 +85,7 @@ def update(self): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._data.name, SENSORS[self._sensor][1]) + return f"{self._data.name} {SENSORS[self._sensor][1]}" @property def state(self): diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 8f607dc299eab1..743bad148f0cc9 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dsmr", "name": "DSMR Slimme Meter", "documentation": "https://www.home-assistant.io/integrations/dsmr", - "requirements": ["dsmr_parser==0.12"], + "requirements": ["dsmr_parser==0.18"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 253e8409f1b3a9..6ffc4a3106c842 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -1,6 +1,5 @@ """Support for Dutch Smart Meter (also known as Smartmeter or P1 port).""" import asyncio -from datetime import timedelta from functools import partial import logging @@ -10,7 +9,12 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + TIME_HOURS, +) from homeassistant.core import CoreState import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -32,9 +36,6 @@ ICON_POWER_FAILURE = "mdi:flash-off" ICON_SWELL_SAG = "mdi:pulse" -# Smart meter sends telegram every 10 seconds -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) - RECONNECT_INTERVAL = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -42,7 +43,7 @@ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( - cv.string, vol.In(["5", "4", "2.2"]) + cv.string, vol.In(["5B", "5", "4", "2.2"]) ), vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), @@ -62,17 +63,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ["Power Consumption", obis_ref.CURRENT_ELECTRICITY_USAGE], ["Power Production", obis_ref.CURRENT_ELECTRICITY_DELIVERY], ["Power Tariff", obis_ref.ELECTRICITY_ACTIVE_TARIFF], - ["Power Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL], - ["Power Consumption (low)", obis_ref.ELECTRICITY_USED_TARIFF_1], - ["Power Consumption (normal)", obis_ref.ELECTRICITY_USED_TARIFF_2], - ["Power Production (low)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1], - ["Power Production (normal)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_2], + ["Energy Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL], + ["Energy Consumption (tarif 1)", obis_ref.ELECTRICITY_USED_TARIFF_1], + ["Energy Consumption (tarif 2)", obis_ref.ELECTRICITY_USED_TARIFF_2], + ["Energy Production (tarif 1)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1], + ["Energy Production (tarif 2)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_2], ["Power Consumption Phase L1", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE], ["Power Consumption Phase L2", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE], ["Power Consumption Phase L3", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE], ["Power Production Phase L1", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE], ["Power Production Phase L2", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE], ["Power Production Phase L3", obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE], + ["Short Power Failure Count", obis_ref.SHORT_POWER_FAILURE_COUNT], ["Long Power Failure Count", obis_ref.LONG_POWER_FAILURE_COUNT], ["Voltage Sags Phase L1", obis_ref.VOLTAGE_SAG_L1_COUNT], ["Voltage Sags Phase L2", obis_ref.VOLTAGE_SAG_L2_COUNT], @@ -83,6 +85,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ["Voltage Phase L1", obis_ref.INSTANTANEOUS_VOLTAGE_L1], ["Voltage Phase L2", obis_ref.INSTANTANEOUS_VOLTAGE_L2], ["Voltage Phase L3", obis_ref.INSTANTANEOUS_VOLTAGE_L3], + ["Current Phase L1", obis_ref.INSTANTANEOUS_CURRENT_L1], + ["Current Phase L2", obis_ref.INSTANTANEOUS_CURRENT_L2], + ["Current Phase L3", obis_ref.INSTANTANEOUS_CURRENT_L3], ] # Generate device entities @@ -91,6 +96,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= # Protocol version specific obis if dsmr_version in ("4", "5"): gas_obis = obis_ref.HOURLY_GAS_METER_READING + elif dsmr_version in ("5B"): + gas_obis = obis_ref.BELGIUM_HOURLY_GAS_METER_READING else: gas_obis = obis_ref.GAS_METER_READING @@ -214,7 +221,7 @@ def state(self): value = self.get_dsmr_object_attr("value") if self._obis == obis_ref.ELECTRICITY_ACTIVE_TARIFF: - return self.translate_tariff(value) + return self.translate_tariff(value, self._config[CONF_DSMR_VERSION]) try: value = round(float(value), self._config[CONF_PRECISION]) @@ -232,8 +239,15 @@ def unit_of_measurement(self): return self.get_dsmr_object_attr("unit") @staticmethod - def translate_tariff(value): - """Convert 2/1 to normal/low.""" + def translate_tariff(value, dsmr_version): + """Convert 2/1 to normal/low depending on DSMR version.""" + # DSMR V5B: Note: In Belgium values are swapped: + # Rate code 2 is used for low rate and rate code 1 is used for normal rate. + if dsmr_version in ("5B"): + if value == "0001": + value = "0002" + elif value == "0002": + value = "0001" # DSMR V2.2: Note: Rate code 1 is used for low rate and rate code 2 is # used for normal rate. if value == "0002": @@ -294,4 +308,4 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, per hour, if any.""" unit = self.get_dsmr_object_attr("unit") if unit: - return unit + "/h" + return f"{unit}/{TIME_HOURS}" diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 45bebfeda92c2d..bd583be37f4e13 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,5 +1,7 @@ """Definitions for DSMR Reader sensors added to MQTT.""" +from homeassistant.const import VOLUME_CUBIC_METERS + def dsmr_transform(value): """Transform DSMR version value to right format.""" @@ -79,7 +81,7 @@ def tariff_transform(value): "dsmr/reading/extra_device_delivered": { "name": "Gas meter usage", "icon": "mdi:fire", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/reading/phase_voltage_l1": { "name": "Current voltage L1", @@ -99,12 +101,12 @@ def tariff_transform(value): "dsmr/consumption/gas/delivered": { "name": "Gas usage", "icon": "mdi:fire", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/consumption/gas/currently_delivered": { "name": "Current gas usage", "icon": "mdi:fire", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/consumption/gas/read_at": { "name": "Gas meter read", @@ -159,7 +161,7 @@ def tariff_transform(value): "dsmr/day-consumption/gas": { "name": "Gas usage", "icon": "mdi:counter", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/day-consumption/gas_cost": { "name": "Gas cost", diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py index aa822da0d6a4ba..826f9cf5acb586 100644 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -47,11 +47,9 @@ def __init__(self, ip_address, name, version): self._version = version if self._version == 1: - url_template = "http://{}/instantaneousdemand" + self._url = f"http://{ip_address}/instantaneousdemand" elif self._version == 2: - url_template = "http://{}:8888/zigbee/se/instantaneousdemand" - - self._url = url_template.format(ip_address) + self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand" self._name = name self._unit_of_measurement = "kW" diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index a5fe8fd6b30a3b..5de0b62a4a9a58 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -3,9 +3,6 @@ For more info on the API see : https://data.gov.ie/dataset/real-time-passenger-information-rtpi-for-dublin-bus-bus-eireann-luas-and-irish-rail/resource/4b9f2c4f-6bf5-4958-a43a-f12dab04cf61 - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.dublin_public_transport/ """ from datetime import datetime, timedelta import logging @@ -14,7 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -112,7 +109,7 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py deleted file mode 100644 index 5a1f29add438df..00000000000000 --- a/homeassistant/components/duke_energy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The duke_energy component.""" diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json deleted file mode 100644 index cebbf45df1195c..00000000000000 --- a/homeassistant/components/duke_energy/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "duke_energy", - "name": "Duke Energy", - "documentation": "https://www.home-assistant.io/integrations/duke_energy", - "requirements": ["pydukeenergy==0.0.6"], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/duke_energy/sensor.py b/homeassistant/components/duke_energy/sensor.py deleted file mode 100644 index cd30ae96caf334..00000000000000 --- a/homeassistant/components/duke_energy/sensor.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Support for Duke Energy Gas and Electric meters.""" -import logging - -from pydukeenergy.api import DukeEnergy, DukeEnergyException -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - -LAST_BILL_USAGE = "last_bills_usage" -LAST_BILL_AVERAGE_USAGE = "last_bills_average_usage" -LAST_BILL_DAYS_BILLED = "last_bills_days_billed" - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up all Duke Energy meters.""" - - try: - duke = DukeEnergy( - config[CONF_USERNAME], config[CONF_PASSWORD], update_interval=120 - ) - except DukeEnergyException: - _LOGGER.error("Failed to set up Duke Energy") - return - - add_entities([DukeEnergyMeter(meter) for meter in duke.get_meters()]) - - -class DukeEnergyMeter(Entity): - """Representation of a Duke Energy meter.""" - - def __init__(self, meter): - """Initialize the meter.""" - self.duke_meter = meter - - @property - def name(self): - """Return the name.""" - return f"duke_energy_{self.duke_meter.id}" - - @property - def unique_id(self): - """Return the unique ID.""" - return self.duke_meter.id - - @property - def state(self): - """Return yesterdays usage.""" - return self.duke_meter.get_usage() - - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self.duke_meter.get_unit() - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attributes = { - LAST_BILL_USAGE: self.duke_meter.get_total(), - LAST_BILL_AVERAGE_USAGE: self.duke_meter.get_average(), - LAST_BILL_DAYS_BILLED: self.duke_meter.get_days_billed(), - } - return attributes - - def update(self): - """Update meter.""" - self.duke_meter.update() diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 695b839d18cf89..966ec407ce8fa8 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -1,9 +1,6 @@ """ Support for getting statistical data from a DWD Weather Warnings. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.dwd_weather_warnings/ - Data is fetched from DWD: https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html @@ -178,12 +175,7 @@ class DwdWeatherWarningsAPI: def __init__(self, region_name): """Initialize the data object.""" - resource = "{}{}{}?{}".format( - "https://", - "www.dwd.de", - "/DWD/warnungen/warnapp_landkreise/json/warnings.json", - "jsonp=loadWarnings", - ) + resource = "https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json?jsonp=loadWarnings" # a User-Agent is necessary for this rest api endpoint (#29496) headers = {"User-Agent": HA_USER_AGENT} diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py new file mode 100755 index 00000000000000..f4fc65b8261b42 --- /dev/null +++ b/homeassistant/components/dynalite/__init__.py @@ -0,0 +1,138 @@ +"""Support for the Dynalite networks.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +# Loading the config flow file will register the flow +from .bridge import DynaliteBridge +from .const import ( + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_BRIDGES, + CONF_CHANNEL, + CONF_DEFAULT, + CONF_FADE, + CONF_NAME, + CONF_POLLTIMER, + CONF_PORT, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) + + +def num_string(value): + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)} +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +AREA_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + }, +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLLTIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Coerce(bool), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Dynalite platform.""" + + conf = config.get(DOMAIN) + LOGGER.debug("Setting up dynalite component config = %s", conf) + + if conf is None: + conf = {} + + hass.data[DOMAIN] = {} + + # User has configured bridges + if CONF_BRIDGES not in conf: + return True + + bridges = conf[CONF_BRIDGES] + + for bridge_conf in bridges: + host = bridge_conf[CONF_HOST] + LOGGER.debug("Starting config entry flow host=%s conf=%s", host, bridge_conf) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=bridge_conf, + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a bridge from a config entry.""" + LOGGER.debug("Setting up entry %s", entry.data) + + bridge = DynaliteBridge(hass, entry.data) + + if not await bridge.async_setup(): + LOGGER.error("Could not set up bridge for entry %s", entry.data) + return False + + if not await bridge.try_connection(): + LOGGER.errot("Could not connect with entry %s", entry) + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = bridge + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + LOGGER.debug("Unloading entry %s", entry.data) + hass.data[DOMAIN].pop(entry.entry_id) + result = await hass.config_entries.async_forward_entry_unload(entry, "light") + return result diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py new file mode 100755 index 00000000000000..cbe08fdadb56b3 --- /dev/null +++ b/homeassistant/components/dynalite/bridge.py @@ -0,0 +1,82 @@ +"""Code to handle a Dynalite bridge.""" + +import asyncio + +from dynalite_devices_lib import DynaliteDevices + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_ALL, CONF_HOST, LOGGER + +CONNECT_TIMEOUT = 30 +CONNECT_INTERVAL = 1 + + +class DynaliteBridge: + """Manages a single Dynalite bridge.""" + + def __init__(self, hass, config): + """Initialize the system based on host parameter.""" + self.hass = hass + self.area = {} + self.async_add_devices = None + self.waiting_devices = [] + self.host = config[CONF_HOST] + # Configure the dynalite devices + self.dynalite_devices = DynaliteDevices( + config=config, + newDeviceFunc=self.add_devices_when_registered, + updateDeviceFunc=self.update_device, + ) + + async def async_setup(self): + """Set up a Dynalite bridge.""" + # Configure the dynalite devices + return await self.dynalite_devices.async_setup() + + def update_signal(self, device=None): + """Create signal to use to trigger entity update.""" + if device: + signal = f"dynalite-update-{self.host}-{device.unique_id}" + else: + signal = f"dynalite-update-{self.host}" + return signal + + @callback + def update_device(self, device): + """Call when a device or all devices should be updated.""" + if device == CONF_ALL: + # This is used to signal connection or disconnection, so all devices may become available or not. + log_string = ( + "Connected" if self.dynalite_devices.available else "Disconnected" + ) + LOGGER.info("%s to dynalite host", log_string) + async_dispatcher_send(self.hass, self.update_signal()) + else: + async_dispatcher_send(self.hass, self.update_signal(device)) + + async def try_connection(self): + """Try to connect to dynalite with timeout.""" + # Currently by polling. Future - will need to change the library to be proactive + for _ in range(0, CONNECT_TIMEOUT): + if self.dynalite_devices.available: + return True + await asyncio.sleep(CONNECT_INTERVAL) + return False + + @callback + def register_add_devices(self, async_add_devices): + """Add an async_add_entities for a category.""" + self.async_add_devices = async_add_devices + if self.waiting_devices: + self.async_add_devices(self.waiting_devices) + + def add_devices_when_registered(self, devices): + """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" + if not devices: + return + if self.async_add_devices: + self.async_add_devices(devices) + else: # handle it later when it is registered + self.waiting_devices.extend(devices) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py new file mode 100755 index 00000000000000..aac421721819f9 --- /dev/null +++ b/homeassistant/components/dynalite/config_flow.py @@ -0,0 +1,35 @@ +"""Config flow to configure Dynalite hub.""" +from homeassistant import config_entries +from homeassistant.const import CONF_HOST + +from .bridge import DynaliteBridge +from .const import DOMAIN, LOGGER # pylint: disable=unused-import + + +class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Dynalite config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + + def __init__(self): + """Initialize the Dynalite flow.""" + self.host = None + + async def async_step_import(self, import_info): + """Import a new bridge as a config entry.""" + LOGGER.debug("Starting async_step_import - %s", import_info) + host = import_info[CONF_HOST] + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured(import_info) + # New entry + bridge = DynaliteBridge(self.hass, import_info) + if not await bridge.async_setup(): + LOGGER.error("Unable to setup bridge - import info=%s", import_info) + return self.async_abort(reason="bridge_setup_failed") + if not await bridge.try_connection(): + return self.async_abort(reason="no_connection") + LOGGER.debug("Creating entry for the bridge - %s", import_info) + return self.async_create_entry(title=host, data=import_info) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py new file mode 100755 index 00000000000000..f77955544653f0 --- /dev/null +++ b/homeassistant/components/dynalite/const.py @@ -0,0 +1,21 @@ +"""Constants for the Dynalite component.""" +import logging + +LOGGER = logging.getLogger(__package__) +DOMAIN = "dynalite" + +CONF_ACTIVE = "active" +CONF_ALL = "ALL" +CONF_AREA = "area" +CONF_AUTO_DISCOVER = "autodiscover" +CONF_BRIDGES = "bridges" +CONF_CHANNEL = "channel" +CONF_DEFAULT = "default" +CONF_FADE = "fade" +CONF_HOST = "host" +CONF_NAME = "name" +CONF_POLLTIMER = "polltimer" +CONF_PORT = "port" + +DEFAULT_NAME = "dynalite" +DEFAULT_PORT = 12345 diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py new file mode 100755 index 00000000000000..652a6178705e5c --- /dev/null +++ b/homeassistant/components/dynalite/light.py @@ -0,0 +1,96 @@ +"""Support for Dynalite channels as lights.""" +from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN, LOGGER + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Record the async_add_entities function to add them later when received from Dynalite.""" + LOGGER.debug("Setting up light entry = %s", config_entry.data) + bridge = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def async_add_lights(devices): + added_lights = [] + for device in devices: + if device.category == "light": + added_lights.append(DynaliteLight(device, bridge)) + if added_lights: + async_add_entities(added_lights) + + bridge.register_add_devices(async_add_lights) + + +class DynaliteLight(Light): + """Representation of a Dynalite Channel as a Home Assistant Light.""" + + def __init__(self, device, bridge): + """Initialize the base class.""" + self._device = device + self._bridge = bridge + + @property + def name(self): + """Return the name of the entity.""" + return self._device.name + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return self._device.unique_id + + @property + def available(self): + """Return if entity is available.""" + return self._device.available + + async def async_update(self): + """Update the entity.""" + return + + @property + def device_info(self): + """Device info for this entity.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Dynalite", + } + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._device.brightness + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + await self._device.async_turn_on(**kwargs) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._device.async_turn_off(**kwargs) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + async def async_added_to_hass(self): + """Added to hass so need to register to dispatch.""" + # register for device specific update + async_dispatcher_connect( + self.hass, + self._bridge.update_signal(self._device), + self.async_schedule_update_ha_state, + ) + # register for wide update + async_dispatcher_connect( + self.hass, self._bridge.update_signal(), self.async_schedule_update_ha_state + ) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json new file mode 100755 index 00000000000000..95667733d38228 --- /dev/null +++ b/homeassistant/components/dynalite/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "dynalite", + "name": "Philips Dynalite", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dynalite", + "dependencies": [], + "codeowners": ["@ziv1234"], + "requirements": ["dynalite_devices==0.1.22"] +} diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py index df97358d55008d..f4e23b016226f5 100644 --- a/homeassistant/components/dyson/climate.py +++ b/homeassistant/components/dyson/climate.py @@ -89,7 +89,7 @@ def current_temperature(self): if self._device.environmental_state: temperature_kelvin = self._device.environmental_state.temperature if temperature_kelvin != 0: - self._current_temp = float("{0:.1f}".format(temperature_kelvin - 273)) + self._current_temp = float(f"{(temperature_kelvin - 273):.1f}") return self._current_temp @property diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 2d41e6b828ad0d..8613ab3e7af326 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -1,8 +1,4 @@ -"""Support for Dyson Pure Cool link fan. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/fan.dyson/ -""" +"""Support for Dyson Pure Cool link fan.""" import logging from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation @@ -157,10 +153,7 @@ def service_handle(service): ) hass.services.register( - DYSON_DOMAIN, - SERVICE_SET_AUTO_MODE, - service_handle, - schema=SET_AUTO_MODE_SCHEMA, + DYSON_DOMAIN, SERVICE_SET_AUTO_MODE, service_handle, schema=SET_AUTO_MODE_SCHEMA ) if has_purecool_devices: hass.services.register( @@ -223,7 +216,7 @@ def set_speed(self, speed: str) -> None: if speed == FanSpeed.FAN_SPEED_AUTO.value: self._device.set_configuration(fan_mode=FanMode.AUTO) else: - fan_speed = FanSpeed("{0:04d}".format(int(speed))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_configuration(fan_mode=FanMode.FAN, fan_speed=fan_speed) def turn_on(self, speed: str = None, **kwargs) -> None: @@ -233,7 +226,7 @@ def turn_on(self, speed: str = None, **kwargs) -> None: if speed == FanSpeed.FAN_SPEED_AUTO.value: self._device.set_configuration(fan_mode=FanMode.AUTO) else: - fan_speed = FanSpeed("{0:04d}".format(int(speed))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_configuration( fan_mode=FanMode.FAN, fan_speed=fan_speed ) @@ -393,7 +386,7 @@ def set_dyson_speed(self, speed: str = None) -> None: """Set the exact speed of the purecool fan.""" _LOGGER.debug("Set exact speed for fan %s", self.name) - fan_speed = FanSpeed("{0:04d}".format(int(speed))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_fan_speed(fan_speed) def oscillate(self, oscillating: bool) -> None: diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json index 4fc49b4ca60016..f6c0c187c8cd12 100644 --- a/homeassistant/components/dyson/manifest.json +++ b/homeassistant/components/dyson/manifest.json @@ -2,7 +2,7 @@ "domain": "dyson", "name": "Dyson", "documentation": "https://www.home-assistant.io/integrations/dyson", - "requirements": ["libpurecool==0.6.0"], + "requirements": ["libpurecool==0.6.1"], "dependencies": [], "codeowners": ["@etheralm"] } diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index 2fdd3cd6c1fc02..c7f61422a2e4d4 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -4,7 +4,7 @@ from libpurecool.dyson_pure_cool import DysonPureCool from libpurecool.dyson_pure_cool_link import DysonPureCoolLink -from homeassistant.const import STATE_OFF, TEMP_CELSIUS +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TIME_HOURS from homeassistant.helpers.entity import Entity from . import DYSON_DEVICES @@ -12,7 +12,7 @@ SENSOR_UNITS = { "air_quality": None, "dust": None, - "filter_life": "hours", + "filter_life": TIME_HOURS, "humidity": "%", } @@ -43,9 +43,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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: + if f"{device.serial}-temperature" not in device_ids: devices.append(DysonTemperatureSensor(device, unit)) - if "{}-{}".format(device.serial, "humidity") not in device_ids: + if f"{device.serial}-humidity" not in device_ids: devices.append(DysonHumiditySensor(device)) elif isinstance(device, DysonPureCoolLink): devices.append(DysonFilterLifeSensor(device)) @@ -173,8 +173,8 @@ def state(self): if temperature_kelvin == 0: return STATE_OFF if self._unit == TEMP_CELSIUS: - return float("{0:.1f}".format(temperature_kelvin - 273.15)) - return float("{0:.1f}".format(temperature_kelvin * 9 / 5 - 459.67)) + return float(f"{(temperature_kelvin - 273.15):.1f}") + return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") return None @property diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 55504e8edf7e5d..208ffb99543ccb 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -2,9 +2,6 @@ Support for EBox. Get data from 'My Usage Page' page: https://client.ebox.ca/myusage - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ebox/ """ from datetime import timedelta import logging @@ -19,6 +16,8 @@ CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + DATA_GIGABITS, + TIME_DAYS, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -27,9 +26,7 @@ _LOGGER = logging.getLogger(__name__) -GIGABITS = "Gb" PRICE = "CAD" -DAYS = "days" PERCENT = "%" DEFAULT_NAME = "EBox" @@ -41,17 +38,21 @@ SENSOR_TYPES = { "usage": ["Usage", PERCENT, "mdi:percent"], "balance": ["Balance", PRICE, "mdi:square-inc-cash"], - "limit": ["Data limit", GIGABITS, "mdi:download"], - "days_left": ["Days left", DAYS, "mdi:calendar-today"], - "before_offpeak_download": ["Download before offpeak", GIGABITS, "mdi:download"], - "before_offpeak_upload": ["Upload before offpeak", GIGABITS, "mdi:upload"], - "before_offpeak_total": ["Total before offpeak", GIGABITS, "mdi:download"], - "offpeak_download": ["Offpeak download", GIGABITS, "mdi:download"], - "offpeak_upload": ["Offpeak Upload", GIGABITS, "mdi:upload"], - "offpeak_total": ["Offpeak Total", GIGABITS, "mdi:download"], - "download": ["Download", GIGABITS, "mdi:download"], - "upload": ["Upload", GIGABITS, "mdi:upload"], - "total": ["Total", GIGABITS, "mdi:download"], + "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], + "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], + "before_offpeak_download": [ + "Download before offpeak", + DATA_GIGABITS, + "mdi:download", + ], + "before_offpeak_upload": ["Upload before offpeak", DATA_GIGABITS, "mdi:upload"], + "before_offpeak_total": ["Total before offpeak", DATA_GIGABITS, "mdi:download"], + "offpeak_download": ["Offpeak download", DATA_GIGABITS, "mdi:download"], + "offpeak_upload": ["Offpeak Upload", DATA_GIGABITS, "mdi:upload"], + "offpeak_total": ["Offpeak Total", DATA_GIGABITS, "mdi:download"], + "download": ["Download", DATA_GIGABITS, "mdi:download"], + "upload": ["Upload", DATA_GIGABITS, "mdi:upload"], + "total": ["Total", DATA_GIGABITS, "mdi:download"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index ec097a153c97dd..10ed0b68e87780 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,8 +1,12 @@ """Constants for ebus component.""" -from homeassistant.const import ENERGY_KILO_WATT_HOUR, PRESSURE_BAR, TEMP_CELSIUS +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + PRESSURE_BAR, + TEMP_CELSIUS, + TIME_SECONDS, +) DOMAIN = "ebusd" -TIME_SECONDS = "seconds" # SensorTypes from ebusdpy module : # 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status' diff --git a/homeassistant/components/ecobee/.translations/es-419.json b/homeassistant/components/ecobee/.translations/es-419.json new file mode 100644 index 00000000000000..3e19977f10f99e --- /dev/null +++ b/homeassistant/components/ecobee/.translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "one_instance_only": "Esta integraci\u00f3n actualmente solo admite una instancia de ecobee." + }, + "error": { + "pin_request_failed": "Error al solicitar PIN de ecobee; verifique que la clave API sea correcta.", + "token_request_failed": "Error al solicitar tokens de ecobee; Int\u00e9ntelo de nuevo." + }, + "step": { + "authorize": { + "description": "Autorice esta aplicaci\u00f3n en https://www.ecobee.com/consumerportal/index.html con c\u00f3digo PIN: \n\n {pin} \n \n Luego, presione Enviar." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/hu.json b/homeassistant/components/ecobee/.translations/hu.json new file mode 100644 index 00000000000000..0950d52bd0e8d2 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/hu.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "one_instance_only": "Ez az integr\u00e1ci\u00f3 jelenleg csak egy ecobee p\u00e9ld\u00e1nyt t\u00e1mogat." + }, + "error": { + "pin_request_failed": "Hiba t\u00f6rt\u00e9nt a PIN-k\u00f3d ecobee-t\u0151l t\u00f6rt\u00e9n\u0151 k\u00e9r\u00e9sekor; ellen\u0151rizze, hogy az API-kulcs helyes-e.", + "token_request_failed": "Hiba t\u00f6rt\u00e9nt a tokenek ecobee-t\u0151l t\u00f6rt\u00e9n\u0151 ig\u00e9nyl\u00e9se k\u00f6zben; pr\u00f3b\u00e1lkozzon \u00fajra." + }, + "step": { + "authorize": { + "description": "K\u00e9rj\u00fck, enged\u00e9lyezze ezt az alkalmaz\u00e1st a https://www.ecobee.com/consumerportal/index.html c\u00edmen a k\u00f6vetkez\u0151 PIN-k\u00f3ddal: \n\n {pin} \n \n Ezut\u00e1n nyomja meg a K\u00fcld\u00e9s gombot.", + "title": "Alkalmaz\u00e1s enged\u00e9lyez\u00e9se ecobee.com-on" + }, + "user": { + "data": { + "api_key": "API kulcs" + }, + "description": "Adja meg az ecobee.com webhelyr\u0151l beszerzett API-kulcsot.", + "title": "ecobee API kulcs" + } + }, + "title": "ecobee" + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/.translations/sv.json b/homeassistant/components/ecobee/.translations/sv.json index f4a63bb449d1f8..da62172dc10f4d 100644 --- a/homeassistant/components/ecobee/.translations/sv.json +++ b/homeassistant/components/ecobee/.translations/sv.json @@ -1,11 +1,25 @@ { "config": { + "abort": { + "one_instance_only": "Denna integration st\u00f6der f\u00f6r n\u00e4rvarande endast en ecobee-instans." + }, + "error": { + "pin_request_failed": "Fel vid beg\u00e4ran av PIN-kod fr\u00e5n ecobee. kontrollera API-nyckeln \u00e4r korrekt.", + "token_request_failed": "Fel vid beg\u00e4ran av tokens fr\u00e5n ecobee; v\u00e4nligen f\u00f6rs\u00f6k igen." + }, "step": { + "authorize": { + "description": "V\u00e4nligen auktorisera denna app p\u00e5 https://www.ecobee.com/consumerportal/index.html med pin-kod:\n\n{pin}\n\nTryck sedan p\u00e5 Skicka.", + "title": "Auktorisera app p\u00e5 ecobee.com" + }, "user": { "data": { "api_key": "API-nyckel" - } + }, + "description": "V\u00e4nligen ange API-nyckeln som erh\u00e5llits fr\u00e5n ecobee.com.", + "title": "ecobee API-nyckel" } - } + }, + "title": "ecobee" } } \ No newline at end of file diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 80c3be7954b4ac..26bfbe5b3dadc1 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -96,9 +96,7 @@ async def update(self): await self._hass.async_add_executor_job(self.ecobee.update) _LOGGER.debug("Updating ecobee") except ExpiredTokenError: - _LOGGER.warning( - "Ecobee update failed; attempting to refresh expired tokens" - ) + _LOGGER.debug("Refreshing expired ecobee tokens") await self.refresh() async def refresh(self) -> bool: @@ -113,7 +111,7 @@ async def refresh(self) -> bool: }, ) return True - _LOGGER.error("Error updating ecobee tokens") + _LOGGER.error("Error refreshing ecobee tokens") return False diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index bb406d81e3a3a9..cbe16832a34781 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -107,7 +107,7 @@ async def async_step_import(self, import_data): if await self.hass.async_add_executor_job(ecobee.refresh_tokens): # Credentials found and validated; create the entry. _LOGGER.debug( - "Valid ecobee configuration found for import, creating config entry" + "Valid ecobee configuration found for import, creating configuration entry" ) return self.async_create_entry( title=DOMAIN, diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 32b589649266b7..8e21b9931cd0b5 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,9 +1,9 @@ { "domain": "ecobee", - "name": "Ecobee", + "name": "ecobee", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "dependencies": [], - "requirements": ["python-ecobee-api==0.1.4"], + "requirements": ["python-ecobee-api==0.2.1"], "codeowners": ["@marthoc"] } diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index c2c34d148e3c55..ca3e7732e1bd99 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -37,7 +37,7 @@ class EcobeeSensor(Entity): def __init__(self, data, sensor_name, sensor_type, sensor_index): """Initialize the sensor.""" self.data = data - self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0]) + self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}" self.sensor_name = sensor_name self.type = sensor_type self.index = sensor_index diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index a74fdaa21baa2e..806c0b4128505b 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -56,10 +56,10 @@ def __init__(self, device): self.device = device self.device.connect_and_wait_until_ready() if self.device.vacuum.get("nick", None) is not None: - self._name = "{}".format(self.device.vacuum["nick"]) + self._name = str(self.device.vacuum["nick"]) else: # In case there is no nickname defined, use the device id - self._name = "{}".format(self.device.vacuum["did"]) + self._name = str(format(self.device.vacuum["did"])) self._fan_speed = None self._error = None diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 22d3533d32fbbc..1d6ff61bf59c98 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -3,9 +3,6 @@ Your beacons must be configured to transmit UID (for identification) and TLM (for temperature) frames. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.eddystone_temperature/ """ import logging @@ -91,7 +88,7 @@ def get_from_conf(config, config_key, length): string = config.get(config_key) if len(string) != length: _LOGGER.error( - "Error in config parameter %s: Must be exactly %d " + "Error in configuration parameter %s: Must be exactly %d " "bytes. Device will not be added", config_key, length / 2, diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json index 20036311592dcb..de8b978b9f9d39 100644 --- a/homeassistant/components/edimax/manifest.json +++ b/homeassistant/components/edimax/manifest.json @@ -2,7 +2,7 @@ "domain": "edimax", "name": "Edimax", "documentation": "https://www.home-assistant.io/integrations/edimax", - "requirements": ["pyedimax==0.1"], + "requirements": ["pyedimax==0.2.1"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index 3d558f6c7708f8..e44ec23bca79d9 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -10,6 +10,8 @@ _LOGGER = logging.getLogger(__name__) +DOMAIN = "edimax" + DEFAULT_NAME = "Edimax Smart Plug" DEFAULT_PASSWORD = "1234" DEFAULT_USERNAME = "admin" @@ -30,7 +32,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) name = config.get(CONF_NAME) - add_entities([SmartPlugSwitch(SmartPlug(host, auth), name)]) + add_entities([SmartPlugSwitch(SmartPlug(host, auth), name)], True) class SmartPlugSwitch(SwitchDevice): @@ -43,6 +45,14 @@ def __init__(self, smartplug, name): self._now_power = None self._now_energy_day = None self._state = False + self._supports_power_monitoring = False + self._info = None + self._mac = None + + @property + def unique_id(self): + """Return the device's MAC address.""" + return self._mac @property def name(self): @@ -74,14 +84,20 @@ def turn_off(self, **kwargs): def update(self): """Update edimax switch.""" - try: - self._now_power = float(self.smartplug.now_power) - except (TypeError, ValueError): - self._now_power = None - - try: - self._now_energy_day = float(self.smartplug.now_energy_day) - except (TypeError, ValueError): - self._now_energy_day = None + if not self._info: + self._info = self.smartplug.info + self._mac = self._info["mac"] + self._supports_power_monitoring = self._info["model"] != "SP1101W" + + if self._supports_power_monitoring: + try: + self._now_power = float(self.smartplug.now_power) + except (TypeError, ValueError): + self._now_power = None + + try: + self._now_energy_day = float(self.smartplug.now_energy_day) + except (TypeError, ValueError): + self._now_energy_day = None self._state = self.smartplug.state == "ON" diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 3be962fea2f501..8c16317beda402 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -63,9 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: - url_string = "{}getCurrentValuesSummary?token={}".format( - _RESOURCE, app_token - ) + url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}" response = requests.get(url_string, timeout=10) for sensor in response.json(): sid = sensor["sid"] @@ -136,9 +134,7 @@ def update(self): response = requests.get(url_string, timeout=10) self._state = response.json()["reading"] elif self.type == "amount": - url_string = "{}getEnergy?token={}&offset={}&period={}".format( - _RESOURCE, self.app_token, self.utc_offset, self.period - ) + url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}" response = requests.get(url_string, timeout=10) self._state = response.json()["sum"] elif self.type == "budget": @@ -146,14 +142,12 @@ def update(self): response = requests.get(url_string, timeout=10) self._state = response.json()["status"] elif self.type == "cost": - url_string = "{}getCost?token={}&offset={}&period={}".format( - _RESOURCE, self.app_token, self.utc_offset, self.period - ) + url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}" response = requests.get(url_string, timeout=10) self._state = response.json()["sum"] elif self.type == "current_values": - url_string = "{}getCurrentValuesSummary?token={}".format( - _RESOURCE, self.app_token + url_string = ( + f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}" ) response = requests.get(url_string, timeout=10) for sensor in response.json(): diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index a8a5a6e1fccd01..595144013b6b16 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -43,12 +43,14 @@ NAME_MAP = { "left_current_sleep": "Left Sleep Session", + "left_current_sleep_fitness": "Left Sleep Fitness", "left_last_sleep": "Left Previous Sleep Session", "left_bed_state": "Left Bed State", "left_presence": "Left Bed Presence", "left_bed_temp": "Left Bed Temperature", "left_sleep_stage": "Left Sleep Stage", "right_current_sleep": "Right Sleep Session", + "right_current_sleep_fitness": "Right Sleep Fitness", "right_last_sleep": "Right Previous Sleep Session", "right_bed_state": "Right Bed State", "right_presence": "Right Bed Presence", @@ -57,14 +59,21 @@ "room_temp": "Room Temperature", } -SENSORS = ["current_sleep", "last_sleep", "bed_state", "bed_temp", "sleep_stage"] +SENSORS = [ + "current_sleep", + "current_sleep_fitness", + "last_sleep", + "bed_state", + "bed_temp", + "sleep_stage", +] SERVICE_HEAT_SET = "heat_set" ATTR_TARGET_HEAT = "target" ATTR_HEAT_DURATION = "duration" -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) SERVICE_EIGHT_SCHEMA = vol.Schema( @@ -101,7 +110,7 @@ async def async_setup(hass, config): _LOGGER.error("Timezone is not set in Home Assistant.") return False - timezone = hass.config.time_zone + timezone = str(hass.config.time_zone) eight = EightSleep(user, password, timezone, partner, None, hass.loop) diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index 75998e71e5f741..6372967b42b90c 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -2,7 +2,7 @@ "domain": "eight_sleep", "name": "Eight Sleep", "documentation": "https://www.home-assistant.io/integrations/eight_sleep", - "requirements": ["pyeight==0.1.2"], + "requirements": ["pyeight==0.1.3"], "dependencies": [], "codeowners": ["@mezz64"] } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index d3d54fd58caa92..af6de2657ce0c8 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -28,6 +28,11 @@ ATTR_DURATION_HEAT = "Heating Time Remaining" ATTR_PROCESSING = "Processing" ATTR_SESSION_START = "Session Start" +ATTR_FIT_DATE = "Fitness Date" +ATTR_FIT_DURATION_SCORE = "Fitness Duration Score" +ATTR_FIT_ASLEEP_SCORE = "Fitness Asleep Score" +ATTR_FIT_OUT_SCORE = "Fitness Out-of-Bed Score" +ATTR_FIT_WAKEUP_SCORE = "Fitness Wakeup Score" _LOGGER = logging.getLogger(__name__) @@ -151,7 +156,11 @@ def state(self): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - if "current_sleep" in self._sensor or "last_sleep" in self._sensor: + if ( + "current_sleep" in self._sensor + or "last_sleep" in self._sensor + or "current_sleep_fitness" in self._sensor + ): return "Score" if "bed_temp" in self._sensor: if self._units == "si": @@ -169,8 +178,12 @@ async def async_update(self): """Retrieve latest state.""" _LOGGER.debug("Updating User sensor: %s", self._sensor) if "current" in self._sensor: - self._state = self._usrobj.current_sleep_score - self._attr = self._usrobj.current_values + if "fitness" in self._sensor: + self._state = self._usrobj.current_sleep_fitness_score + self._attr = self._usrobj.current_fitness_values + else: + self._state = self._usrobj.current_sleep_score + self._attr = self._usrobj.current_values elif "last" in self._sensor: self._state = self._usrobj.last_sleep_score self._attr = self._usrobj.last_values @@ -193,6 +206,16 @@ def device_state_attributes(self): # Skip attributes if sensor type doesn't support return None + if "fitness" in self._sensor_root: + state_attr = { + ATTR_FIT_DATE: self._attr["date"], + ATTR_FIT_DURATION_SCORE: self._attr["duration"], + ATTR_FIT_ASLEEP_SCORE: self._attr["asleep"], + ATTR_FIT_OUT_SCORE: self._attr["out"], + ATTR_FIT_WAKEUP_SCORE: self._attr["wakeup"], + } + return state_attr + state_attr = {ATTR_SESSION_START: self._attr["date"]} state_attr[ATTR_TNT] = self._attr["tnt"] state_attr[ATTR_PROCESSING] = self._attr["processing"] diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index db7690730dd91e..49a3c67d60498a 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,6 +1,6 @@ heat_set: - description: Set heating level for eight sleep. + description: Set heating/cooling level for eight sleep. fields: - duration: {description: Duration to heat at the target level in seconds., example: 3600} + duration: {description: Duration to heat/cool at the target level in seconds., example: 3600} entity_id: {description: Entity id of the bed state to adjust., example: sensor.eight_left_bed_state} - target: {description: Target heating level from 0-100., example: 35} + target: {description: Target cooling/heating level from -100 to 100., example: 35} diff --git a/homeassistant/components/elgato/.translations/ca.json b/homeassistant/components/elgato/.translations/ca.json index b717a5abadee28..3ba9029eb00361 100644 --- a/homeassistant/components/elgato/.translations/ca.json +++ b/homeassistant/components/elgato/.translations/ca.json @@ -15,7 +15,7 @@ "port": "N\u00famero de port" }, "description": "Configura l'Elgato Key Light per integrar-lo amb Home Assistant.", - "title": "Enlla\u00e7a Elgato Key Light" + "title": "Enlla\u00e7 amb Elgato Key Light" }, "zeroconf_confirm": { "description": "Vols afegir l'Elgato Key Light amb n\u00famero de s\u00e8rie `{serial_number}` a Home Assistant?", diff --git a/homeassistant/components/elgato/.translations/es-419.json b/homeassistant/components/elgato/.translations/es-419.json new file mode 100644 index 00000000000000..2653060030a35b --- /dev/null +++ b/homeassistant/components/elgato/.translations/es-419.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Host o direcci\u00f3n IP", + "port": "N\u00famero de puerto" + }, + "description": "Configure su Elgato Key Light para integrarse con Home Assistant." + }, + "zeroconf_confirm": { + "title": "Dispositivo Elgato Key Light descubierto" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/hu.json b/homeassistant/components/elgato/.translations/hu.json new file mode 100644 index 00000000000000..d3618d0039dd65 --- /dev/null +++ b/homeassistant/components/elgato/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Ez az Elgato Key Light eszk\u00f6z m\u00e1r konfigur\u00e1lva van.", + "connection_error": "Nem siker\u00fclt csatlakozni az Elgato Key Light eszk\u00f6zh\u00f6z." + }, + "step": { + "user": { + "data": { + "host": "Hosztn\u00e9v vagy IP c\u00edm", + "port": "Portsz\u00e1m" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/ru.json b/homeassistant/components/elgato/.translations/ru.json index 454e6e78d84351..1663ea4d23aa81 100644 --- a/homeassistant/components/elgato/.translations/ru.json +++ b/homeassistant/components/elgato/.translations/ru.json @@ -19,9 +19,9 @@ }, "zeroconf_confirm": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Elgato Key Light \u0441 \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c `{serial_number}`?", - "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Elgado Key Light" + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Elgato Key Light" } }, - "title": "Elgado Key Light" + "title": "Elgato Key Light" } } \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/sv.json b/homeassistant/components/elgato/.translations/sv.json new file mode 100644 index 00000000000000..83850c186c7399 --- /dev/null +++ b/homeassistant/components/elgato/.translations/sv.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r Elgato Key Light-enheten \u00e4r redan konfigurerad.", + "connection_error": "Det gick inte att ansluta till Elgato Key Light-enheten." + }, + "error": { + "connection_error": "Det gick inte att ansluta till Elgato Key Light-enheten." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "V\u00e4rd eller IP-adress", + "port": "Portnummer" + }, + "description": "St\u00e4ll in ditt Elgato Key Light f\u00f6r att integrera med Home Assistant.", + "title": "L\u00e4nk din Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vill du l\u00e4gga till Elgato Key Light med serienummer `{serial_number}` till Home Assistant?", + "title": "Uppt\u00e4ckte Elgato Key Light-enhet" + } + }, + "title": "Elgato Key Light" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index a8a81734999e45..2f3e05fd720995 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -7,8 +7,8 @@ from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.helpers import ConfigType from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import CONF_SERIAL_NUMBER, DOMAIN # pylint: disable=unused-import diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 67b84c4f3bf7a3..2acb8030cf1d2f 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -257,9 +257,7 @@ def __init__(self, element, elk, elk_data): uid_start = f"elkm1m_{self._prefix}" else: uid_start = "elkm1" - self._unique_id = "{uid_start}_{name}".format( - uid_start=uid_start, name=self._element.default_name("_") - ).lower() + self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() @property def name(self): diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 3ed5356f4de136..df29e1cda7ee37 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -178,7 +178,7 @@ def icon(self): ZoneType.PHONE_KEY.value: "phone-classic", ZoneType.INTERCOM_KEY.value: "deskphone", } - return "mdi:{}".format(zone_icons.get(self._element.definition, "alarm-bell")) + return f"mdi:{zone_icons.get(self._element.definition, 'alarm-bell')}" @property def device_state_attributes(self): diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py index a77d21cf173a8d..d867d286f50e45 100644 --- a/homeassistant/components/elv/switch.py +++ b/homeassistant/components/elv/switch.py @@ -81,12 +81,12 @@ def device_state_attributes(self): def update(self): """Update the PCA switch's state.""" try: - self._emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format( - self._pca.get_current_power(self._device_id) - ) - self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = "{:.2f}".format( - self._pca.get_total_consumption(self._device_id) - ) + self._emeter_params[ + ATTR_CURRENT_POWER_W + ] = f"{self._pca.get_current_power(self._device_id):.1f}" + self._emeter_params[ + ATTR_TOTAL_ENERGY_KWH + ] = f"{self._pca.get_total_consumption(self._device_id):.2f}" self._available = True self._state = self._pca.get_state(self._device_id) diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 2fd1014dcdf649..56d68cee6b583f 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -190,9 +190,7 @@ def supports_remote_control(self): @property def name(self): """Return the name of the device.""" - return ( - f"Emby - {self.device.client} - {self.device.name}" or DEVICE_DEFAULT_NAME - ) + return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME @property def should_poll(self): @@ -309,44 +307,26 @@ def supported_features(self): return SUPPORT_EMBY return None - def async_media_play(self): - """Play media. + async def async_media_play(self): + """Play media.""" + await self.device.media_play() - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_play() - - def async_media_pause(self): - """Pause the media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_pause() - - def async_media_stop(self): - """Stop the media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_stop() - - def async_media_next_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_next() + async def async_media_pause(self): + """Pause the media player.""" + await self.device.media_pause() - def async_media_previous_track(self): - """Send next track command. + async def async_media_stop(self): + """Stop the media player.""" + await self.device.media_stop() - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_previous() + async def async_media_next_track(self): + """Send next track command.""" + await self.device.media_next() - def async_media_seek(self, position): - """Send seek command. + async def async_media_previous_track(self): + """Send next track command.""" + await self.device.media_previous() - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_seek(position) + async def async_media_seek(self, position): + """Send seek command.""" + await self.device.media_seek(position) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 34063e4c253f4f..4f214d697f31e1 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -64,9 +64,7 @@ def get_id(sensorid, feedtag, feedname, feedid, feeduserid): """Return unique identifier for feed / sensor.""" - return "emoncms{}_{}_{}_{}_{}".format( - sensorid, feedtag, feedname, feedid, feeduserid - ) + return f"emoncms{sensorid}_{feedtag}_{feedname}_{feedid}_{feeduserid}" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -134,7 +132,7 @@ def __init__( # ID if there's only one. id_for_name = "" if str(sensorid) == "1" else sensorid # Use the feed name assigned in EmonCMS or fall back to the feed ID - feed_name = elem.get("name") or "Feed {}".format(elem["id"]) + feed_name = elem.get("name") or f"Feed {elem['id']}" self._name = f"EmonCMS{id_for_name} {feed_name}" else: self._name = name @@ -246,7 +244,7 @@ def update(self): self.data = req.json() else: _LOGGER.error( - "Please verify if the specified config value " + "Please verify if the specified configuration value " "'%s' is correct! (HTTP Status_code = %d)", CONF_URL, req.status_code, diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index b054d69e7a4976..9a2d624a55ff74 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -351,8 +351,9 @@ async def put(self, request, username, entity_number): if HUE_API_STATE_BRI in request_json: if entity.domain == light.DOMAIN: - parsed[STATE_ON] = parsed[STATE_BRIGHTNESS] > 0 - if not entity_features & SUPPORT_BRIGHTNESS: + if entity_features & SUPPORT_BRIGHTNESS: + parsed[STATE_ON] = parsed[STATE_BRIGHTNESS] > 0 + else: parsed[STATE_BRIGHTNESS] = None elif entity.domain == scene.DOMAIN: @@ -616,16 +617,7 @@ def entity_to_json(config, entity): """Convert an entity to its Hue bridge JSON representation.""" entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest() - unique_id = "00:{}:{}:{}:{}:{}:{}:{}-{}".format( - unique_id[0:2], - unique_id[2:4], - unique_id[4:6], - unique_id[6:8], - unique_id[8:10], - unique_id[10:12], - unique_id[12:14], - unique_id[14:16], - ) + unique_id = f"00:{unique_id[0:2]}:{unique_id[2:4]}:{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" state = get_entity_state(config, entity) @@ -687,26 +679,25 @@ def entity_to_json(config, entity): retval["state"].update( {HUE_API_STATE_COLORMODE: "ct", HUE_API_STATE_CT: state[STATE_COLOR_TEMP]} ) - elif ( - entity_features - & ( - SUPPORT_BRIGHTNESS - | SUPPORT_SET_POSITION - | SUPPORT_SET_SPEED - | SUPPORT_VOLUME_SET - | SUPPORT_TARGET_TEMPERATURE - ) - ) or entity.domain == script.DOMAIN: + elif entity_features & ( + SUPPORT_BRIGHTNESS + | SUPPORT_SET_POSITION + | SUPPORT_SET_SPEED + | SUPPORT_VOLUME_SET + | SUPPORT_TARGET_TEMPERATURE + ): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" retval["modelid"] = "HASS123" retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) else: - # On/off light (Zigbee Device ID: 0x0000) - # Supports groups, scenes and on/off control - retval["type"] = "On/off light" - retval["modelid"] = "HASS321" + # Dimmable light (Zigbee Device ID: 0x0100) + # Supports groups, scenes, on/off and dimming + # Reports fixed brightness for compatibility with Alexa. + retval["type"] = "Dimmable light" + retval["modelid"] = "HASS123" + retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) return retval @@ -718,7 +709,7 @@ def create_hue_success_response(entity_id, attr, value): def create_list_of_entities(config, request): - """Create a list of all entites.""" + """Create a list of all entities.""" hass = request.app["hass"] json_response = {} diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index da9b4e23fe20ec..0ee336de670729 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -26,16 +26,16 @@ def __init__(self, config): @core.callback def get(self, request): """Handle a GET request.""" - xml_template = """ + resp_text = f""" 1 0 -http://{0}:{1}/ +http://{self.config.advertise_ip}:{self.config.advertise_port}/ urn:schemas-upnp-org:device:Basic:1 -Home Assistant Bridge ({0}) +Home Assistant Bridge ({self.config.advertise_ip}) Royal Philips Electronics http://www.philips.com Philips hue Personal Wireless Lighting @@ -48,10 +48,6 @@ def get(self, request): """ - resp_text = xml_template.format( - self.config.advertise_ip, self.config.advertise_port - ) - return web.Response(text=resp_text, content_type="text/xml") @@ -77,10 +73,10 @@ def __init__( # Note that the double newline at the end of # this string is required per the SSDP spec - resp_template = """HTTP/1.1 200 OK + resp_template = f"""HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: -LOCATION: http://{0}:{1}/description.xml +LOCATION: http://{advertise_ip}:{advertise_port}/description.xml SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1 hue-bridgeid: 1234 ST: urn:schemas-upnp-org:device:basic:1 @@ -88,11 +84,7 @@ def __init__( """ - self.upnp_response = ( - resp_template.format(advertise_ip, advertise_port) - .replace("\n", "\r\n") - .encode("utf-8") - ) + self.upnp_response = resp_template.replace("\n", "\r\n").encode("utf-8") def run(self): """Run the server.""" diff --git a/homeassistant/components/emulated_roku/.translations/pl.json b/homeassistant/components/emulated_roku/.translations/pl.json index 0ed3cc3d14af62..0dd32f66c9fce6 100644 --- a/homeassistant/components/emulated_roku/.translations/pl.json +++ b/homeassistant/components/emulated_roku/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "name_exists": "Nazwa ju\u017c istnieje" + "name_exists": "Nazwa ju\u017c istnieje." }, "step": { "user": { diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index a44effff55a0ed..1d233c9ed81309 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -109,7 +109,7 @@ def launch(self, roku_usn, app_id): ) LOGGER.debug( - "Intializing emulated_roku %s on %s:%s", + "Initializing emulated_roku %s on %s:%s", self.roku_usn, self.host_ip, self.listen_port, diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py index 0a6d54693ef9ec..3e363e060c226c 100644 --- a/homeassistant/components/emulated_roku/config_flow.py +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -38,7 +38,7 @@ async def async_step_user(self, user_input=None): servers_num = len(configured_servers(self.hass)) if servers_num: - default_name = "{} {}".format(DEFAULT_NAME, servers_num + 1) + default_name = f"{DEFAULT_NAME} {servers_num + 1}" default_port = DEFAULT_PORT + servers_num else: default_name = DEFAULT_NAME diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 59ca10da791e94..5cf908a33a1af0 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -111,9 +111,7 @@ def __init__(self, dev_id, dev_name, sensor_type): 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._dev_name = f"{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 diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 2ecae21824eeb5..0425accd06b78f 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -12,6 +12,7 @@ CONF_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP, + TIME_MINUTES, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -119,7 +120,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entities = [] for place in data.all_stop_places_quays(): try: - given_name = "{} {}".format(name, data.get_stop_info(place).name) + given_name = f"{name} {data.get_stop_info(place).name}" except KeyError: given_name = f"{name} {place}" @@ -183,7 +184,7 @@ def device_state_attributes(self) -> dict: @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self) -> str: @@ -230,9 +231,9 @@ async def async_update(self) -> None: self._attributes[ATTR_NEXT_UP_AT] = calls[1].expected_departure_time.strftime( "%H:%M" ) - self._attributes[ATTR_NEXT_UP_IN] = "{} min".format( - due_in_minutes(calls[1].expected_departure_time) - ) + self._attributes[ + ATTR_NEXT_UP_IN + ] = f"{due_in_minutes(calls[1].expected_departure_time)} min" self._attributes[ATTR_NEXT_UP_REALTIME] = calls[1].is_realtime self._attributes[ATTR_NEXT_UP_DELAY] = calls[1].delay_in_min @@ -241,8 +242,7 @@ async def async_update(self) -> None: for i, call in enumerate(calls[2:]): key_name = "departure_#" + str(i + 3) - self._attributes[key_name] = "{}{} {}".format( - "" if bool(call.is_realtime) else "ca. ", - call.expected_departure_time.strftime("%H:%M"), - call.front_display, + self._attributes[key_name] = ( + f"{'' if bool(call.is_realtime) else 'ca. '}" + f"{call.expected_departure_time.strftime('%H:%M')} {call.front_display}" ) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 4ef3e17fc4683e..d51b69f5713686 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,9 +1,4 @@ -""" -Support for the Environment Canada radar imagery. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.environment_canada/ -""" +"""Support for the Environment Canada radar imagery.""" import datetime import logging diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index a9d97dc62714ba..9b208c452e5df2 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.0.34"], + "requirements": ["env_canada==0.0.35"], "dependencies": [], "codeowners": ["@michaeldavie"] } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1568ba19d6b7b2..601a7f2ba3667b 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -1,9 +1,4 @@ -""" -Support for the Environment Canada weather service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.environment_canada/ -""" +"""Support for the Environment Canada weather service.""" from datetime import datetime, timedelta import logging import re @@ -28,7 +23,6 @@ ATTR_UPDATED = "updated" ATTR_STATION = "station" -ATTR_DETAIL = "alert detail" ATTR_TIME = "alert time" CONF_ATTRIBUTION = "Data provided by Environment Canada" @@ -119,7 +113,7 @@ def update(self): metadata = self.ec_data.metadata sensor_data = conditions.get(self.sensor_type) - self._unique_id = "{}-{}".format(metadata["location"], self.sensor_type) + self._unique_id = f"{metadata['location']}-{self.sensor_type}" self._attr = {} self._name = sensor_data.get("label") value = sensor_data.get("value") @@ -127,10 +121,7 @@ def update(self): if isinstance(value, list): self._state = " | ".join([str(s.get("title")) for s in value])[:255] self._attr.update( - { - ATTR_DETAIL: " | ".join([str(s.get("detail")) for s in value]), - ATTR_TIME: " | ".join([str(s.get("date")) for s in value]), - } + {ATTR_TIME: " | ".join([str(s.get("date")) for s in value])} ) elif self.sensor_type == "tendency": self._state = str(value).capitalize() diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 572543e39c4c19..10666b4a34e490 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -1,9 +1,4 @@ -""" -Platform for retrieving meteorological data from Environment Canada. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/weather.environmentcanada/ -""" +"""Platform for retrieving meteorological data from Environment Canada.""" import datetime import logging import re diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index 9394b5af543cb2..ebd201b55503bc 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "ESP jest ju\u017c skonfigurowane" + "already_configured": "ESP jest ju\u017c skonfigurowane." }, "error": { "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 'api:'.", diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json index 46790868aba611..4839167785d0e5 100644 --- a/homeassistant/components/esphome/.translations/zh-Hans.json +++ b/homeassistant/components/esphome/.translations/zh-Hans.json @@ -8,6 +8,7 @@ "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01", "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index cabba95ea7e48c..9fbe3eff8227a7 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -39,20 +39,11 @@ # Import config flow so that it's added to the registry from .config_flow import EsphomeFlowHandler # noqa: F401 -from .entry_data import ( - DATA_KEY, - DISPATCHER_ON_DEVICE_UPDATE, - DISPATCHER_ON_LIST, - DISPATCHER_ON_STATE, - DISPATCHER_REMOVE_ENTITY, - DISPATCHER_UPDATE_ENTITY, - RuntimeEntryData, -) +from .entry_data import DATA_KEY, RuntimeEntryData DOMAIN = "esphome" _LOGGER = logging.getLogger(__name__) -STORAGE_KEY = "esphome.{}" STORAGE_VERSION = 1 # No config schema - only configuration entry @@ -85,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool # Store client in per-config-entry hass.data store = Store( - hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id), encoder=JSONEncoder + hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder ) entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=store @@ -403,7 +394,7 @@ def async_list_entities(infos: List[EntityInfo]): # Add entities to Home Assistant async_add_entities(add_entities) - signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id) + signal = f"esphome_{entry.entry_id}_on_list" entry_data.cleanup_callbacks.append( async_dispatcher_connect(hass, signal, async_list_entities) ) @@ -416,7 +407,7 @@ def async_entity_state(state: EntityState): entry_data.state[component_key][state.key] = state entry_data.async_update_entity(hass, component_key, state.key) - signal = DISPATCHER_ON_STATE.format(entry_id=entry.entry_id) + signal = f"esphome_{entry.entry_id}_on_state" entry_data.cleanup_callbacks.append( async_dispatcher_connect(hass, signal, async_entity_state) ) @@ -490,21 +481,29 @@ async def async_added_to_hass(self) -> None: self._remove_callbacks.append( async_dispatcher_connect( self.hass, - DISPATCHER_UPDATE_ENTITY.format(**kwargs), + ( + f"esphome_{kwargs.get('entry_id')}" + f"_update_{kwargs.get('component_key')}_{kwargs.get('key')}" + ), self._on_state_update, ) ) self._remove_callbacks.append( async_dispatcher_connect( - self.hass, DISPATCHER_REMOVE_ENTITY.format(**kwargs), self.async_remove + self.hass, + ( + f"esphome_{kwargs.get('entry_id')}_remove_" + f"{kwargs.get('component_key')}_{kwargs.get('key')}" + ), + self.async_remove, ) ) self._remove_callbacks.append( async_dispatcher_connect( self.hass, - DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs), + f"esphome_{kwargs.get('entry_id')}_on_device_update", self._on_device_update, ) ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 53289799b439e1..17d3ed5f65983b 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -5,8 +5,8 @@ from aioesphomeapi import APIClient, APIConnectionError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.helpers import ConfigType +from homeassistant import config_entries, core +from homeassistant.helpers.typing import ConfigType from .entry_data import DATA_KEY, RuntimeEntryData @@ -115,6 +115,7 @@ async def async_step_zeroconf(self, user_input: ConfigType): return await self.async_step_discovery_confirm() + @core.callback def _async_get_entry(self): return self.async_create_entry( title=self._name, diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 48f1aea2c2ddd0..d8453c974f61b5 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -21,16 +21,12 @@ import attr from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType DATA_KEY = "esphome" -DISPATCHER_UPDATE_ENTITY = "esphome_{entry_id}_update_{component_key}_{key}" -DISPATCHER_REMOVE_ENTITY = "esphome_{entry_id}_remove_{component_key}_{key}" -DISPATCHER_ON_LIST = "esphome_{entry_id}_on_list" -DISPATCHER_ON_DEVICE_UPDATE = "esphome_{entry_id}_on_device_update" -DISPATCHER_ON_STATE = "esphome_{entry_id}_on_state" # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM = { @@ -71,22 +67,20 @@ class RuntimeEntryData: loaded_platforms = attr.ib(type=Set[str], factory=set) platform_load_lock = attr.ib(type=asyncio.Lock, factory=asyncio.Lock) + @callback def async_update_entity( self, hass: HomeAssistantType, component_key: str, key: int ) -> None: """Schedule the update of an entity.""" - signal = DISPATCHER_UPDATE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key - ) + signal = f"esphome_{self.entry_id}_update_{component_key}_{key}" async_dispatcher_send(hass, signal) + @callback def async_remove_entity( self, hass: HomeAssistantType, component_key: str, key: int ) -> None: """Schedule the removal of an entity.""" - signal = DISPATCHER_REMOVE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key - ) + signal = f"esphome_{self.entry_id}_remove_{component_key}_{key}" async_dispatcher_send(hass, signal) async def _ensure_platforms_loaded( @@ -117,17 +111,19 @@ async def async_update_static_infos( await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Then send dispatcher event - signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) + signal = f"esphome_{self.entry_id}_on_list" async_dispatcher_send(hass, signal, infos) + @callback def async_update_state(self, hass: HomeAssistantType, state: EntityState) -> None: """Distribute an update of state information to all platforms.""" - signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) + signal = f"esphome_{self.entry_id}_on_state" async_dispatcher_send(hass, signal, state) + @callback def async_update_device_state(self, hass: HomeAssistantType) -> None: """Distribute an update of a core device state like availability.""" - signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) + signal = f"esphome_{self.entry_id}_on_device_update" async_dispatcher_send(hass, signal) async def async_load_from_store(self) -> Tuple[List[EntityInfo], List[UserService]]: diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index e50991af6c1376..0856f27071035a 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -69,9 +69,7 @@ def state(self) -> Optional[str]: return None if self._state.missing_state: return None - return "{:.{prec}f}".format( - self._state.state, prec=self._static_info.accuracy_decimals - ) + return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property def unit_of_measurement(self) -> str: diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index b106d9d2ae660c..e3ce1ccaafa5e4 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -1,5 +1,6 @@ """Support for Essent API.""" from datetime import timedelta +from typing import Optional from pyessent import PyEssent import voluptuous as vol @@ -73,7 +74,7 @@ def retrieve_meter_data(self, meter): def update(self): """Retrieve the latest meter data from Essent.""" essent = PyEssent(self._username, self._password) - eans = essent.get_EANs() + eans = set(essent.get_EANs()) for possible_meter in eans: meter_data = essent.read_meter(possible_meter, only_last_meter_reading=True) if meter_data: @@ -92,6 +93,11 @@ def __init__(self, essent_base, meter, meter_type, tariff, unit): self._tariff = tariff self._unit = unit + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._meter}-{self._type}-{self._tariff}" + @property def name(self): """Return the name of the sensor.""" diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index f7fa9deffa07fd..da9d5b88ae0358 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -32,8 +32,6 @@ {vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string])} ) -NAME_FORMAT = "EverLights {} Zone {}" - def color_rgb_to_int(red: int, green: int, blue: int) -> int: """Return a RGB color as an integer.""" @@ -96,7 +94,7 @@ def available(self) -> bool: @property def name(self): """Return the name of the device.""" - return NAME_FORMAT.format(self._mac, self._channel) + return f"EverLights {self._mac} Zone {self._channel}" @property def is_on(self): diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 949471d64d044f..1a408c0a660ce6 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -148,7 +148,7 @@ def _handle_exception(err) -> bool: return False except aiohttp.ClientConnectionError: - # this appears to be a common occurance with the vendor's servers + # this appears to be a common occurrence with the vendor's servers _LOGGER.warning( "Unable to connect with the vendor's server. " "Check your network and the vendor's service status page. " @@ -184,7 +184,7 @@ async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: tokens = dict(app_storage if app_storage else {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: - # any tokens wont be valid, and store might be be corrupt + # any tokens won't be valid, and store might be be corrupt await store.async_save({}) return ({}, None) @@ -266,7 +266,7 @@ def setup_service_functions(hass: HomeAssistantType, broker): Not all Honeywell TCC-compatible systems support all operating modes. In addition, each mode will require any of four distinct service schemas. This has to be - enumerated before registering the approperiate handlers. + enumerated before registering the appropriate handlers. It appears that all TCC-compatible systems support the same three zones modes. """ diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b7f6e965a8f844..aece0f0ec0df50 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -175,7 +175,7 @@ async def async_zone_svc_request(self, service: dict, data: dict) -> None: if ATTR_DURATION_UNTIL in data: duration = data[ATTR_DURATION_UNTIL] - if duration == 0: + if duration.total_seconds() == 0: await self._update_schedule() until = parse_datetime(str(self.setpoints.get("next_sp_from"))) else: diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py new file mode 100644 index 00000000000000..96891e8b291e47 --- /dev/null +++ b/homeassistant/components/ezviz/__init__.py @@ -0,0 +1 @@ +"""Support for Ezviz devices via Ezviz Cloud API.""" diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py new file mode 100644 index 00000000000000..b8ede42a508aad --- /dev/null +++ b/homeassistant/components/ezviz/camera.py @@ -0,0 +1,237 @@ +"""This component provides basic support for Ezviz IP cameras.""" +import asyncio +import logging + +from haffmpeg.tools import IMAGE_JPEG, ImageFrame +from pyezviz.camera import EzvizCamera +from pyezviz.client import EzvizClient, PyEzvizError +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_CAMERAS = "cameras" + +DEFAULT_CAMERA_USERNAME = "admin" +DEFAULT_RTSP_PORT = "554" + +DATA_FFMPEG = "ffmpeg" + +EZVIZ_DATA = "ezviz" +ENTITIES = "entities" + +CAMERA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CAMERAS, default={}): {cv.string: CAMERA_SCHEMA}, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ezviz IP Cameras.""" + + conf_cameras = config[CONF_CAMERAS] + + account = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + try: + ezviz_client = EzvizClient(account, password) + ezviz_client.login() + cameras = ezviz_client.load_cameras() + + except PyEzvizError as exp: + _LOGGER.error(exp) + return + + # now, let's build the HASS devices + camera_entities = [] + + # Add the cameras as devices in HASS + for camera in cameras: + + camera_username = DEFAULT_CAMERA_USERNAME + camera_password = "" + camera_rtsp_stream = "" + camera_serial = camera["serial"] + + # There seem to be a bug related to localRtspPort in Ezviz API... + local_rtsp_port = DEFAULT_RTSP_PORT + if camera["local_rtsp_port"] and camera["local_rtsp_port"] != 0: + local_rtsp_port = camera["local_rtsp_port"] + + if camera_serial in conf_cameras: + camera_username = conf_cameras[camera_serial][CONF_USERNAME] + camera_password = conf_cameras[camera_serial][CONF_PASSWORD] + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{camera['local_ip']}:{local_rtsp_port}" + _LOGGER.debug( + "Camera %s source stream: %s", camera["serial"], camera_rtsp_stream + ) + + else: + _LOGGER.info( + "Found camera with serial %s without configuration. Add it to configuration.yaml to see the camera stream", + camera_serial, + ) + + camera["username"] = camera_username + camera["password"] = camera_password + camera["rtsp_stream"] = camera_rtsp_stream + + camera["ezviz_camera"] = EzvizCamera(ezviz_client, camera_serial) + + camera_entities.append(HassEzvizCamera(**camera)) + + add_entities(camera_entities) + + +class HassEzvizCamera(Camera): + """An implementation of a Foscam IP camera.""" + + def __init__(self, **data): + """Initialize an Ezviz camera.""" + super().__init__() + + self._username = data["username"] + self._password = data["password"] + self._rtsp_stream = data["rtsp_stream"] + + self._ezviz_camera = data["ezviz_camera"] + self._serial = data["serial"] + self._name = data["name"] + self._status = data["status"] + self._privacy = data["privacy"] + self._audio = data["audio"] + self._ir_led = data["ir_led"] + self._state_led = data["state_led"] + self._follow_move = data["follow_move"] + self._alarm_notify = data["alarm_notify"] + self._alarm_sound_mod = data["alarm_sound_mod"] + self._encrypted = data["encrypted"] + self._local_ip = data["local_ip"] + self._detection_sensibility = data["detection_sensibility"] + self._device_sub_category = data["device_sub_category"] + self._local_rtsp_port = data["local_rtsp_port"] + + self._ffmpeg = None + + def update(self): + """Update the camera states.""" + + data = self._ezviz_camera.status() + + self._name = data["name"] + self._status = data["status"] + self._privacy = data["privacy"] + self._audio = data["audio"] + self._ir_led = data["ir_led"] + self._state_led = data["state_led"] + self._follow_move = data["follow_move"] + self._alarm_notify = data["alarm_notify"] + self._alarm_sound_mod = data["alarm_sound_mod"] + self._encrypted = data["encrypted"] + self._local_ip = data["local_ip"] + self._detection_sensibility = data["detection_sensibility"] + self._device_sub_category = data["device_sub_category"] + self._local_rtsp_port = data["local_rtsp_port"] + + async def async_added_to_hass(self): + """Subscribe to ffmpeg and add camera to list.""" + self._ffmpeg = self.hass.data[DATA_FFMPEG] + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return True + + @property + def device_state_attributes(self): + """Return the Ezviz-specific camera state attributes.""" + return { + # if privacy == true, the device closed the lid or did a 180° tilt + "privacy": self._privacy, + # is the camera listening ? + "audio": self._audio, + # infrared led on ? + "ir_led": self._ir_led, + # state led on ? + "state_led": self._state_led, + # if true, the camera will move automatically to follow movements + "follow_move": self._follow_move, + # if true, if some movement is detected, the app is notified + "alarm_notify": self._alarm_notify, + # if true, if some movement is detected, the camera makes some sound + "alarm_sound_mod": self._alarm_sound_mod, + # are the camera's stored videos/images encrypted? + "encrypted": self._encrypted, + # camera's local ip on local network + "local_ip": self._local_ip, + # from 1 to 9, the higher is the sensibility, the more it will detect small movements + "detection_sensibility": self._detection_sensibility, + } + + @property + def available(self): + """Return True if entity is available.""" + return self._status + + @property + def brand(self): + """Return the camera brand.""" + return "Ezviz" + + @property + def supported_features(self): + """Return supported features.""" + if self._rtsp_stream: + return SUPPORT_STREAM + return 0 + + @property + def model(self): + """Return the camera model.""" + return self._device_sub_category + + @property + def is_on(self): + """Return true if on.""" + return self._status + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def async_camera_image(self): + """Return a frame from the camera stream.""" + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + + image = await asyncio.shield( + ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG,) + ) + return image + + async def stream_source(self): + """Return the stream source.""" + if self._local_rtsp_port: + rtsp_stream_source = "rtsp://{}:{}@{}:{}".format( + self._username, self._password, self._local_ip, self._local_rtsp_port + ) + _LOGGER.debug( + "Camera %s source stream: %s", self._serial, rtsp_stream_source + ) + self._rtsp_stream = rtsp_stream_source + return rtsp_stream_source + return None diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json new file mode 100644 index 00000000000000..167f063c0f700f --- /dev/null +++ b/homeassistant/components/ezviz/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ezviz", + "name": "Ezviz", + "documentation": "https://www.home-assistant.io/integrations/ezviz", + "dependencies": [], + "codeowners": ["@baqs"], + "requirements": ["pyezviz==0.1.5"] +} diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 692b48d9db5d80..6e47cb459668ae 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -1,10 +1,4 @@ -""" -Support for displaying IPs banned by fail2ban. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.fail2ban/ - -""" +"""Support for displaying IPs banned by fail2ban.""" from datetime import timedelta import logging import os diff --git a/homeassistant/components/fan/.translations/es-419.json b/homeassistant/components/fan/.translations/es-419.json new file mode 100644 index 00000000000000..dd0c006d760f8c --- /dev/null +++ b/homeassistant/components/fan/.translations/es-419.json @@ -0,0 +1,12 @@ +{ + "device_automation": { + "condition_type": { + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 encendido" + }, + "trigger_type": { + "turned_off": "{entity_name} se apag\u00f3", + "turned_on": "{entity_name} se encendi\u00f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/sv.json b/homeassistant/components/fan/.translations/sv.json new file mode 100644 index 00000000000000..c080d1b364b862 --- /dev/null +++ b/homeassistant/components/fan/.translations/sv.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "St\u00e4ng av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e4r avst\u00e4ngd", + "is_on": "{entity_name} \u00e4r p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} st\u00e4ngdes av", + "turned_on": "{entity_name} aktiverades" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/.translations/zh-Hant.json b/homeassistant/components/fan/.translations/zh-Hant.json index 78c0d991125e46..01da8652b2f0b1 100644 --- a/homeassistant/components/fan/.translations/zh-Hant.json +++ b/homeassistant/components/fan/.translations/zh-Hant.json @@ -1,16 +1,16 @@ { "device_automation": { "action_type": { - "turn_off": "\u95dc\u9589 {entity_name}", - "turn_on": "\u958b\u555f {entity_name}" + "turn_off": "\u95dc\u9589{entity_name}", + "turn_on": "\u958b\u555f{entity_name}" }, "condition_type": { - "is_off": "{entity_name} \u95dc\u9589", - "is_on": "{entity_name} \u958b\u555f" + "is_off": "{entity_name}\u95dc\u9589", + "is_on": "{entity_name}\u958b\u555f" }, "trigger_type": { - "turned_off": "{entity_name} \u5df2\u95dc\u9589", - "turned_on": "{entity_name} \u5df2\u958b\u555f" + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" } } } \ No newline at end of file diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 44b33af0c6e253..76bd16a6363446 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -111,25 +111,20 @@ def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" raise NotImplementedError() - def async_set_speed(self, speed: str): - """Set the speed of the fan. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_set_speed(self, speed: str): + """Set the speed of the fan.""" if speed is SPEED_OFF: - return self.async_turn_off() - return self.hass.async_add_job(self.set_speed, speed) + await self.async_turn_off() + else: + await self.hass.async_add_job(self.set_speed, speed) def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" raise NotImplementedError() - def async_set_direction(self, direction: str): - """Set the direction of the fan. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_direction, direction) + async def async_set_direction(self, direction: str): + """Set the direction of the fan.""" + await self.hass.async_add_job(self.set_direction, direction) # pylint: disable=arguments-differ def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: @@ -137,25 +132,20 @@ def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: raise NotImplementedError() # pylint: disable=arguments-differ - def async_turn_on(self, speed: Optional[str] = None, **kwargs): - """Turn on the fan. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_turn_on(self, speed: Optional[str] = None, **kwargs): + """Turn on the fan.""" if speed is SPEED_OFF: - return self.async_turn_off() - return self.hass.async_add_job(ft.partial(self.turn_on, speed, **kwargs)) + await self.async_turn_off() + else: + await self.hass.async_add_job(ft.partial(self.turn_on, speed, **kwargs)) def oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" pass - def async_oscillate(self, oscillating: bool): - """Oscillate the fan. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.oscillate, oscillating) + async def async_oscillate(self, oscillating: bool): + """Oscillate the fan.""" + await self.hass.async_add_job(self.oscillate, oscillating) @property def is_on(self): @@ -179,7 +169,7 @@ def current_direction(self) -> Optional[str]: @property def capability_attributes(self): - """Return capabilitiy attributes.""" + """Return capability attributes.""" return {ATTR_SPEED_LIST: self.speed_list} @property diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index c69f28c10e9f2f..d3a8aa5c395485 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -13,7 +13,7 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -64,6 +64,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: @@ -75,6 +76,7 @@ def async_condition_from_config( else: state = STATE_OFF + @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" return condition.state(hass, config[ATTR_ENTITY_ID], state) diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index ee478950095b2d..1fbd9089ed7f71 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -48,7 +48,7 @@ set_direction: description: Set the fan rotation. fields: entity_id: - description: Name(s) of the entities to toggle + description: Name(s) of the entities to set example: 'fan.living_room' direction: description: The direction to rotate. Either 'forward' or 'reverse' diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 6d9445ce159370..a6eaa21ae35ece 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,6 +1,7 @@ """Support for Fast.com internet speed testing sensor.""" import logging +from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -11,8 +12,6 @@ ICON = "mdi:speedometer" -UNIT_OF_MEASUREMENT = "Mbit/s" - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Fast.com sensor.""" @@ -41,7 +40,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return UNIT_OF_MEASUREMENT + return DATA_RATE_MEGABITS_PER_SECOND @property def icon(self): diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 52ecb8812055fb..89529046f85a38 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -247,8 +247,8 @@ def _read_scenes(self): room_name = self._room_map[device.roomID].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" - device.ha_id = "scene_{}_{}_{}".format( - slugify(room_name), slugify(device.name), device.id + device.ha_id = ( + f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.id}" ) device.unique_id_str = f"{self.hub_serial}.scene.{device.id}" self._scene_map[device.id] = device @@ -269,8 +269,8 @@ def _read_devices(self): room_name = self._room_map[device.roomID].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" - device.ha_id = "{}_{}_{}".format( - slugify(room_name), slugify(device.name), device.id + device.ha_id = ( + f"{slugify(room_name)}_{slugify(device.name)}_{device.id}" ) if ( device.enabled diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index af2c2a9401a188..fa2d6ceb3c6624 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Fibaro binary sensors.""" import logging -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON from . import FIBARO_DEVICES, FibaroDevice @@ -40,7 +40,7 @@ def __init__(self, fibaro_device): """Initialize the binary_sensor.""" self._state = None super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" stype = None devconf = fibaro_device.device_config if fibaro_device.type in SENSOR_TYPES: diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index fe9c0990fa8880..d2f8094f26dae1 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -4,7 +4,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - ENTITY_ID_FORMAT, + DOMAIN, CoverDevice, ) @@ -29,7 +29,7 @@ class FibaroCover(FibaroDevice, CoverDevice): def __init__(self, fibaro_device): """Initialize the Vera device.""" super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" @staticmethod def bound(position): diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index ba77942a44819d..d14d9a195d94c1 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -7,7 +7,7 @@ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, - ENTITY_ID_FORMAT, + DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, @@ -68,7 +68,7 @@ def __init__(self, fibaro_device): supports_dimming = "levelChange" in fibaro_device.interfaces supports_white_v = "setW" in fibaro_device.actions - # Configuration can overrride default capability detection + # Configuration can override default capability detection if devconf.get(CONF_DIMMING, supports_dimming): self._supported_flags |= SUPPORT_BRIGHTNESS if devconf.get(CONF_COLOR, supports_color): @@ -77,7 +77,7 @@ def __init__(self, fibaro_device): self._supported_flags |= SUPPORT_WHITE_VALUE super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" @property def brightness(self): diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 1e0bae212f8f24..5fce0da7a2b58c 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -1,8 +1,9 @@ """Support for Fibaro sensors.""" import logging -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import DOMAIN from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, @@ -20,8 +21,13 @@ None, DEVICE_CLASS_TEMPERATURE, ], - "com.fibaro.smokeSensor": ["Smoke", "ppm", "mdi:fire", None], - "CO2": ["CO2", "ppm", "mdi:cloud", None], + "com.fibaro.smokeSensor": [ + "Smoke", + CONCENTRATION_PARTS_PER_MILLION, + "mdi:fire", + None, + ], + "CO2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:cloud", None], "com.fibaro.humiditySensor": ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], "com.fibaro.lightSensor": ["Light", "lx", None, DEVICE_CLASS_ILLUMINANCE], } @@ -47,7 +53,7 @@ def __init__(self, fibaro_device): self.current_value = None self.last_changed_time = None super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" if fibaro_device.type in SENSOR_TYPES: self._unit = SENSOR_TYPES[fibaro_device.type][1] self._icon = SENSOR_TYPES[fibaro_device.type][2] diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 4bb8c34d57953f..b00e5817c9e2ff 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,7 +1,7 @@ """Support for Fibaro switches.""" import logging -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice @@ -26,7 +26,7 @@ def __init__(self, fibaro_device): """Initialize the Fibaro device.""" self._state = False super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" def turn_on(self, **kwargs): """Turn device on.""" diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 086ae87a529996..951d13dadb4df1 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -3,9 +3,6 @@ Get data from 'Usage Summary' page: https://www.fido.ca/pages/#/my-account/wireless - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.fido/ """ from datetime import timedelta import logging @@ -20,6 +17,8 @@ CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + DATA_KILOBITS, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -27,10 +26,8 @@ _LOGGER = logging.getLogger(__name__) -KILOBITS = "Kb" PRICE = "CAD" MESSAGES = "messages" -MINUTES = "minutes" DEFAULT_NAME = "Fido" @@ -40,9 +37,9 @@ SENSOR_TYPES = { "fido_dollar": ["Fido dollar", PRICE, "mdi:square-inc-cash"], "balance": ["Balance", PRICE, "mdi:square-inc-cash"], - "data_used": ["Data used", KILOBITS, "mdi:download"], - "data_limit": ["Data limit", KILOBITS, "mdi:download"], - "data_remaining": ["Data remaining", KILOBITS, "mdi:download"], + "data_used": ["Data used", DATA_KILOBITS, "mdi:download"], + "data_limit": ["Data limit", DATA_KILOBITS, "mdi:download"], + "data_remaining": ["Data remaining", DATA_KILOBITS, "mdi:download"], "text_used": ["Text used", MESSAGES, "mdi:message-text"], "text_limit": ["Text limit", MESSAGES, "mdi:message-text"], "text_remaining": ["Text remaining", MESSAGES, "mdi:message-text"], @@ -52,12 +49,12 @@ "text_int_used": ["International text used", MESSAGES, "mdi:message-alert"], "text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"], "text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"], - "talk_used": ["Talk used", MINUTES, "mdi:cellphone"], - "talk_limit": ["Talk limit", MINUTES, "mdi:cellphone"], - "talk_remaining": ["Talk remaining", MINUTES, "mdi:cellphone"], - "other_talk_used": ["Other Talk used", MINUTES, "mdi:cellphone"], - "other_talk_limit": ["Other Talk limit", MINUTES, "mdi:cellphone"], - "other_talk_remaining": ["Other Talk remaining", MINUTES, "mdi:cellphone"], + "talk_used": ["Talk used", TIME_MINUTES, "mdi:cellphone"], + "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"], + "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"], + "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"], + "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"], + "other_talk_remaining": ["Other Talk remaining", TIME_MINUTES, "mdi:cellphone"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 4cd83e64a83b5d..528d44bbb838b0 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -46,15 +46,11 @@ def send_message(self, message="", **kwargs): """Send a message to a file.""" with open(self.filepath, "a") as file: if os.stat(self.filepath).st_size == 0: - title = "{} notifications (Log started: {})\n{}\n".format( - kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - dt_util.utcnow().isoformat(), - "-" * 80, - ) + title = f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" file.write(title) if self.add_timestamp: - text = "{} {}\n".format(dt_util.utcnow().isoformat(), message) + text = f"{dt_util.utcnow().isoformat()} {message}\n" else: text = f"{message}\n" file.write(text) diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 8c6cd30b1186fa..3d96aab04e95a7 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import DATA_MEGABYTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -42,7 +43,7 @@ def __init__(self, path): self._size = None self._last_updated = None self._name = path.split("/")[-1] - self._unit_of_measurement = "MB" + self._unit_of_measurement = DATA_MEGABYTES def update(self): """Update the sensor.""" diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index baa4f90af3f8db..77622f62b1d820 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -377,7 +377,7 @@ def name(self): @property def skip_processing(self): - """Return wether the current filter_state should be skipped.""" + """Return whether the current filter_state should be skipped.""" return self._skip_processing def _filter_state(self, new_state): diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 5ddb63ef899a3f..b6a4fe550c93d4 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -11,7 +11,14 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_UNIT_SYSTEM +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_UNIT_SYSTEM, + MASS_KILOGRAMS, + MASS_MILLIGRAMS, + TIME_MILLISECONDS, + TIME_MINUTES, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -48,14 +55,14 @@ "activities/elevation": ["Elevation", "", "walk"], "activities/floors": ["Floors", "floors", "walk"], "activities/heart": ["Resting Heart Rate", "bpm", "heart-pulse"], - "activities/minutesFairlyActive": ["Minutes Fairly Active", "minutes", "walk"], - "activities/minutesLightlyActive": ["Minutes Lightly Active", "minutes", "walk"], + "activities/minutesFairlyActive": ["Minutes Fairly Active", TIME_MINUTES, "walk"], + "activities/minutesLightlyActive": ["Minutes Lightly Active", TIME_MINUTES, "walk"], "activities/minutesSedentary": [ "Minutes Sedentary", - "minutes", + TIME_MINUTES, "seat-recline-normal", ], - "activities/minutesVeryActive": ["Minutes Very Active", "minutes", "run"], + "activities/minutesVeryActive": ["Minutes Very Active", TIME_MINUTES, "run"], "activities/steps": ["Steps", "steps", "walk"], "activities/tracker/activityCalories": ["Tracker Activity Calories", "cal", "fire"], "activities/tracker/calories": ["Tracker Calories", "cal", "fire"], @@ -64,22 +71,22 @@ "activities/tracker/floors": ["Tracker Floors", "floors", "walk"], "activities/tracker/minutesFairlyActive": [ "Tracker Minutes Fairly Active", - "minutes", + TIME_MINUTES, "walk", ], "activities/tracker/minutesLightlyActive": [ "Tracker Minutes Lightly Active", - "minutes", + TIME_MINUTES, "walk", ], "activities/tracker/minutesSedentary": [ "Tracker Minutes Sedentary", - "minutes", + TIME_MINUTES, "seat-recline-normal", ], "activities/tracker/minutesVeryActive": [ "Tracker Minutes Very Active", - "minutes", + TIME_MINUTES, "run", ], "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], @@ -89,28 +96,32 @@ "devices/battery": ["Battery", None, None], "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], "sleep/efficiency": ["Sleep Efficiency", "%", "sleep"], - "sleep/minutesAfterWakeup": ["Minutes After Wakeup", "minutes", "sleep"], - "sleep/minutesAsleep": ["Sleep Minutes Asleep", "minutes", "sleep"], - "sleep/minutesAwake": ["Sleep Minutes Awake", "minutes", "sleep"], - "sleep/minutesToFallAsleep": ["Sleep Minutes to Fall Asleep", "minutes", "sleep"], + "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"], + "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"], + "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"], + "sleep/minutesToFallAsleep": [ + "Sleep Minutes to Fall Asleep", + TIME_MINUTES, + "sleep", + ], "sleep/startTime": ["Sleep Start Time", None, "clock"], - "sleep/timeInBed": ["Sleep Time in Bed", "minutes", "hotel"], + "sleep/timeInBed": ["Sleep Time in Bed", TIME_MINUTES, "hotel"], } FITBIT_MEASUREMENTS = { "en_US": { - "duration": "ms", + "duration": TIME_MILLISECONDS, "distance": "mi", "elevation": "ft", "height": "in", "weight": "lbs", "body": "in", "liquids": "fl. oz.", - "blood glucose": "mg/dL", + "blood glucose": f"{MASS_MILLIGRAMS}/dL", "battery": "", }, "en_GB": { - "duration": "milliseconds", + "duration": TIME_MILLISECONDS, "distance": "kilometers", "elevation": "meters", "height": "centimeters", @@ -121,11 +132,11 @@ "battery": "", }, "metric": { - "duration": "milliseconds", + "duration": TIME_MILLISECONDS, "distance": "kilometers", "elevation": "meters", "height": "centimeters", - "weight": "kilograms", + "weight": MASS_KILOGRAMS, "body": "centimeters", "liquids": "milliliters", "blood glucose": "mmol/L", @@ -170,16 +181,14 @@ def fitbit_configuration_callback(callback_data): start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" - description = """Please create a Fitbit developer app at + description = f"""Please create a Fitbit developer app at https://dev.fitbit.com/apps/new. For the OAuth 2.0 Application Type choose Personal. - Set the Callback URL to {}. + Set the Callback URL to {start_url}. They will provide you a Client ID and secret. - These need to be saved into the file located at: {}. + These need to be saved into the file located at: {config_path}. Then come back here and hit the below button. - """.format( - start_url, config_path - ) + """ submit = "I have saved my Client ID and Client Secret into fitbit.conf." @@ -297,9 +306,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET) ) - redirect_uri = "{}{}".format( - hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH - ) + redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" fitbit_auth_start_url, _ = oauth.authorize_token_url( redirect_uri=redirect_uri, @@ -344,26 +351,20 @@ def get(self, request): result = None if data.get("code") is not None: - redirect_uri = "{}{}".format( - hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH - ) + redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" try: result = self.oauth.fetch_access_token(data.get("code"), redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) - response_message = """Something went wrong when + response_message = f"""Something went wrong when attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format( - error - ) + encountered was {error}. Please try again!""" except MismatchingStateError as error: _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = """Something went wrong when + response_message = f"""Something went wrong when attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format( - error - ) + encountered was {error}. Please try again!""" else: _LOGGER.error("Unknown error when authing") response_message = """Something went wrong when @@ -378,10 +379,8 @@ def get(self, request): An unknown error occurred. Please try again! """ - html_response = """Fitbit Auth -

{}

""".format( - response_message - ) + html_response = f"""Fitbit Auth +

{response_message}

""" if result: config_contents = { @@ -413,7 +412,7 @@ def __init__( self.extra = extra self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] if self.extra: - self._name = "{0} Battery".format(self.extra.get("deviceVersion")) + self._name = f"{self.extra.get('deviceVersion')} Battery" unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] if unit_type == "": split_resource = self.resource_type.split("/") @@ -449,7 +448,7 @@ def icon(self): if self.resource_type == "devices/battery" and self.extra: battery_level = BATTERY_LEVELS[self.extra.get("battery")] return icon_for_battery_level(battery_level=battery_level, charging=None) - return "mdi:{}".format(FITBIT_RESOURCES_LIST[self.resource_type][2]) + return f"mdi:{FITBIT_RESOURCES_LIST[self.resource_type][2]}" @property def device_state_attributes(self): @@ -502,7 +501,7 @@ def update(self): self._state = raw_state else: try: - self._state = "{0:,}".format(int(raw_state)) + self._state = f"{int(raw_state):,}" except TypeError: self._state = raw_state diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 34ddd9a8ffa6ed..8720f67f39698b 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -1,16 +1,4 @@ -""" -Platform for Flexit AC units with CI66 Modbus adapter. - -Example configuration: - -climate: - - platform: flexit - name: Main AC - slave: 21 - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/climate.flexit/ -""" +"""Platform for Flexit AC units with CI66 Modbus adapter.""" import logging from typing import List diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index 4f2f229977fc03..55f92e2e5ceabd 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -168,7 +168,7 @@ def _create_channel(self): @property def name(self): """Return the name of the device.""" - return "flic_{}".format(self.address.replace(":", "")) + return f"flic_{self.address.replace(':', '')}" @property def address(self): @@ -192,9 +192,7 @@ def device_state_attributes(self): def _queued_event_check(self, click_type, time_diff): """Generate a log message and returns true if timeout exceeded.""" - time_string = "{:d} {}".format( - time_diff, "second" if time_diff == 1 else "seconds" - ) + time_string = f"{time_diff:d} {'second' if time_diff == 1 else 'seconds'}" if time_diff > self._timeout: _LOGGER.warning( diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index a71601ea2c4786..107c837970d239 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -16,7 +16,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_ACCESS_TOKEN): cv.string}) -async def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the Flock notification service.""" access_token = config.get(CONF_ACCESS_TOKEN) url = f"{_RESOURCE}{access_token}" diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index f22b633591123e..0205bb308be60b 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -2,9 +2,6 @@ Flux for Home-Assistant. The idea was taken from https://github.com/KpaBap/hue-flux/ - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/switch.flux/ """ import datetime import logging @@ -167,7 +164,7 @@ async def async_update(call=None): """Update lights.""" await flux.async_flux_update() - service_name = slugify("{} {}".format(name, "update")) + service_name = slugify(f"{name} update") hass.services.async_register(DOMAIN, service_name, async_update) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 16db60abbc0188..88b8c91420d7d7 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -167,7 +167,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ipaddr = device["ipaddr"] if ipaddr in light_ips: continue - device["name"] = "{} {}".format(device["id"], ipaddr) + device["name"] = f"{device['id']} {ipaddr}" device[ATTR_MODE] = None device[CONF_PROTOCOL] = None device[CONF_CUSTOM_EFFECT] = None diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index a706ab2a0b5cf4..19a5791d7cb5be 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import DATA_MEGABYTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -63,7 +64,7 @@ def __init__(self, folder_path, filter_term): self._number_of_files = None self._size = None self._name = os.path.split(os.path.split(folder_path)[0])[1] - self._unit_of_measurement = "MB" + self._unit_of_measurement = DATA_MEGABYTES self._file_list = None def update(self): diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index efb74e2cc9a1ab..80d17b4f23bd79 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -10,9 +10,13 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONF_TOKEN, CONF_USERNAME, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -30,12 +34,20 @@ ATTR_FOOBOT_INDEX = "index" SENSOR_TYPES = { - "time": [ATTR_TIME, "s"], - "pm": [ATTR_PM2_5, "µg/m3", "mdi:cloud"], + "time": [ATTR_TIME, TIME_SECONDS], + "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"], "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], "hum": [ATTR_HUMIDITY, "%", "mdi:water-percent"], - "co2": [ATTR_CARBON_DIOXIDE, "ppm", "mdi:periodic-table-co2"], - "voc": [ATTR_VOLATILE_ORGANIC_COMPOUNDS, "ppb", "mdi:cloud"], + "co2": [ + ATTR_CARBON_DIOXIDE, + CONCENTRATION_PARTS_PER_MILLION, + "mdi:periodic-table-co2", + ], + "voc": [ + ATTR_VOLATILE_ORGANIC_COMPOUNDS, + CONCENTRATION_PARTS_PER_BILLION, + "mdi:cloud", + ], "allpollu": [ATTR_FOOBOT_INDEX, "%", "mdi:percent"], } @@ -89,7 +101,7 @@ def __init__(self, data, device, sensor_type): """Initialize the sensor.""" self._uuid = device["uuid"] self.foobot_data = data - self._name = "Foobot {} {}".format(device["name"], SENSOR_TYPES[sensor_type][0]) + self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}" self.type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index f4ec655689475b..1c4c6bb9c8cfb1 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -190,12 +190,7 @@ def supported_features(self): async def stream_source(self): """Return the stream source.""" if self._rtsp_port: - return "rtsp://{}:{}@{}:{}/videoMain".format( - self._username, - self._password, - self._foscam_session.host, - self._rtsp_port, - ) + return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/videoMain" return None @property diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index af15c4e5fa8454..07d177ebf30f28 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -52,12 +52,7 @@ def setup(hass, config): def checkin_user(call): """Check a user in on Swarm.""" - url = ( - "https://api.foursquare.com/v2/checkins/add" - "?oauth_token={}" - "&v=20160802" - "&m=swarm" - ).format(config[CONF_ACCESS_TOKEN]) + url = f"https://api.foursquare.com/v2/checkins/add?oauth_token={config[CONF_ACCESS_TOKEN]}&v=20160802&m=swarm" response = requests.post(url, data=call.data, timeout=10) if response.status_code not in (200, 201): diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 5a29a619a3345b..7a66490c90d411 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "requirements": ["aiofreepybox==0.0.8"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": ["@snoof85"] } diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 61ec670d217ef8..0653120b49c745 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -1,6 +1,7 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" import logging +from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND from homeassistant.helpers.entity import Entity from . import DATA_FREEBOX @@ -56,7 +57,7 @@ class FbxRXSensor(FbxSensor): """Update the Freebox RxSensor.""" _name = "Freebox download speed" - _unit = "KB/s" + _unit = DATA_RATE_KILOBYTES_PER_SECOND _icon = "mdi:download-network" async def async_update(self): @@ -70,7 +71,7 @@ class FbxTXSensor(FbxSensor): """Update the Freebox TxSensor.""" _name = "Freebox upload speed" - _unit = "KB/s" + _unit = DATA_RATE_KILOBYTES_PER_SECOND _icon = "mdi:upload-network" async def async_update(self): diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index b6655c9634f631..062d6a699feb93 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -18,7 +18,7 @@ class FbxWifiSwitch(SwitchDevice): """Representation of a freebox wifi switch.""" def __init__(self, fbx): - """Initilize the Wifi switch.""" + """Initialize the Wifi switch.""" self._name = "Freebox WiFi" self._state = None self._fbx = fbx diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 21b86e26af18a4..5536e8fada30f4 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -1,6 +1,6 @@ { "domain": "fritz", - "name": "AVM Fritzbox", + "name": "AVM FRITZ!Box", "documentation": "https://www.home-assistant.io/integrations/fritz", "requirements": ["fritzconnection==1.2.0"], "dependencies": [], diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index c51c952ab06aba..5b87d6e726a199 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -78,9 +78,9 @@ def device_state_attributes(self): attrs[ATTR_STATE_LOCKED] = self._device.lock if self._device.has_powermeter: - attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( - (self._device.energy or 0.0) / 1000 - ) + attrs[ + ATTR_TOTAL_CONSUMPTION + ] = f"{((self._device.energy or 0.0) / 1000):.3f}" attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = ATTR_TOTAL_CONSUMPTION_UNIT_VALUE if self._device.has_temperature_sensor: attrs[ATTR_TEMPERATURE] = str( diff --git a/homeassistant/components/fritzdect/__init__.py b/homeassistant/components/fritzdect/__init__.py deleted file mode 100644 index d64990bc3f0c62..00000000000000 --- a/homeassistant/components/fritzdect/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The fritzdect component.""" diff --git a/homeassistant/components/fritzdect/manifest.json b/homeassistant/components/fritzdect/manifest.json deleted file mode 100644 index 9fc9129360876e..00000000000000 --- a/homeassistant/components/fritzdect/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "fritzdect", - "name": "AVM FRITZ!DECT", - "documentation": "https://www.home-assistant.io/integrations/fritzdect", - "requirements": ["fritzhome==1.0.4"], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/fritzdect/switch.py b/homeassistant/components/fritzdect/switch.py deleted file mode 100644 index f67c84ae5525c1..00000000000000 --- a/homeassistant/components/fritzdect/switch.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Support for FRITZ!DECT Switches.""" -import logging - -from fritzhome.fritz import FritzBox -from requests.exceptions import HTTPError, RequestException -import voluptuous as vol - -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_PASSWORD, - CONF_USERNAME, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, - TEMP_CELSIUS, -) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -# Standard Fritz Box IP -DEFAULT_HOST = "fritz.box" - -ATTR_CURRENT_CONSUMPTION = "current_consumption" -ATTR_CURRENT_CONSUMPTION_UNIT = "current_consumption_unit" -ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = POWER_WATT - -ATTR_TOTAL_CONSUMPTION = "total_consumption" -ATTR_TOTAL_CONSUMPTION_UNIT = "total_consumption_unit" -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR - -ATTR_TEMPERATURE_UNIT = "temperature_unit" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Add all switches connected to Fritz Box.""" - - host = config.get(CONF_HOST) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - # Log into Fritz Box - fritz = FritzBox(host, username, password) - try: - fritz.login() - except Exception: # pylint: disable=broad-except - _LOGGER.error("Login to Fritz!Box failed") - return - - # Add all actors to hass - for actor in fritz.get_actors(): - # Only add devices that support switching - if actor.has_switch: - data = FritzDectSwitchData(fritz, actor.actor_id) - data.is_online = True - add_entities([FritzDectSwitch(hass, data, actor.name)], True) - - -class FritzDectSwitch(SwitchDevice): - """Representation of a FRITZ!DECT switch.""" - - def __init__(self, hass, data, name): - """Initialize the switch.""" - self.units = hass.config.units - self.data = data - self._name = name - - @property - def name(self): - """Return the name of the FRITZ!DECT switch, if any.""" - return self._name - - @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - attrs = {} - - if ( - self.data.has_powermeter - and self.data.current_consumption is not None - and self.data.total_consumption is not None - ): - attrs[ATTR_CURRENT_CONSUMPTION] = "{:.1f}".format( - self.data.current_consumption - ) - attrs[ATTR_CURRENT_CONSUMPTION_UNIT] = "{}".format( - ATTR_CURRENT_CONSUMPTION_UNIT_VALUE - ) - attrs[ATTR_TOTAL_CONSUMPTION] = f"{self.data.total_consumption:.3f}" - attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = "{}".format( - ATTR_TOTAL_CONSUMPTION_UNIT_VALUE - ) - - if self.data.has_temperature and self.data.temperature is not None: - attrs[ATTR_TEMPERATURE] = "{}".format( - self.units.temperature(self.data.temperature, TEMP_CELSIUS) - ) - attrs[ATTR_TEMPERATURE_UNIT] = f"{self.units.temperature_unit}" - return attrs - - @property - def current_power_w(self): - """Return the current power usage in Watt.""" - try: - return float(self.data.current_consumption) - except ValueError: - return None - - @property - def is_on(self): - """Return true if switch is on.""" - return self.data.state - - def turn_on(self, **kwargs): - """Turn the switch on.""" - if not self.data.is_online: - _LOGGER.error("turn_on: Not online skipping request") - return - - try: - actor = self.data.fritz.get_actor_by_ain(self.data.ain) - actor.switch_on() - except (RequestException, HTTPError): - _LOGGER.error("Fritz!Box query failed, triggering relogin") - self.data.is_online = False - - def turn_off(self, **kwargs): - """Turn the switch off.""" - if not self.data.is_online: - _LOGGER.error("turn_off: Not online skipping request") - return - - try: - actor = self.data.fritz.get_actor_by_ain(self.data.ain) - actor.switch_off() - except (RequestException, HTTPError): - _LOGGER.error("Fritz!Box query failed, triggering relogin") - self.data.is_online = False - - def update(self): - """Get the latest data from the fritz box and updates the states.""" - if not self.data.is_online: - _LOGGER.error("update: Not online, logging back in") - - try: - self.data.fritz.login() - except Exception: # pylint: disable=broad-except - _LOGGER.error("Login to Fritz!Box failed") - return - - self.data.is_online = True - - try: - self.data.update() - except Exception: # pylint: disable=broad-except - _LOGGER.error("Fritz!Box query failed, triggering relogin") - self.data.is_online = False - - -class FritzDectSwitchData: - """Get the latest data from the fritz box.""" - - def __init__(self, fritz, ain): - """Initialize the data object.""" - self.fritz = fritz - self.ain = ain - self.state = None - self.temperature = None - self.current_consumption = None - self.total_consumption = None - self.has_switch = False - self.has_temperature = False - self.has_powermeter = False - self.is_online = False - - def update(self): - """Get the latest data from the fritz box.""" - if not self.is_online: - _LOGGER.error("Not online skipping request") - return - - try: - actor = self.fritz.get_actor_by_ain(self.ain) - except (RequestException, HTTPError): - _LOGGER.error("Request to actor registry failed") - self.state = None - self.temperature = None - self.current_consumption = None - self.total_consumption = None - raise Exception("Request to actor registry failed") - - if actor is None: - _LOGGER.error("Actor could not be found") - self.state = None - self.temperature = None - self.current_consumption = None - self.total_consumption = None - raise Exception("Actor could not be found") - - try: - self.state = actor.get_state() - self.current_consumption = (actor.get_power() or 0.0) / 1000 - self.total_consumption = (actor.get_energy() or 0.0) / 1000 - except (RequestException, HTTPError): - _LOGGER.error("Request to actor failed") - self.state = None - self.temperature = None - self.current_consumption = None - self.total_consumption = None - raise Exception("Request to actor failed") - - self.temperature = actor.temperature - self.has_switch = actor.has_switch - self.has_temperature = actor.has_temperature - self.has_powermeter = actor.has_powermeter diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 27e2531c9f911b..722dc2dc65909e 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -90,11 +90,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device = condition[CONF_DEVICE] sensor_type = condition[CONF_SENSOR_TYPE] scope = condition[CONF_SCOPE] - name = "Fronius {} {} {}".format( - condition[CONF_SENSOR_TYPE].replace("_", " ").capitalize(), - device if scope == SCOPE_DEVICE else SCOPE_SYSTEM, - config[CONF_RESOURCE], - ) + name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" if sensor_type == TYPE_INVERTER: if scope == SCOPE_SYSTEM: adapter_cls = FroniusInverterSystem @@ -258,9 +254,7 @@ def __init__(self, parent: FroniusAdapter, name): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format( - self._name.replace("_", " ").capitalize(), self.parent.name - ) + return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}" @property def state(self): diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8039b9947e75ed..5864c642fa9cb7 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config import async_hass_config_yaml from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback +from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass @@ -49,8 +50,8 @@ "display": "standalone", "icons": [ { - "src": "/static/icons/favicon-{size}x{size}.png".format(size=size), - "sizes": "{size}x{size}".format(size=size), + "src": f"/static/icons/favicon-{size}x{size}.png", + "sizes": f"{size}x{size}", "type": "image/png", "purpose": "maskable any", } @@ -61,6 +62,10 @@ "short_name": "Assistant", "start_url": "/?homescreen=1", "theme_color": DEFAULT_THEME_COLOR, + "prefer_related_applications": True, + "related_applications": [ + {"platform": "play", "id": "io.homeassistant.companion.android"} + ], } DATA_PANELS = "frontend_panels" @@ -103,19 +108,6 @@ SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" -SERVICE_SET_THEME_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) -WS_TYPE_GET_PANELS = "get_panels" -SCHEMA_GET_PANELS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_GET_PANELS} -) -WS_TYPE_GET_THEMES = "frontend/get_themes" -SCHEMA_GET_THEMES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_GET_THEMES} -) -WS_TYPE_GET_TRANSLATIONS = "frontend/get_translations" -SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_GET_TRANSLATIONS, vol.Required("language"): str} -) class Panel: @@ -193,7 +185,7 @@ def async_register_built_in_panel( panels = hass.data.setdefault(DATA_PANELS, {}) if panel.frontend_url_path in panels: - _LOGGER.warning("Overwriting integration %s", panel.frontend_url_path) + raise ValueError(f"Overwriting panel {panel.frontend_url_path}") panels[panel.frontend_url_path] = panel @@ -251,15 +243,9 @@ def _frontend_root(dev_repo_path): async def async_setup(hass, config): """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_PANELS, websocket_get_panels, SCHEMA_GET_PANELS - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_THEMES, websocket_get_themes, SCHEMA_GET_THEMES - ) - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_TRANSLATIONS, websocket_get_translations, SCHEMA_GET_TRANSLATIONS - ) + hass.components.websocket_api.async_register_command(websocket_get_panels) + hass.components.websocket_api.async_register_command(websocket_get_themes) + hass.components.websocket_api.async_register_command(websocket_get_translations) hass.http.register_view(ManifestJSONView) conf = config.get(DOMAIN, {}) @@ -331,24 +317,20 @@ async def async_setup(hass, config): def _async_setup_themes(hass, themes): """Set up themes data and services.""" hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME - if themes is None: - hass.data[DATA_THEMES] = {} - return - - hass.data[DATA_THEMES] = themes + hass.data[DATA_THEMES] = themes or {} @callback def update_theme_and_fire_event(): """Update theme_color in manifest.""" name = hass.data[DATA_DEFAULT_THEME] themes = hass.data[DATA_THEMES] - if name != DEFAULT_THEME and PRIMARY_COLOR in themes[name]: - MANIFEST_JSON["theme_color"] = themes[name][PRIMARY_COLOR] - else: - MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR - hass.bus.async_fire( - EVENT_THEMES_UPDATED, {"themes": themes, "default_theme": name} - ) + MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR + if name != DEFAULT_THEME: + MANIFEST_JSON["theme_color"] = themes[name].get( + "app-header-background-color", + themes[name].get(PRIMARY_COLOR, DEFAULT_THEME_COLOR), + ) + hass.bus.async_fire(EVENT_THEMES_UPDATED) @callback def set_theme(call): @@ -371,10 +353,17 @@ async def reload_themes(_): hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME update_theme_and_fire_event() - hass.services.async_register( - DOMAIN, SERVICE_SET_THEME, set_theme, schema=SERVICE_SET_THEME_SCHEMA + service.async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_THEME, + set_theme, + vol.Schema({vol.Required(CONF_NAME): cv.string}), + ) + + service.async_register_admin_service( + hass, DOMAIN, SERVICE_RELOAD_THEMES, reload_themes ) - hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) class IndexView(web_urldispatcher.AbstractResource): @@ -496,6 +485,7 @@ def get(self, request): # pylint: disable=no-self-use @callback +@websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels(hass, connection, msg): """Handle get panels command. @@ -512,11 +502,29 @@ def websocket_get_panels(hass, connection, msg): @callback +@websocket_api.websocket_command({"type": "frontend/get_themes"}) def websocket_get_themes(hass, connection, msg): """Handle get themes command. Async friendly. """ + if hass.config.safe_mode: + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "themes": { + "safe_mode": { + "primary-color": "#db4437", + "accent-color": "#eeee02", + } + }, + "default_theme": "safe_mode", + }, + ) + ) + return + connection.send_message( websocket_api.result_message( msg["id"], @@ -528,6 +536,9 @@ def websocket_get_themes(hass, connection, msg): ) +@websocket_api.websocket_command( + {"type": "frontend/get_translations", vol.Required("language"): str} +) @websocket_api.async_response async def websocket_get_translations(hass, connection, msg): """Handle get translations command. diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 159ee68a53eab8..2b39681af25836 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/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200108.2" + "home-assistant-frontend==20200220.3" ], "dependencies": [ "api", diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 2f68c5f8e017ca..b37945b5e072f1 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -9,7 +9,6 @@ DATA_STORAGE = "frontend_storage" STORAGE_VERSION_USER_DATA = 1 -STORAGE_KEY_USER_DATA = "frontend.user_data_{}" async def async_setup_frontend_storage(hass): @@ -31,8 +30,7 @@ async def with_store_func(hass, connection, msg): if store is None: store = stores[user_id] = hass.helpers.storage.Store( - STORAGE_VERSION_USER_DATA, - STORAGE_KEY_USER_DATA.format(connection.user.id), + STORAGE_VERSION_USER_DATA, f"frontend.user_data_{connection.user.id}" ) if user_id not in data: diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 010420d0f98840..93e96d6e96767a 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -24,8 +24,10 @@ ) from homeassistant.const import ( CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT, + STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, @@ -53,13 +55,13 @@ DEFAULT_PORT = 80 DEFAULT_PASSWORD = "1234" -DEVICE_URL = "http://{0}:{1}/device" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_NAME): cv.string, } ) @@ -68,17 +70,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the Frontier Silicon platform.""" if discovery_info is not None: async_add_entities( - [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD)], True + [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD, None)], + True, ) return True host = config.get(CONF_HOST) port = config.get(CONF_PORT) password = config.get(CONF_PASSWORD) + name = config.get(CONF_NAME) try: async_add_entities( - [AFSAPIDevice(DEVICE_URL.format(host, port), password)], True + [AFSAPIDevice(f"http://{host}:{port}/device", password, name)], True ) _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) return True @@ -93,13 +97,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AFSAPIDevice(MediaPlayerDevice): """Representation of a Frontier Silicon device on the network.""" - def __init__(self, device_url, password): + def __init__(self, device_url, password, name): """Initialize the Frontier Silicon API device.""" self._device_url = device_url self._password = password self._state = None - self._name = None + self._name = name self._title = None self._artist = None self._album_name = None @@ -107,6 +111,8 @@ def __init__(self, device_url, password): self._source = None self._source_list = None self._media_image_url = None + self._max_volume = None + self._volume_level = None # Properties @property @@ -176,6 +182,11 @@ def media_image_url(self): """Image url of current playing media.""" return self._media_image_url + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume_level + async def async_update(self): """Get the latest date and update device state.""" fs_device = self.fs_device @@ -186,14 +197,23 @@ async def async_update(self): if not self._source_list: self._source_list = await fs_device.get_mode_list() - status = await fs_device.get_play_status() - self._state = { - "playing": STATE_PLAYING, - "paused": STATE_PAUSED, - "stopped": STATE_OFF, - "unknown": STATE_UNKNOWN, - None: STATE_OFF, - }.get(status, STATE_UNKNOWN) + # The API seems to include 'zero' in the number of steps (e.g. if the range is + # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. + # If call to get_volume fails set to 0 and try again next time. + if not self._max_volume: + self._max_volume = int(await fs_device.get_volume_steps() or 1) - 1 + + if await fs_device.get_power(): + status = await fs_device.get_play_status() + self._state = { + "playing": STATE_PLAYING, + "paused": STATE_PAUSED, + "stopped": STATE_IDLE, + "unknown": STATE_UNKNOWN, + None: STATE_IDLE, + }.get(status, STATE_UNKNOWN) + else: + self._state = STATE_OFF if self._state != STATE_OFF: info_name = await fs_device.get_play_name() @@ -206,6 +226,11 @@ async def async_update(self): self._source = await fs_device.get_mode() self._mute = await fs_device.get_mute() self._media_image_url = await fs_device.get_play_graphic() + + volume = await self.fs_device.get_volume() + + # Prevent division by zero if max_volume not known yet + self._volume_level = float(volume or 0) / (self._max_volume or 1) else: self._title = None self._artist = None @@ -215,6 +240,8 @@ async def async_update(self): self._mute = None self._media_image_url = None + self._volume_level = None + # Management actions # power control async def async_turn_on(self): @@ -266,16 +293,20 @@ async def async_mute_volume(self, mute): async def async_volume_up(self): """Send volume up command.""" volume = await self.fs_device.get_volume() - await self.fs_device.set_volume(volume + 1) + volume = int(volume or 0) + 1 + await self.fs_device.set_volume(min(volume, self._max_volume)) async def async_volume_down(self): """Send volume down command.""" volume = await self.fs_device.get_volume() - await self.fs_device.set_volume(volume - 1) + volume = int(volume or 0) - 1 + await self.fs_device.set_volume(max(volume, 0)) async def async_set_volume_level(self, volume): """Set volume command.""" - await self.fs_device.set_volume(volume) + if self._max_volume: # Can't do anything sensible if not set + volume = int(volume * self._max_volume) + await self.fs_device.set_volume(volume) async def async_select_source(self, source): """Select input source.""" diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 0eeb5f2b8f9b68..5a43c3c728172b 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -251,9 +251,7 @@ def update(self): def _get_variable(self, var): """Get latest status.""" - url = "{}/v1/devices/{}/{}?access_token={}".format( - self.particle_url, self.device_id, var, self.access_token - ) + url = f"{self.particle_url}/v1/devices/{self.device_id}/{var}?access_token={self.access_token}" ret = requests.get(url, timeout=10) result = {} for pairs in ret.json()["result"].split("|"): diff --git a/homeassistant/components/garmin_connect/.translations/ca.json b/homeassistant/components/garmin_connect/.translations/ca.json new file mode 100644 index 00000000000000..95e59cf350d818 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest compte ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida.", + "too_many_requests": "Massa sol\u00b7licituds, torna-ho a intentar m\u00e9s tard.", + "unknown": "Error inesperat." + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les teves credencials.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/cs.json b/homeassistant/components/garmin_connect/.translations/cs.json new file mode 100644 index 00000000000000..ed8d33cc65cc81 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/cs.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Tento \u00fa\u010det je ji\u017e nakonfigurov\u00e1n." + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit, zkuste to znovu.", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed.", + "too_many_requests": "P\u0159\u00edli\u0161 mnoho po\u017eadavk\u016f, opakujte to pozd\u011bji.", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/da.json b/homeassistant/components/garmin_connect/.translations/da.json new file mode 100644 index 00000000000000..1bbc5e7edba3ee --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/da.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Denne konto er allerede konfigureret." + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse - pr\u00f8v igen.", + "invalid_auth": "Ugyldig godkendelse.", + "too_many_requests": "For mange anmodninger - pr\u00f8v igen senere.", + "unknown": "Uventet fejl." + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "Brugernavn" + }, + "description": "Indtast dine legitimationsoplysninger.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/de.json b/homeassistant/components/garmin_connect/.translations/de.json new file mode 100644 index 00000000000000..dc1dfe5e9bd48f --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Konto ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "invalid_auth": "Ung\u00fcltige Authentifizierung.", + "too_many_requests": "Zu viele Anfragen, wiederholen Sie es sp\u00e4ter.", + "unknown": "Unerwarteter Fehler." + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Geben Sie Ihre Zugangsdaten ein.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/en.json b/homeassistant/components/garmin_connect/.translations/en.json new file mode 100644 index 00000000000000..5dac9131fb0b7f --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured." + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "invalid_auth": "Invalid authentication.", + "too_many_requests": "Too many requests, retry later.", + "unknown": "Unexpected error." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter your credentials.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/es-419.json b/homeassistant/components/garmin_connect/.translations/es-419.json new file mode 100644 index 00000000000000..6e20b4cd2cc18a --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Esta cuenta ya est\u00e1 configurada." + }, + "error": { + "cannot_connect": "No se pudo conectar, intente nuevamente.", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", + "unknown": "Error inesperado." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Ingrese sus credenciales." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/es.json b/homeassistant/components/garmin_connect/.translations/es.json new file mode 100644 index 00000000000000..989d86dbc35f2b --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Esta cuenta ya est\u00e1 configurada." + }, + "error": { + "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntelo de nuevo.", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Introduzca sus credenciales.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/fr.json b/homeassistant/components/garmin_connect/.translations/fr.json new file mode 100644 index 00000000000000..f0dd8a79e5bc0c --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer.", + "invalid_auth": "Authentification non valide.", + "too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.", + "unknown": "Erreur inattendue." + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Entrez vos informations d'identification.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/hu.json b/homeassistant/components/garmin_connect/.translations/hu.json new file mode 100644 index 00000000000000..931fa2959622a5 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/hu.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Ez a fi\u00f3k m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni, pr\u00f3b\u00e1lkozzon \u00fajra.", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s.", + "too_many_requests": "T\u00fal sok k\u00e9r\u00e9s, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra.", + "unknown": "V\u00e1ratlan hiba." + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg a hiteles\u00edt\u0151 adatait.", + "title": "Garmin Csatlakoz\u00e1s" + } + }, + "title": "Garmin Csatlakoz\u00e1s" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/it.json b/homeassistant/components/garmin_connect/.translations/it.json new file mode 100644 index 00000000000000..2d942bbc6a2ff0 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Questo account \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare.", + "invalid_auth": "Autenticazione non valida.", + "too_many_requests": "Troppe richieste, riprovare pi\u00f9 tardi.", + "unknown": "Errore imprevisto." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le tue credenziali", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/ko.json b/homeassistant/components/garmin_connect/.translations/ko.json new file mode 100644 index 00000000000000..018a0a8d923eb6 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "Garmin \uc5f0\uacb0" + } + }, + "title": "Garmin \uc5f0\uacb0" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/lb.json b/homeassistant/components/garmin_connect/.translations/lb.json new file mode 100644 index 00000000000000..8289be66d5936c --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse Kont ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun.", + "too_many_requests": "Ze vill Ufroen, prob\u00e9iert sp\u00e9ider nach emol.", + "unknown": "Onerwaarte Feeler." + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/nl.json b/homeassistant/components/garmin_connect/.translations/nl.json new file mode 100644 index 00000000000000..c7a690de6e2ced --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Dit account is al geconfigureerd." + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw.", + "invalid_auth": "Ongeldige authenticatie", + "too_many_requests": "Te veel aanvragen, probeer het later opnieuw.", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer uw gegevens in", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/no.json b/homeassistant/components/garmin_connect/.translations/no.json new file mode 100644 index 00000000000000..f7bdba27906e2d --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Denne kontoen er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kunne ikke koble til, pr\u00f8v igjen.", + "invalid_auth": "Ugyldig godkjenning.", + "too_many_requests": "For mange foresp\u00f8rsler, pr\u00f8v p\u00e5 nytt senere.", + "unknown": "Uventet feil." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Angi brukeropplysninger.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/pl.json b/homeassistant/components/garmin_connect/.translations/pl.json new file mode 100644 index 00000000000000..45d0296b668c3b --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/pl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "To konto jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/ru.json b/homeassistant/components/garmin_connect/.translations/ru.json new file mode 100644 index 00000000000000..f8d018e1b1e9b9 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/sl.json b/homeassistant/components/garmin_connect/.translations/sl.json new file mode 100644 index 00000000000000..5b85611d5b7aa4 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Ta ra\u010dun je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova.", + "invalid_auth": "Neveljavna avtentikacija.", + "too_many_requests": "Preve\u010d zahtev, poskusite pozneje.", + "unknown": "Nepri\u010dakovana napaka." + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite svoje poverilnice.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/sv.json b/homeassistant/components/garmin_connect/.translations/sv.json new file mode 100644 index 00000000000000..12715a97ebedb0 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Det h\u00e4r kontot har redan konfigurerats." + }, + "error": { + "cannot_connect": "Kunde inte ansluta, var god f\u00f6rs\u00f6k igen.", + "invalid_auth": "Ogiltig autentisering.", + "too_many_requests": "F\u00f6r m\u00e5nga f\u00f6rfr\u00e5gningar, f\u00f6rs\u00f6k igen senare.", + "unknown": "Ov\u00e4ntat fel." + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Ange dina anv\u00e4ndaruppgifter.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/zh-Hant.json b/homeassistant/components/garmin_connect/.translations/zh-Hant.json new file mode 100644 index 00000000000000..8ddb5e61295deb --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_auth": "\u9a57\u8b49\u7121\u6548\u3002", + "too_many_requests": "\u8acb\u6c42\u6b21\u6578\u904e\u591a\uff0c\u8acb\u7a0d\u5f8c\u91cd\u8a66\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u6191\u8b49\u3002", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py new file mode 100644 index 00000000000000..d63d82d1284320 --- /dev/null +++ b/homeassistant/components/garmin_connect/__init__.py @@ -0,0 +1,108 @@ +"""The Garmin Connect integration.""" +import asyncio +from datetime import date, timedelta +import logging + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] +MIN_SCAN_INTERVAL = timedelta(minutes=10) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Garmin Connect component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Garmin Connect from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + garmin_client = Garmin(username, password) + + try: + garmin_client.login() + except ( + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occurred during Garmin Connect login: %s", err) + return False + except (GarminConnectConnectionError) as err: + _LOGGER.error("Error occurred during Garmin Connect login: %s", err) + raise ConfigEntryNotReady + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unknown error occurred during Garmin Connect login") + return False + + garmin_data = GarminConnectData(hass, garmin_client) + hass.data[DOMAIN][entry.entry_id] = garmin_data + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class GarminConnectData: + """Define an object to hold sensor data.""" + + def __init__(self, hass, client): + """Initialize.""" + self.client = client + self.data = None + + @Throttle(MIN_SCAN_INTERVAL) + async def async_update(self): + """Update data via library.""" + today = date.today() + + try: + self.data = self.client.get_stats(today.isoformat()) + except ( + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occurred during Garmin Connect get stats: %s", err) + return + except (GarminConnectConnectionError) as err: + _LOGGER.error("Error occurred during Garmin Connect get stats: %s", err) + return + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unknown error occurred during Garmin Connect get stats") + return diff --git a/homeassistant/components/garmin_connect/config_flow.py b/homeassistant/components/garmin_connect/config_flow.py new file mode 100644 index 00000000000000..36c63c7b995a51 --- /dev/null +++ b/homeassistant/components/garmin_connect/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Garmin Connect integration.""" +import logging + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class GarminConnectConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Garmin Connect.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return await self._show_setup_form() + + garmin_client = Garmin(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + + errors = {} + try: + garmin_client.login() + except GarminConnectConnectionError: + errors["base"] = "cannot_connect" + return await self._show_setup_form(errors) + except GarminConnectAuthenticationError: + errors["base"] = "invalid_auth" + return await self._show_setup_form(errors) + except GarminConnectTooManyRequestsError: + errors["base"] = "too_many_requests" + return await self._show_setup_form(errors) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return await self._show_setup_form(errors) + + unique_id = garmin_client.get_full_name() + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data={ + CONF_ID: unique_id, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py new file mode 100644 index 00000000000000..c01f11464a1ca4 --- /dev/null +++ b/homeassistant/components/garmin_connect/const.py @@ -0,0 +1,306 @@ +"""Constants for the Garmin Connect integration.""" +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, TIME_MINUTES + +DOMAIN = "garmin_connect" +ATTRIBUTION = "Data provided by garmin.com" + +GARMIN_ENTITY_LIST = { + "totalSteps": ["Total Steps", "steps", "mdi:walk", None, True], + "dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, True], + "totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, True], + "activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, True], + "bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, True], + "consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, False], + "burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, True], + "remainingKilocalories": [ + "Remaining KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "netRemainingKilocalories": [ + "Net Remaining KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "netCalorieGoal": ["Net Calorie Goal", "cal", "mdi:food", None, False], + "totalDistanceMeters": ["Total Distance Mtr", "m", "mdi:walk", None, True], + "wellnessStartTimeLocal": [ + "Wellness Start Time", + "", + "mdi:clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "wellnessEndTimeLocal": [ + "Wellness End Time", + "", + "mdi:clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "wellnessDescription": ["Wellness Description", "", "mdi:clock", None, False], + "wellnessDistanceMeters": ["Wellness Distance Mtr", "m", "mdi:walk", None, False], + "wellnessActiveKilocalories": [ + "Wellness Active KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False], + "highlyActiveSeconds": [ + "Highly Active Time", + TIME_MINUTES, + "mdi:fire", + None, + False, + ], + "activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True], + "sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True], + "sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True], + "measurableAwakeDuration": [ + "Awake Duration", + TIME_MINUTES, + "mdi:sleep", + None, + True, + ], + "measurableAsleepDuration": [ + "Sleep Duration", + TIME_MINUTES, + "mdi:sleep", + None, + True, + ], + "floorsAscendedInMeters": ["Floors Ascended Mtr", "m", "mdi:stairs", None, False], + "floorsDescendedInMeters": ["Floors Descended Mtr", "m", "mdi:stairs", None, False], + "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True], + "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True], + "userFloorsAscendedGoal": [ + "Floors Ascended Goal", + "floors", + "mdi:stairs", + None, + True, + ], + "minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], + "maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], + "abnormalHeartRateAlertsCount": [ + "Abnormal HR Counts", + "", + "mdi:heart-pulse", + None, + False, + ], + "lastSevenDaysAvgRestingHeartRate": [ + "Last 7 Days Avg Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + False, + ], + "averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True], + "maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True], + "stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False], + "stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False], + "restStressDuration": [ + "Rest Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "activityStressDuration": [ + "Activity Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "uncategorizedStressDuration": [ + "Uncat. Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "totalStressDuration": [ + "Total Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "lowStressDuration": [ + "Low Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "mediumStressDuration": [ + "Medium Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "highStressDuration": [ + "High Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "stressPercentage": ["Stress Percentage", "%", "mdi:flash-alert", None, False], + "restStressPercentage": [ + "Rest Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "activityStressPercentage": [ + "Activity Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "uncategorizedStressPercentage": [ + "Uncat. Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "lowStressPercentage": [ + "Low Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "mediumStressPercentage": [ + "Medium Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "highStressPercentage": [ + "High Stress Percentage", + "%", + "mdi:flash-alert", + None, + False, + ], + "moderateIntensityMinutes": [ + "Moderate Intensity", + TIME_MINUTES, + "mdi:flash-alert", + None, + False, + ], + "vigorousIntensityMinutes": [ + "Vigorous Intensity", + TIME_MINUTES, + "mdi:run-fast", + None, + False, + ], + "intensityMinutesGoal": [ + "Intensity Goal", + TIME_MINUTES, + "mdi:run-fast", + None, + False, + ], + "bodyBatteryChargedValue": [ + "Body Battery Charged", + "%", + "mdi:battery-charging-100", + None, + True, + ], + "bodyBatteryDrainedValue": [ + "Body Battery Drained", + "%", + "mdi:battery-alert-variant-outline", + None, + True, + ], + "bodyBatteryHighestValue": [ + "Body Battery Highest", + "%", + "mdi:battery-heart", + None, + True, + ], + "bodyBatteryLowestValue": [ + "Body Battery Lowest", + "%", + "mdi:battery-heart-outline", + None, + True, + ], + "bodyBatteryMostRecentValue": [ + "Body Battery Most Recent", + "%", + "mdi:battery-positive", + None, + True, + ], + "averageSpo2": ["Average SPO2", "%", "mdi:diabetes", None, True], + "lowestSpo2": ["Lowest SPO2", "%", "mdi:diabetes", None, True], + "latestSpo2": ["Latest SPO2", "%", "mdi:diabetes", None, True], + "latestSpo2ReadingTimeLocal": [ + "Latest SPO2 Time", + "", + "mdi:diabetes", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "averageMonitoringEnvironmentAltitude": [ + "Average Altitude", + "%", + "mdi:image-filter-hdr", + None, + False, + ], + "highestRespirationValue": [ + "Highest Respiration", + "brpm", + "mdi:progress-clock", + None, + False, + ], + "lowestRespirationValue": [ + "Lowest Respiration", + "brpm", + "mdi:progress-clock", + None, + False, + ], + "latestRespirationValue": [ + "Latest Respiration", + "brpm", + "mdi:progress-clock", + None, + False, + ], + "latestRespirationTimeGMT": [ + "Latest Respiration Update", + "", + "mdi:progress-clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], +} diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json new file mode 100644 index 00000000000000..b22828315722d6 --- /dev/null +++ b/homeassistant/components/garmin_connect/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "garmin_connect", + "name": "Garmin Connect", + "documentation": "https://www.home-assistant.io/integrations/garmin_connect", + "dependencies": [], + "requirements": ["garminconnect==0.1.8"], + "codeowners": ["@cyberjunky"], + "config_flow": true +} diff --git a/homeassistant/components/garmin_connect/sensor.py b/homeassistant/components/garmin_connect/sensor.py new file mode 100644 index 00000000000000..5edf54d95dceb8 --- /dev/null +++ b/homeassistant/components/garmin_connect/sensor.py @@ -0,0 +1,180 @@ +"""Platform for Garmin Connect integration.""" +import logging +from typing import Any, Dict + +from garminconnect import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTRIBUTION, DOMAIN, GARMIN_ENTITY_LIST + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Garmin Connect sensor based on a config entry.""" + garmin_data = hass.data[DOMAIN][entry.entry_id] + unique_id = entry.data[CONF_ID] + + try: + await garmin_data.async_update() + except ( + GarminConnectConnectionError, + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occurred during Garmin Connect Client update: %s", err) + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unknown error occurred during Garmin Connect Client update.") + + entities = [] + for ( + sensor_type, + (name, unit, icon, device_class, enabled_by_default), + ) in GARMIN_ENTITY_LIST.items(): + + _LOGGER.debug( + "Registering entity: %s, %s, %s, %s, %s, %s", + sensor_type, + name, + unit, + icon, + device_class, + enabled_by_default, + ) + entities.append( + GarminConnectSensor( + garmin_data, + unique_id, + sensor_type, + name, + unit, + icon, + device_class, + enabled_by_default, + ) + ) + + async_add_entities(entities, True) + + +class GarminConnectSensor(Entity): + """Representation of a Garmin Connect Sensor.""" + + def __init__( + self, + data, + unique_id, + sensor_type, + name, + unit, + icon, + device_class, + enabled_default: bool = True, + ): + """Initialize.""" + self._data = data + self._unique_id = unique_id + self._type = sensor_type + self._name = name + self._unit = unit + self._icon = icon + self._device_class = device_class + self._enabled_default = enabled_default + self._available = True + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return f"{self._unique_id}_{self._type}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def device_state_attributes(self): + """Return attributes for sensor.""" + attributes = {} + if self._data.data: + attributes = { + "source": self._data.data["source"], + "last_synced": self._data.data["lastSyncTimestampGMT"], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + return attributes + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._unique_id)}, + "name": "Garmin Connect", + "manufacturer": "Garmin Connect", + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + async def async_update(self): + """Update the data from Garmin Connect.""" + if not self.enabled: + return + + await self._data.async_update() + data = self._data.data + if not data: + _LOGGER.error("Didn't receive data from Garmin Connect") + return + if data.get(self._type) is None: + _LOGGER.debug("Entity type %s not set in fetched data", self._type) + self._available = False + return + self._available = True + + if "Duration" in self._type or "Seconds" in self._type: + self._state = data[self._type] // 60 + else: + self._state = data[self._type] + + _LOGGER.debug( + "Entity %s set to state %s %s", self._type, self._state, self._unit + ) diff --git a/homeassistant/components/garmin_connect/strings.json b/homeassistant/components/garmin_connect/strings.json new file mode 100644 index 00000000000000..faf463ea8db07f --- /dev/null +++ b/homeassistant/components/garmin_connect/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured." + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "invalid_auth": "Invalid authentication.", + "too_many_requests": "Too many requests, retry later.", + "unknown": "Unexpected error." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter your credentials.", + "title": "Garmin Connect" + } + }, + "title": "Garmin Connect" + } +} diff --git a/homeassistant/components/gdacs/.translations/ca.json b/homeassistant/components/gdacs/.translations/ca.json new file mode 100644 index 00000000000000..5f5acfe7ccf372 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/ca.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada." + }, + "step": { + "user": { + "data": { + "radius": "Radi" + }, + "title": "Introducci\u00f3 dels detalls del filtre." + } + }, + "title": "Sistema Global de Coordinaci\u00f3 i Alerta per Desastres (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/da.json b/homeassistant/components/gdacs/.translations/da.json new file mode 100644 index 00000000000000..64f3dd000c4c4f --- /dev/null +++ b/homeassistant/components/gdacs/.translations/da.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Lokaliteten er allerede konfigureret." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Udfyld dine filteroplysninger." + } + }, + "title": "Globalt katastrofevarslings- og koordineringssystem (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/de.json b/homeassistant/components/gdacs/.translations/de.json new file mode 100644 index 00000000000000..12f94250402aed --- /dev/null +++ b/homeassistant/components/gdacs/.translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Der Standort ist bereits konfiguriert." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00fclle deine Filterangaben aus." + } + }, + "title": "Globales Katastrophenalarm- und Koordinierungssystem (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/en.json b/homeassistant/components/gdacs/.translations/en.json new file mode 100644 index 00000000000000..4e7ceb3846cc89 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Location is already configured." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Fill in your filter details." + } + }, + "title": "Global Disaster Alert and Coordination System (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/es.json b/homeassistant/components/gdacs/.translations/es.json new file mode 100644 index 00000000000000..6c02b33954165a --- /dev/null +++ b/homeassistant/components/gdacs/.translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada." + }, + "step": { + "user": { + "data": { + "radius": "Radio" + }, + "title": "Rellena los datos de tu filtro." + } + }, + "title": "Sistema Mundial de Alerta y Coordinaci\u00f3n de Desastres (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/fr.json b/homeassistant/components/gdacs/.translations/fr.json new file mode 100644 index 00000000000000..a4366cb5dc7fc7 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "user": { + "data": { + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + }, + "title": "Syst\u00e8me mondial d'alerte et de coordination en cas de catastrophe (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/hu.json b/homeassistant/components/gdacs/.translations/hu.json new file mode 100644 index 00000000000000..79bcba3388f8e3 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van." + }, + "step": { + "user": { + "data": { + "radius": "Sug\u00e1r" + }, + "title": "T\u00f6ltse ki a sz\u0171r\u0151 adatait." + } + }, + "title": "Glob\u00e1lis katasztr\u00f3fariaszt\u00e1si \u00e9s koordin\u00e1ci\u00f3s rendszer (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/it.json b/homeassistant/components/gdacs/.translations/it.json new file mode 100644 index 00000000000000..249b47f9f59cb5 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/it.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "La posizione \u00e8 gi\u00e0 configurata." + }, + "step": { + "user": { + "data": { + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + }, + "title": "Sistema globale di allerta e coordinamento delle catastrofi (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/ko.json b/homeassistant/components/gdacs/.translations/ko.json new file mode 100644 index 00000000000000..10d6f73e56f28b --- /dev/null +++ b/homeassistant/components/gdacs/.translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." + } + }, + "title": "\uad6d\uc81c \uc7ac\ub09c \uacbd\ubcf4 \ubc0f \uc870\uc815 \uae30\uad6c (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/lb.json b/homeassistant/components/gdacs/.translations/lb.json new file mode 100644 index 00000000000000..a4077ed630e42f --- /dev/null +++ b/homeassistant/components/gdacs/.translations/lb.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Standuert ass scho konfigu\u00e9iert." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + }, + "title": "Globale D\u00e9saster Alerte a Koordinatioun System (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/nl.json b/homeassistant/components/gdacs/.translations/nl.json new file mode 100644 index 00000000000000..62383e43e362ba --- /dev/null +++ b/homeassistant/components/gdacs/.translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Locatie is al geconfigureerd." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Vul uw filtergegevens in." + } + }, + "title": "Wereldwijd rampenwaarschuwings- en co\u00f6rdinatiesysteem (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/no.json b/homeassistant/components/gdacs/.translations/no.json new file mode 100644 index 00000000000000..54b3ca684519e6 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/no.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Plasseringen er allerede konfigurert." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Fyll ut filterdetaljene." + } + }, + "title": "Globalt katastrofevarslings- og koordineringssystem (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/pl.json b/homeassistant/components/gdacs/.translations/pl.json new file mode 100644 index 00000000000000..f4b90cc35c70e7 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/pl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana." + }, + "step": { + "user": { + "data": { + "radius": "Promie\u0144" + }, + "title": "Wprowad\u017a szczeg\u00f3\u0142owe dane filtra." + } + }, + "title": "Globalny system ostrzegania i koordynacji w przypadku katastrof (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/ru.json b/homeassistant/components/gdacs/.translations/ru.json new file mode 100644 index 00000000000000..f006832b5be565 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/ru.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e." + }, + "step": { + "user": { + "data": { + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + } + }, + "title": "\u0413\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 \u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u0438 \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0446\u0438\u0438 \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0441\u0442\u0438\u0445\u0438\u0439\u043d\u044b\u0445 \u0431\u0435\u0434\u0441\u0442\u0432\u0438\u0439 (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/sl.json b/homeassistant/components/gdacs/.translations/sl.json new file mode 100644 index 00000000000000..fc522a1a2630c8 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/sl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Lokacija je \u017ee nastavljena." + }, + "step": { + "user": { + "data": { + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + }, + "title": "Globalni sistem opozarjanja in koordinacije nesre\u010d (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/sv.json b/homeassistant/components/gdacs/.translations/sv.json new file mode 100644 index 00000000000000..3c7fb00056e070 --- /dev/null +++ b/homeassistant/components/gdacs/.translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Plats \u00e4r redan konfigurerad." + }, + "step": { + "user": { + "data": { + "radius": "Radie" + }, + "title": "Fyll i filterinformation." + } + }, + "title": "Globalt katastrofvarnings- och samordningssystem (GDACS)" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/.translations/zh-Hant.json b/homeassistant/components/gdacs/.translations/zh-Hant.json new file mode 100644 index 00000000000000..59f9b7be03176e --- /dev/null +++ b/homeassistant/components/gdacs/.translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u4f4d\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f91" + }, + "title": "\u586b\u5beb\u904e\u6ffe\u5668\u8cc7\u8a0a\u3002" + } + }, + "title": "\u5168\u7403\u707d\u96e3\u9810\u8b66\u548c\u5354\u8abf\u7cfb\u7d71\uff08GDACS\uff09" + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py new file mode 100644 index 00000000000000..8b00b2b3ff1617 --- /dev/null +++ b/homeassistant/components/gdacs/__init__.py @@ -0,0 +1,208 @@ +"""The Global Disaster Alert and Coordination System (GDACS) integration.""" +import asyncio +from datetime import timedelta +import logging + +from aio_georss_gdacs import GdacsFeedManager +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import ( + CONF_CATEGORIES, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + FEED, + PLATFORMS, + VALID_CATEGORIES, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_CATEGORIES, default=[]): vol.All( + cv.ensure_list, [vol.In(VALID_CATEGORIES)] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the GDACS component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + scan_interval = conf[CONF_SCAN_INTERVAL] + categories = conf[CONF_CATEGORIES] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_SCAN_INTERVAL: scan_interval, + CONF_CATEGORIES: categories, + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the GDACS component as config entry.""" + hass.data.setdefault(DOMAIN, {}) + feeds = hass.data[DOMAIN].setdefault(FEED, {}) + + radius = config_entry.data[CONF_RADIUS] + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + # Create feed entity manager for all platforms. + manager = GdacsFeedEntityManager(hass, config_entry, radius) + feeds[config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await manager.async_init() + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an GDACS component config entry.""" + manager = hass.data[DOMAIN][FEED].pop(config_entry.entry_id) + await manager.async_stop() + await asyncio.wait( + [ + hass.config_entries.async_forward_entry_unload(config_entry, domain) + for domain in PLATFORMS + ] + ) + return True + + +class GdacsFeedEntityManager: + """Feed Entity Manager for GDACS feed.""" + + def __init__(self, hass, config_entry, radius_in_km): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._config_entry = config_entry + coordinates = ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ) + categories = config_entry.data[CONF_CATEGORIES] + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GdacsFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + filter_radius=radius_in_km, + filter_categories=categories, + status_async_callback=self._status_update, + ) + self._config_entry_id = config_entry.entry_id + self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) + self._track_time_remove_callback = None + self._status_info = None + self.listeners = [] + + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" + + for domain in PLATFORMS: + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, domain + ) + ) + + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, self._scan_interval + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self): + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + @callback + def async_event_new_entity(self): + """Return manager specific event to signal new entity.""" + return f"gdacs_new_geolocation_{self._config_entry_id}" + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def status_info(self): + """Return latest status update info received.""" + return self._status_info + + async def _generate_entity(self, external_id): + """Generate new entity.""" + async_dispatcher_send( + self._hass, self.async_event_new_entity(), self, external_id + ) + + async def _update_entity(self, external_id): + """Update entity.""" + async_dispatcher_send(self._hass, f"gdacs_update_{external_id}") + + async def _remove_entity(self, external_id): + """Remove entity.""" + async_dispatcher_send(self._hass, f"gdacs_delete_{external_id}") + + async def _status_update(self, status_info): + """Propagate status update.""" + _LOGGER.debug("Status update received: %s", status_info) + self._status_info = status_info + async_dispatcher_send(self._hass, f"gdacs_status_{self._config_entry_id}") diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py new file mode 100644 index 00000000000000..1e12a116ed5a68 --- /dev/null +++ b/homeassistant/components/gdacs/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow to configure the GDACS integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, +) +from homeassistant.helpers import config_validation as cv + +from .const import ( # pylint: disable=unused-import + CONF_CATEGORIES, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +DATA_SCHEMA = vol.Schema( + {vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int} +) + +_LOGGER = logging.getLogger(__name__) + + +class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GDACS config flow.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + _LOGGER.debug("User input: %s", user_input) + if not user_input: + return await self._show_form() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + user_input[CONF_LATITUDE] = latitude + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_LONGITUDE] = longitude + + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + categories = user_input.get(CONF_CATEGORIES, []) + user_input[CONF_CATEGORIES] = categories + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py new file mode 100644 index 00000000000000..5d5c83f013e329 --- /dev/null +++ b/homeassistant/components/gdacs/const.py @@ -0,0 +1,19 @@ +"""Define constants for the GDACS integration.""" +from datetime import timedelta + +from aio_georss_gdacs.consts import EVENT_TYPE_MAP + +DOMAIN = "gdacs" + +PLATFORMS = ("sensor", "geo_location") + +FEED = "feed" + +CONF_CATEGORIES = "categories" + +DEFAULT_ICON = "mdi:alert" +DEFAULT_RADIUS = 500.0 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) + +# Fetch valid categories from integration library. +VALID_CATEGORIES = list(EVENT_TYPE_MAP.values()) diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py new file mode 100644 index 00000000000000..616be5a5e187d3 --- /dev/null +++ b/homeassistant/components/gdacs/geo_location.py @@ -0,0 +1,224 @@ +"""Geolocation support for GDACS Feed.""" +import logging +from typing import Optional + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import DEFAULT_ICON, DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALERT_LEVEL = "alert_level" +ATTR_COUNTRY = "country" +ATTR_DESCRIPTION = "description" +ATTR_DURATION_IN_WEEK = "duration_in_week" +ATTR_EVENT_TYPE = "event_type" +ATTR_EXTERNAL_ID = "external_id" +ATTR_FROM_DATE = "from_date" +ATTR_POPULATION = "population" +ATTR_SEVERITY = "severity" +ATTR_TO_DATE = "to_date" +ATTR_VULNERABILITY = "vulnerability" + +ICONS = { + "DR": "mdi:water-off", + "EQ": "mdi:pulse", + "FL": "mdi:home-flood", + "TC": "mdi:weather-hurricane", + "TS": "mdi:waves", + "VO": "mdi:image-filter-hdr", +} + +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + +SOURCE = "gdacs" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GDACS Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + + @callback + def async_add_geolocation(feed_manager, external_id): + """Add gelocation entity from feed.""" + new_entity = GdacsEvent(feed_manager, external_id) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect( + hass, manager.async_event_new_entity(), async_add_geolocation + ) + ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. + hass.async_create_task(manager.async_update()) + _LOGGER.debug("Geolocation setup done") + + +class GdacsEvent(GeolocationEvent): + """This represents an external event with GDACS feed data.""" + + def __init__(self, feed_manager, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._title = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._alert_level = None + self._country = None + self._description = None + self._duration_in_week = None + self._event_type_short = None + self._event_type = None + self._from_date = None + self._to_date = None + self._population = None + self._severity = None + self._vulnerability = None + self._version = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, f"gdacs_delete_{self._external_id}", self._delete_callback + ) + self._remove_signal_update = async_dispatcher_connect( + self.hass, f"gdacs_update_{self._external_id}", self._update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + self._remove_signal_delete() + self._remove_signal_update() + + @callback + def _delete_callback(self): + """Remove this entity.""" + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GDACS feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + event_name = feed_entry.event_name + if not event_name: + # Earthquakes usually don't have an event name. + event_name = f"{feed_entry.country} ({feed_entry.event_id})" + self._title = f"{feed_entry.event_type}: {event_name}" + # Convert distance if not metric system. + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + self._distance = IMPERIAL_SYSTEM.length( + feed_entry.distance_to_home, LENGTH_KILOMETERS + ) + else: + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._alert_level = feed_entry.alert_level + self._country = feed_entry.country + self._description = feed_entry.title + self._duration_in_week = feed_entry.duration_in_week + self._event_type_short = feed_entry.event_type_short + self._event_type = feed_entry.event_type + self._from_date = feed_entry.from_date + self._to_date = feed_entry.to_date + self._population = feed_entry.population + self._severity = feed_entry.severity + self._vulnerability = feed_entry.vulnerability + # Round vulnerability value if presented as float. + if isinstance(self._vulnerability, float): + self._vulnerability = round(self._vulnerability, 1) + self._version = feed_entry.version + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self._event_type_short and self._event_type_short in ICONS: + return ICONS[self._event_type_short] + return DEFAULT_ICON + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._title + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_DESCRIPTION, self._description), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_EVENT_TYPE, self._event_type), + (ATTR_ALERT_LEVEL, self._alert_level), + (ATTR_COUNTRY, self._country), + (ATTR_DURATION_IN_WEEK, self._duration_in_week), + (ATTR_FROM_DATE, self._from_date), + (ATTR_TO_DATE, self._to_date), + (ATTR_POPULATION, self._population), + (ATTR_SEVERITY, self._severity), + (ATTR_VULNERABILITY, self._vulnerability), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json new file mode 100644 index 00000000000000..45105b21ab43ee --- /dev/null +++ b/homeassistant/components/gdacs/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "gdacs", + "name": "Global Disaster Alert and Coordination System (GDACS)", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gdacs", + "requirements": [ + "aio_georss_gdacs==0.3" + ], + "dependencies": [], + "codeowners": [ + "@exxamalte" + ], + "quality_scale": "platinum" +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py new file mode 100644 index 00000000000000..7ef2855a9be24c --- /dev/null +++ b/homeassistant/components/gdacs/sensor.py @@ -0,0 +1,140 @@ +"""Feed Entity Manager Sensor support for GDACS Feed.""" +import logging +from typing import Optional + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt + +from .const import DEFAULT_ICON, DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATUS = "status" +ATTR_LAST_UPDATE = "last_update" +ATTR_LAST_UPDATE_SUCCESSFUL = "last_update_successful" +ATTR_LAST_TIMESTAMP = "last_timestamp" +ATTR_CREATED = "created" +ATTR_UPDATED = "updated" +ATTR_REMOVED = "removed" + +DEFAULT_UNIT_OF_MEASUREMENT = "alerts" + +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GDACS Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + sensor = GdacsSensor(entry.entry_id, entry.title, manager) + async_add_entities([sensor]) + _LOGGER.debug("Sensor setup done") + + +class GdacsSensor(Entity): + """This is a status sensor for the GDACS integration.""" + + def __init__(self, config_entry_id, config_title, manager): + """Initialize entity.""" + self._config_entry_id = config_entry_id + self._config_title = config_title + self._manager = manager + self._status = None + self._last_update = None + self._last_update_successful = None + self._last_timestamp = None + self._total = None + self._created = None + self._updated = None + self._removed = None + self._remove_signal_status = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_status = async_dispatcher_connect( + self.hass, + f"gdacs_status_{self._config_entry_id}", + self._update_status_callback, + ) + _LOGGER.debug("Waiting for updates %s", self._config_entry_id) + # First update is manual because of how the feed entity manager is updated. + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self._remove_signal_status: + self._remove_signal_status() + + @callback + def _update_status_callback(self): + """Call status update method.""" + _LOGGER.debug("Received status update for %s", self._config_entry_id) + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GDACS status sensor.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._config_entry_id) + if self._manager: + status_info = self._manager.status_info() + if status_info: + self._update_from_status_info(status_info) + + def _update_from_status_info(self, status_info): + """Update the internal state from the provided information.""" + self._status = status_info.status + self._last_update = ( + dt.as_utc(status_info.last_update) if status_info.last_update else None + ) + if status_info.last_update_successful: + self._last_update_successful = dt.as_utc(status_info.last_update_successful) + else: + self._last_update_successful = None + self._last_timestamp = status_info.last_timestamp + self._total = status_info.total + self._created = status_info.created + self._updated = status_info.updated + self._removed = status_info.removed + + @property + def state(self): + """Return the state of the sensor.""" + return self._total + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return f"GDACS ({self._config_title})" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEFAULT_ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_STATUS, self._status), + (ATTR_LAST_UPDATE, self._last_update), + (ATTR_LAST_UPDATE_SUCCESSFUL, self._last_update_successful), + (ATTR_LAST_TIMESTAMP, self._last_timestamp), + (ATTR_CREATED, self._created), + (ATTR_UPDATED, self._updated), + (ATTR_REMOVED, self._removed), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/gdacs/strings.json b/homeassistant/components/gdacs/strings.json new file mode 100644 index 00000000000000..353b1b85634203 --- /dev/null +++ b/homeassistant/components/gdacs/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "title": "Global Disaster Alert and Coordination System (GDACS)", + "step": { + "user": { + "title": "Fill in your filter details.", + "data": { + "radius": "Radius" + } + } + }, + "abort": { + "already_configured": "Location is already configured." + } + } +} diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index e179b576f70134..23ee049052c70b 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -453,10 +453,7 @@ async def _async_heater_turn_off(self): await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) async def async_set_preset_mode(self, preset_mode: str): - """Set new preset mode. - - This method must be run in the event loop and returns a coroutine. - """ + """Set new preset mode.""" if preset_mode == PRESET_AWAY and not self._is_away: self._is_away = True self._saved_target_temp = self._target_temp diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 2f881232495d0e..3435fcc50cf449 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -29,9 +29,6 @@ SCAN_INTERVAL = timedelta(minutes=5) -SIGNAL_DELETE_ENTITY = "geo_json_events_delete_{}" -SIGNAL_UPDATE_ENTITY = "geo_json_events_update_{}" - SOURCE = "geo_json_events" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -108,11 +105,11 @@ def _generate_entity(self, external_id): def _update_entity(self, external_id): """Update entity.""" - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") def _remove_entity(self, external_id): """Remove entity.""" - dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") class GeoJsonLocationEvent(GeolocationEvent): @@ -133,12 +130,12 @@ async def async_added_to_hass(self): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - SIGNAL_DELETE_ENTITY.format(self._external_id), + f"geo_json_events_delete_{self._external_id}", self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"geo_json_events_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index b8891cdef0d2b5..22f02a4218c5d2 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -4,9 +4,6 @@ Retrieves current events (typically incidents or alerts) in GeoRSS format, and shows information on events filtered by distance to the HA instance's location and grouped by category. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.geo_rss_events/ """ from datetime import timedelta import logging @@ -121,9 +118,7 @@ def __init__( @property def name(self): """Return the name of the sensor.""" - return "{} {}".format( - self._service_name, "Any" if self._category is None else self._category - ) + return f"{self._service_name} {'Any' if self._category is None else self._category}" @property def state(self): diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 9afc9a8bfacbaa..cb663676512f41 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -114,7 +114,7 @@ def _is_mobile_beacon(data, mobile_beacons): def _device_name(data): """Return name of device tracker.""" if ATTR_BEACON_ID in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data["name"]) + return f"{BEACON_DEV_PREFIX}_{data['name']}" return data["device"] diff --git a/homeassistant/components/geonetnz_quakes/.translations/ko.json b/homeassistant/components/geonetnz_quakes/.translations/ko.json index 26caa2ebe54cad..66a216149ddad0 100644 --- a/homeassistant/components/geonetnz_quakes/.translations/ko.json +++ b/homeassistant/components/geonetnz_quakes/.translations/ko.json @@ -9,7 +9,7 @@ "mmi": "MMI", "radius": "\ubc18\uacbd" }, - "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694" + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." } }, "title": "GeoNet NZ Quakes" diff --git a/homeassistant/components/geonetnz_quakes/.translations/pl.json b/homeassistant/components/geonetnz_quakes/.translations/pl.json index fd82bba43b573e..bdd8f152d39cf4 100644 --- a/homeassistant/components/geonetnz_quakes/.translations/pl.json +++ b/homeassistant/components/geonetnz_quakes/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Lokalizacja ju\u017c zarejestrowana" + "identifier_exists": "Lokalizacja jest ju\u017c zarejestrowana." }, "step": { "user": { diff --git a/homeassistant/components/geonetnz_quakes/.translations/sv.json b/homeassistant/components/geonetnz_quakes/.translations/sv.json new file mode 100644 index 00000000000000..13058ad3ad2a3e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Plats redan registrerad" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radie" + }, + "title": "Fyll i dina filterdetaljer." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py index 141d05068473e3..fae8841bee3ae5 100644 --- a/homeassistant/components/geonetnz_quakes/__init__.py +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -34,10 +34,6 @@ DOMAIN, FEED, PLATFORMS, - SIGNAL_DELETE_ENTITY, - SIGNAL_NEW_GEOLOCATION, - SIGNAL_STATUS, - SIGNAL_UPDATE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -200,7 +196,7 @@ async def async_stop(self): @callback def async_event_new_entity(self): """Return manager specific event to signal new entity.""" - return SIGNAL_NEW_GEOLOCATION.format(self._config_entry_id) + return f"geonetnz_quakes_new_geolocation_{self._config_entry_id}" def get_entry(self, external_id): """Get feed entry by external id.""" @@ -222,14 +218,16 @@ async def _generate_entity(self, external_id): async def _update_entity(self, external_id): """Update entity.""" - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"geonetnz_quakes_update_{external_id}") async def _remove_entity(self, external_id): """Remove entity.""" - async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"geonetnz_quakes_delete_{external_id}") async def _status_update(self, status_info): """Propagate status update.""" _LOGGER.debug("Status update received: %s", status_info) self._status_info = status_info - async_dispatcher_send(self._hass, SIGNAL_STATUS.format(self._config_entry_id)) + async_dispatcher_send( + self._hass, f"geonetnz_quakes_status_{self._config_entry_id}" + ) diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index d564d407f7c7e9..43818b55f6f1d9 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -15,9 +15,3 @@ DEFAULT_MMI = 3 DEFAULT_RADIUS = 50.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) - -SIGNAL_DELETE_ENTITY = "geonetnz_quakes_delete_{}" -SIGNAL_UPDATE_ENTITY = "geonetnz_quakes_update_{}" -SIGNAL_STATUS = "geonetnz_quakes_status_{}" - -SIGNAL_NEW_GEOLOCATION = "geonetnz_quakes_new_geolocation_{}" diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index ae8b8fef48d95a..d7fd91d3d5be8f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -14,7 +14,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.unit_system import IMPERIAL_SYSTEM -from .const import DOMAIN, FEED, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY +from .const import DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -75,12 +75,12 @@ async def async_added_to_hass(self): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - SIGNAL_DELETE_ENTITY.format(self._external_id), + f"geonetnz_quakes_delete_{self._external_id}", self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"geonetnz_quakes_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 775ca8760bc374..50813b062f0eb6 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -3,7 +3,7 @@ "name": "GeoNet NZ Quakes", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes", - "requirements": ["aio_geojson_geonetnz_quakes==0.11"], + "requirements": ["aio_geojson_geonetnz_quakes==0.12"], "dependencies": [], "codeowners": ["@exxamalte"] } diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index e0be94d1b261d4..f5360c76c452fa 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -7,7 +7,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import dt -from .const import DOMAIN, FEED, SIGNAL_STATUS +from .const import DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -53,7 +53,7 @@ async def async_added_to_hass(self): """Call when entity is added to hass.""" self._remove_signal_status = async_dispatcher_connect( self.hass, - SIGNAL_STATUS.format(self._config_entry_id), + f"geonetnz_quakes_status_{self._config_entry_id}", self._update_status_callback, ) _LOGGER.debug("Waiting for updates %s", self._config_entry_id) diff --git a/homeassistant/components/geonetnz_volcano/.translations/ca.json b/homeassistant/components/geonetnz_volcano/.translations/ca.json index 2e595b7304046c..6874256e5fe72b 100644 --- a/homeassistant/components/geonetnz_volcano/.translations/ca.json +++ b/homeassistant/components/geonetnz_volcano/.translations/ca.json @@ -8,7 +8,7 @@ "data": { "radius": "Radi" }, - "title": "Introdueix els detalls del filtre." + "title": "Introducci\u00f3 dels detalls del filtre." } }, "title": "GeoNet NZ Volcano" diff --git a/homeassistant/components/geonetnz_volcano/.translations/hu.json b/homeassistant/components/geonetnz_volcano/.translations/hu.json new file mode 100644 index 00000000000000..e53a91bcb039c9 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "A hely m\u00e1r regisztr\u00e1lt" + }, + "step": { + "user": { + "data": { + "radius": "Sug\u00e1r" + }, + "title": "T\u00f6ltse ki a sz\u0171r\u0151 adatait." + } + }, + "title": "GeoNet NZ vulk\u00e1n" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/.translations/ko.json b/homeassistant/components/geonetnz_volcano/.translations/ko.json index 5d393fef4c49fb..d19091e75e85ac 100644 --- a/homeassistant/components/geonetnz_volcano/.translations/ko.json +++ b/homeassistant/components/geonetnz_volcano/.translations/ko.json @@ -8,7 +8,7 @@ "data": { "radius": "\ubc18\uacbd" }, - "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694" + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." } }, "title": "GeoNet NZ Volcano" diff --git a/homeassistant/components/geonetnz_volcano/.translations/pl.json b/homeassistant/components/geonetnz_volcano/.translations/pl.json index 7d329815f3fd4f..c51a69356a10f8 100644 --- a/homeassistant/components/geonetnz_volcano/.translations/pl.json +++ b/homeassistant/components/geonetnz_volcano/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Lokalizacja ju\u017c zarejestrowana" + "identifier_exists": "Lokalizacja jest ju\u017c zarejestrowana." }, "step": { "user": { diff --git a/homeassistant/components/geonetnz_volcano/.translations/sv.json b/homeassistant/components/geonetnz_volcano/.translations/sv.json new file mode 100644 index 00000000000000..35e7e24c926a01 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/.translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "Plats redan registrerad" + }, + "step": { + "user": { + "data": { + "radius": "Radie" + }, + "title": "Fyll i dina filterdetaljer." + } + }, + "title": "GeoNet NZ Volcano" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py index e24de7fdc5da9e..e2c6cb77083f49 100644 --- a/homeassistant/components/geonetnz_volcano/__init__.py +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -24,14 +24,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from .config_flow import configured_instances -from .const import ( - DEFAULT_RADIUS, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - FEED, - SIGNAL_NEW_SENSOR, - SIGNAL_UPDATE_ENTITY, -) +from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -173,7 +166,7 @@ async def async_stop(self): @callback def async_event_new_entity(self): """Return manager specific event to signal new entity.""" - return SIGNAL_NEW_SENSOR.format(self._config_entry_id) + return f"geonetnz_volcano_new_sensor_{self._config_entry_id}" def get_entry(self, external_id): """Get feed entry by external id.""" @@ -199,7 +192,7 @@ async def _generate_entity(self, external_id): async def _update_entity(self, external_id): """Update entity.""" - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"geonetnz_volcano_update_{external_id}") async def _remove_entity(self, external_id): """Ignore removing entity.""" diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py index 7bc15d3a6a1cd7..d48e9775f19598 100644 --- a/homeassistant/components/geonetnz_volcano/const.py +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -14,6 +14,3 @@ DEFAULT_ICON = "mdi:image-filter-hdr" DEFAULT_RADIUS = 50.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) - -SIGNAL_NEW_SENSOR = "geonetnz_volcano_new_sensor_{}" -SIGNAL_UPDATE_ENTITY = "geonetnz_volcano_update_{}" diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index f87ea88fc1cba8..3d5d0681f02222 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -23,7 +23,6 @@ DEFAULT_ICON, DOMAIN, FEED, - SIGNAL_UPDATE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -79,7 +78,7 @@ async def async_added_to_hass(self): """Call when entity is added to hass.""" self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"geonetnz_volcano_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/gios/.translations/ca.json b/homeassistant/components/gios/.translations/ca.json index 80fedcafdd97d9..dadd38c24ae01f 100644 --- a/homeassistant/components/gios/.translations/ca.json +++ b/homeassistant/components/gios/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "La integraci\u00f3 GIO\u015a per a aquesta estaci\u00f3 ja est\u00e0 configurada." + }, "error": { "cannot_connect": "No s'ha pogut connectar al servidor de GIO\u015a.", "invalid_sensors_data": "Les dades dels sensors d'aquesta estaci\u00f3 de mesura s\u00f3n inv\u00e0lides.", diff --git a/homeassistant/components/gios/.translations/es-419.json b/homeassistant/components/gios/.translations/es-419.json new file mode 100644 index 00000000000000..53439a7ab7be40 --- /dev/null +++ b/homeassistant/components/gios/.translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Nombre de la integraci\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/hu.json b/homeassistant/components/gios/.translations/hu.json new file mode 100644 index 00000000000000..75fcb2088a5b79 --- /dev/null +++ b/homeassistant/components/gios/.translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "A GIO\u015a integr\u00e1ci\u00f3 ehhez a m\u00e9r\u0151\u00e1llom\u00e1shoz m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni a GIO\u015a szerverhez.", + "invalid_sensors_data": "\u00c9rv\u00e9nytelen \u00e9rz\u00e9kel\u0151k adatai ehhez a m\u00e9r\u0151\u00e1llom\u00e1shoz.", + "wrong_station_id": "A m\u00e9r\u0151\u00e1llom\u00e1s azonos\u00edt\u00f3ja nem megfelel\u0151." + }, + "step": { + "user": { + "data": { + "name": "Az integr\u00e1ci\u00f3 neve", + "station_id": "A m\u00e9r\u0151\u00e1llom\u00e1s azonos\u00edt\u00f3ja" + }, + "description": "A GIO\u015a (lengyel k\u00f6rnyezetv\u00e9delmi f\u0151fel\u00fcgyel\u0151) leveg\u0151min\u0151s\u00e9gi integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha seg\u00edts\u00e9gre van sz\u00fcks\u00e9ged a konfigur\u00e1ci\u00f3val kapcsolatban, l\u00e1togass ide: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Lengyel K\u00f6rnyezetv\u00e9delmi F\u0151fel\u00fcgyel\u0151s\u00e9g)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/nl.json b/homeassistant/components/gios/.translations/nl.json new file mode 100644 index 00000000000000..eb48768183854b --- /dev/null +++ b/homeassistant/components/gios/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a-integratie voor dit meetstation is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met de GIO\u015a-server.", + "invalid_sensors_data": "Ongeldige sensorgegevens voor dit meetstation.", + "wrong_station_id": "ID van het meetstation is niet correct." + }, + "step": { + "user": { + "data": { + "name": "Naam van de integratie", + "station_id": "ID van het meetstation" + }, + "description": "GIO\u015a (Poolse hoofdinspectie van milieubescherming) luchtkwaliteitintegratie instellen. Als u hulp nodig hebt bij de configuratie, kijk dan hier: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Poolse hoofdinspectie van milieubescherming)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/ru.json b/homeassistant/components/gios/.translations/ru.json index 69ffff98517f8d..0045b08cec8b52 100644 --- a/homeassistant/components/gios/.translations/ru.json +++ b/homeassistant/components/gios/.translations/ru.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 GIO\u015a.", - "invalid_sensors_data": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438.", + "invalid_sensors_data": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438.", "wrong_station_id": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 ID \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438." }, "step": { diff --git a/homeassistant/components/gios/.translations/sl.json b/homeassistant/components/gios/.translations/sl.json index da3995dd0b341b..089435dee3ff08 100644 --- a/homeassistant/components/gios/.translations/sl.json +++ b/homeassistant/components/gios/.translations/sl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "GIO\u015a integracija za to merilno postajo je \u017ee nastavljena." + }, "error": { "cannot_connect": "Ne morem se povezati s stre\u017enikom GIO\u015a.", "invalid_sensors_data": "Neveljavni podatki senzorjev za to merilno postajo.", diff --git a/homeassistant/components/gios/.translations/sv.json b/homeassistant/components/gios/.translations/sv.json new file mode 100644 index 00000000000000..b5a865b5ccd15c --- /dev/null +++ b/homeassistant/components/gios/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a-integration f\u00f6r denna m\u00e4tstation \u00e4r redan konfigurerad." + }, + "error": { + "cannot_connect": "Det g\u00e5r inte att ansluta till GIO\u015a-servern.", + "invalid_sensors_data": "Ogiltig sensordata f\u00f6r denna m\u00e4tstation.", + "wrong_station_id": "M\u00e4tstationens ID \u00e4r inte korrekt." + }, + "step": { + "user": { + "data": { + "name": "Integrationens namn", + "station_id": "M\u00e4tstationens ID" + }, + "description": "St\u00e4ll in luftkvalitetintegration f\u00f6r GIO\u015a (polsk chefinspektorat f\u00f6r milj\u00f6skydd). Om du beh\u00f6ver hj\u00e4lp med konfigurationen titta h\u00e4r: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + }, + "title": "GIO\u015a" + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/es-419.json b/homeassistant/components/glances/.translations/es-419.json new file mode 100644 index 00000000000000..6debc6da6c1677 --- /dev/null +++ b/homeassistant/components/glances/.translations/es-419.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "cannot_connect": "No se puede conectar al host", + "wrong_version": "Versi\u00f3n no compatible (2 o 3 solamente)" + }, + "step": { + "user": { + "data": { + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Nombre de usuario", + "verify_ssl": "Verificar la certificaci\u00f3n del sistema", + "version": "Versi\u00f3n de API de Glances (2 o 3)" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frecuencia de actualizaci\u00f3n" + }, + "description": "Configurar opciones para Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/hu.json b/homeassistant/components/glances/.translations/hu.json new file mode 100644 index 00000000000000..1d7c2ea40230da --- /dev/null +++ b/homeassistant/components/glances/.translations/hu.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Kiszolg\u00e1l\u00f3 m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni a kiszolg\u00e1l\u00f3hoz", + "wrong_version": "Nem t\u00e1mogatott verzi\u00f3 (2 vagy 3 csak)" + }, + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "port": "Port", + "ssl": "Az SSL / TLS haszn\u00e1lat\u00e1val csatlakozzon a Glances rendszerhez", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "verify_ssl": "A rendszer tan\u00fas\u00edt\u00e1s\u00e1nak ellen\u0151rz\u00e9se", + "version": "Glances API-verzi\u00f3 (2 vagy 3)" + }, + "title": "Glances Be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g" + }, + "description": "A Glances be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/.translations/sv.json b/homeassistant/components/glances/.translations/sv.json new file mode 100644 index 00000000000000..f4b95081a10baa --- /dev/null +++ b/homeassistant/components/glances/.translations/sv.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad." + }, + "error": { + "cannot_connect": "Det g\u00e5r inte att ansluta till v\u00e4rden", + "wrong_version": "Version st\u00f6ds inte (endast 2 eller 3)" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "name": "Namn", + "password": "L\u00f6senord", + "port": "Port", + "ssl": "Anv\u00e4nd SSL / TLS f\u00f6r att ansluta till Glances-systemet", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "Verifiera certifieringen av systemet", + "version": "Glances API-version (2 eller 3)" + }, + "title": "St\u00e4ll in Glances" + } + }, + "title": "Glances" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Uppdateringsfrekvens" + }, + "description": "Konfigurera alternativ f\u00f6r Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index e47586ea245b5c..31a3f0f69e4bea 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,5 +1,5 @@ """Constants for Glances component.""" -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, TEMP_CELSIUS DOMAIN = "glances" CONF_VERSION = "version" @@ -14,23 +14,28 @@ SUPPORTED_VERSIONS = [2, 3] SENSOR_TYPES = { - "disk_use_percent": ["Disk used percent", "%", "mdi:harddisk"], - "disk_use": ["Disk used", "GiB", "mdi:harddisk"], - "disk_free": ["Disk free", "GiB", "mdi:harddisk"], - "memory_use_percent": ["RAM used percent", "%", "mdi:memory"], - "memory_use": ["RAM used", "MiB", "mdi:memory"], - "memory_free": ["RAM free", "MiB", "mdi:memory"], - "swap_use_percent": ["Swap used percent", "%", "mdi:memory"], - "swap_use": ["Swap used", "GiB", "mdi:memory"], - "swap_free": ["Swap free", "GiB", "mdi:memory"], - "processor_load": ["CPU load", "15 min", "mdi:memory"], - "process_running": ["Running", "Count", "mdi:memory"], - "process_total": ["Total", "Count", "mdi:memory"], - "process_thread": ["Thread", "Count", "mdi:memory"], - "process_sleeping": ["Sleeping", "Count", "mdi:memory"], - "cpu_use_percent": ["CPU used", "%", "mdi:memory"], - "cpu_temp": ["CPU Temp", TEMP_CELSIUS, "mdi:thermometer"], - "docker_active": ["Containers active", "", "mdi:docker"], - "docker_cpu_use": ["Containers CPU used", "%", "mdi:docker"], - "docker_memory_use": ["Containers RAM used", "MiB", "mdi:docker"], + "disk_use_percent": ["fs", "used percent", "%", "mdi:harddisk"], + "disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk"], + "disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk"], + "memory_use_percent": ["mem", "RAM used percent", "%", "mdi:memory"], + "memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory"], + "memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory"], + "swap_use_percent": ["memswap", "Swap used percent", "%", "mdi:memory"], + "swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory"], + "swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory"], + "processor_load": ["load", "CPU load", "15 min", "mdi:memory"], + "process_running": ["processcount", "Running", "Count", "mdi:memory"], + "process_total": ["processcount", "Total", "Count", "mdi:memory"], + "process_thread": ["processcount", "Thread", "Count", "mdi:memory"], + "process_sleeping": ["processcount", "Sleeping", "Count", "mdi:memory"], + "cpu_use_percent": ["cpu", "CPU used", "%", "mdi:memory"], + "sensor_temp": ["sensors", "Temp", TEMP_CELSIUS, "mdi:thermometer"], + "docker_active": ["docker", "Containers active", "", "mdi:docker"], + "docker_cpu_use": ["docker", "Containers CPU used", "%", "mdi:docker"], + "docker_memory_use": [ + "docker", + "Containers RAM used", + DATA_MEBIBYTES, + "mdi:docker", + ], } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 968081cfc43995..f701dfdb741a64 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -14,13 +14,51 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Glances sensors.""" - glances_data = hass.data[DOMAIN][config_entry.entry_id] + client = hass.data[DOMAIN][config_entry.entry_id] name = config_entry.data[CONF_NAME] dev = [] - for sensor_type in SENSOR_TYPES: - dev.append( - GlancesSensor(glances_data, name, SENSOR_TYPES[sensor_type][0], sensor_type) - ) + + for sensor_type, sensor_details in SENSOR_TYPES.items(): + if not sensor_details[0] in client.api.data: + continue + if sensor_details[0] in client.api.data: + if sensor_details[0] == "fs": + # fs will provide a list of disks attached + for disk in client.api.data[sensor_details[0]]: + dev.append( + GlancesSensor( + client, + name, + disk["mnt_point"], + SENSOR_TYPES[sensor_type][1], + sensor_type, + SENSOR_TYPES[sensor_type], + ) + ) + elif sensor_details[0] == "sensors": + # sensors will provide temp for different devices + for sensor in client.api.data[sensor_details[0]]: + dev.append( + GlancesSensor( + client, + name, + sensor["label"], + SENSOR_TYPES[sensor_type][1], + sensor_type, + SENSOR_TYPES[sensor_type], + ) + ) + elif client.api.data[sensor_details[0]]: + dev.append( + GlancesSensor( + client, + name, + "", + SENSOR_TYPES[sensor_type][1], + sensor_type, + SENSOR_TYPES[sensor_type], + ) + ) async_add_entities(dev, True) @@ -28,19 +66,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GlancesSensor(Entity): """Implementation of a Glances sensor.""" - def __init__(self, glances_data, name, sensor_name, sensor_type): + def __init__( + self, + glances_data, + name, + sensor_name_prefix, + sensor_name_suffix, + sensor_type, + sensor_details, + ): """Initialize the sensor.""" self.glances_data = glances_data - self._sensor_name = sensor_name + self._sensor_name_prefix = sensor_name_prefix + self._sensor_name_suffix = sensor_name_suffix self._name = name self.type = sensor_type self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.sensor_details = sensor_details + self.unsub_update = None @property def name(self): """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name}" + return f"{self._name} {self._sensor_name_prefix} {self._sensor_name_suffix}" @property def unique_id(self): @@ -50,12 +98,12 @@ def unique_id(self): @property def icon(self): """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] + return self.sensor_details[3] @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._unit_of_measurement + return self.sensor_details[2] @property def available(self): @@ -74,7 +122,7 @@ def should_poll(self): async def async_added_to_hass(self): """Handle entity which will be added.""" - async_dispatcher_connect( + self.unsub_update = async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) @@ -82,22 +130,40 @@ async def async_added_to_hass(self): def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) + async def will_remove_from_hass(self): + """Unsubscribe from update dispatcher.""" + if self.unsub_update: + self.unsub_update() + self.unsub_update = None + async def async_update(self): """Get the latest data from REST API.""" value = self.glances_data.api.data + if value is None: + return if value is not None: - if self.type == "disk_use_percent": - self._state = value["fs"][0]["percent"] - elif self.type == "disk_use": - self._state = round(value["fs"][0]["used"] / 1024 ** 3, 1) - elif self.type == "disk_free": - try: - self._state = round(value["fs"][0]["free"] / 1024 ** 3, 1) - except KeyError: - self._state = round( - (value["fs"][0]["size"] - value["fs"][0]["used"]) / 1024 ** 3, 1 - ) + if self.sensor_details[0] == "fs": + for var in value["fs"]: + if var["mnt_point"] == self._sensor_name_prefix: + disk = var + break + if self.type == "disk_use_percent": + self._state = disk["percent"] + elif self.type == "disk_use": + self._state = round(disk["used"] / 1024 ** 3, 1) + elif self.type == "disk_free": + try: + self._state = round(disk["free"] / 1024 ** 3, 1) + except KeyError: + self._state = round( + (disk["size"] - disk["used"]) / 1024 ** 3, 1, + ) + elif self.type == "sensor_temp": + for sensor in value["sensors"]: + if sensor["label"] == self._sensor_name_prefix: + self._state = sensor["value"] + break elif self.type == "memory_use_percent": self._state = value["mem"]["percent"] elif self.type == "memory_use": @@ -126,25 +192,6 @@ async def async_update(self): self._state = value["processcount"]["sleeping"] elif self.type == "cpu_use_percent": self._state = value["quicklook"]["cpu"] - elif self.type == "cpu_temp": - for sensor in value["sensors"]: - if sensor["label"] in [ - "amdgpu 1", - "aml_thermal", - "Core 0", - "Core 1", - "CPU Temperature", - "CPU", - "cpu-thermal 1", - "cpu_thermal 1", - "exynos-therm 1", - "Package id 0", - "Physical id 0", - "radeon 1", - "soc-thermal 1", - "soc_thermal 1", - ]: - self._state = sensor["value"] elif self.type == "docker_active": count = 0 try: diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index fcb0182ec0e4d6..62aea62bf84659 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -51,9 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + (f"Error: {ex}
You will need to restart hass after fixing."), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 0e7ccd33b33206..f3321416b1f688 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -144,17 +144,17 @@ def do_authentication(hass, hass_config, config): dev_flow = oauth.step1_get_device_and_user_codes() except OAuth2DeviceCodeError as err: hass.components.persistent_notification.create( - "Error: {}
You will need to restart hass after fixing." "".format(err), + f"Error: {err}
You will need to restart hass after fixing." "", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) return False hass.components.persistent_notification.create( - "In order to authorize Home-Assistant to view your calendars " - 'you must visit: {} and enter ' - "code: {}".format( - dev_flow.verification_url, dev_flow.verification_url, dev_flow.user_code + ( + f"In order to authorize Home-Assistant to view your calendars " + f'you must visit: {dev_flow.verification_url} and enter ' + f"code: {dev_flow.user_code}" ), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, @@ -182,8 +182,10 @@ def step2_exchange(now): do_setup(hass, hass_config, config) listener() hass.components.persistent_notification.create( - "We are all setup now. Check {} for calendars that have " - "been found".format(YAML_DEVICES), + ( + f"We are all setup now. Check {YAML_DEVICES} for calendars that have " + f"been found" + ), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index dcb87d1d93d3aa..c9f8d857b6241a 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -133,7 +133,6 @@ (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, (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, (sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR, } @@ -143,3 +142,8 @@ CHALLENGE_FAILED_PIN_NEEDED = "challengeFailedPinNeeded" STORE_AGENT_USER_IDS = "agent_user_ids" + +SOURCE_CLOUD = "cloud" +SOURCE_LOCAL = "local" + +NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 6493d759880983..6ba301c01e8b4c 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -28,6 +28,8 @@ DOMAIN, DOMAIN_TO_GOOGLE_TYPES, ERR_FUNCTION_NOT_SUPPORTED, + NOT_EXPOSE_LOCAL, + SOURCE_LOCAL, STORE_AGENT_USER_IDS, ) from .error import SmartHomeError @@ -121,6 +123,7 @@ async def async_report_state_all(self, message): ] await gather(*jobs) + @callback def async_enable_report_state(self): """Enable proactive mode.""" # Circular dep @@ -130,6 +133,7 @@ def async_enable_report_state(self): if self._unsub_report_state is None: self._unsub_report_state = async_enable_report_state(self.hass, self) + @callback def async_disable_report_state(self): """Disable report state.""" if self._unsub_report_state is not None: @@ -232,7 +236,7 @@ async def _handle_local_webhook(self, hass, webhook_id, request): return json_response(smart_home.turned_off_response(payload)) result = await smart_home.async_handle_message( - self.hass, self, self.local_sdk_user_id, payload + self.hass, self, self.local_sdk_user_id, payload, SOURCE_LOCAL ) if _LOGGER.isEnabledFor(logging.DEBUG): @@ -286,15 +290,22 @@ def __init__( self, config: AbstractConfig, user_id: str, + source: str, request_id: str, devices: Optional[List[dict]], ): """Initialize the request data.""" self.config = config + self.source = source self.request_id = request_id self.context = Context(user_id=user_id) self.devices = devices + @property + def is_local_request(self): + """Return if this is a local request.""" + return self.source == SOURCE_LOCAL + def get_google_type(domain, device_class): """Google type based on domain and device class.""" @@ -341,6 +352,18 @@ def should_expose(self): """If entity should be exposed.""" return self.config.should_expose(self.state) + @callback + def should_expose_local(self) -> bool: + """Return if the entity should be exposed locally.""" + return ( + self.should_expose() + and get_google_type( + self.state.domain, self.state.attributes.get(ATTR_DEVICE_CLASS) + ) + not in NOT_EXPOSE_LOCAL + and not self.might_2fa() + ) + @callback def is_supported(self) -> bool: """Return if the entity is supported by Google.""" @@ -354,6 +377,9 @@ def might_2fa(self) -> bool: features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) device_class = state.attributes.get(ATTR_DEVICE_CLASS) + if not self.config.should_2fa(state): + return False + return any( trait.might_2fa(domain, features, device_class) for trait in self.traits() ) @@ -386,9 +412,9 @@ async def sync_serialize(self, agent_user_id): # use aliases aliases = entity_config.get(CONF_ALIASES) if aliases: - device["name"]["nicknames"] = aliases + device["name"]["nicknames"] = [name] + aliases - if self.config.is_local_sdk_active: + if self.config.is_local_sdk_active and self.should_expose_local(): device["otherDeviceIds"] = [{"deviceId": self.entity_id}] device["customData"] = { "webhookId": self.config.local_sdk_webhook_id, diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f8fa51da8d7853..4c16e230e92be6 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -30,6 +30,7 @@ HOMEGRAPH_TOKEN_URL, REPORT_STATE_BASE_URL, REQUEST_SYNC_BASE_URL, + SOURCE_CLOUD, ) from .helpers import AbstractConfig from .smart_home import async_handle_message @@ -52,7 +53,7 @@ def _get_homegraph_jwt(time, iss, key): async def _get_homegraph_token(hass, jwt_signed): headers = { - "Authorization": "Bearer {}".format(jwt_signed), + "Authorization": f"Bearer {jwt_signed}", "Content-Type": "application/x-www-form-urlencoded", } data = { @@ -179,12 +180,12 @@ async def async_call_homegraph_api_key(self, url, data): return 500 async def async_call_homegraph_api(self, url, data): - """Call a homegraph api with authenticaiton.""" + """Call a homegraph api with authentication.""" session = async_get_clientsession(self.hass) async def _call(): headers = { - "Authorization": "Bearer {}".format(self._access_token), + "Authorization": f"Bearer {self._access_token}", "X-GFE-SSL": "yes", } async with session.post(url, headers=headers, json=data) as res: @@ -238,6 +239,10 @@ async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message: dict = await request.json() result = await async_handle_message( - request.app["hass"], self.config, request["hass_user"].id, message + request.app["hass"], + self.config, + request["hass_user"].id, + message, + SOURCE_CLOUD, ) return self.json(result) diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 1e8b6c020de1ef..d6bcafd3bff9a9 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -21,6 +21,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig """Enable state reporting.""" async def async_entity_state_listener(changed_entity, old_state, new_state): + if not hass.is_running: + return + if not new_state: return diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 8033bcec8650b9..97c872bdaf8dec 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -21,9 +21,11 @@ _LOGGER = logging.getLogger(__name__) -async def async_handle_message(hass, config, user_id, message): +async def async_handle_message(hass, config, user_id, message, source): """Handle incoming API messages.""" - data = RequestData(config, user_id, message["requestId"], message.get("devices")) + data = RequestData( + config, user_id, source, message["requestId"], message.get("devices") + ) response = await _process(hass, data, message) @@ -75,7 +77,9 @@ async def async_devices_sync(hass, data, payload): https://developers.google.com/assistant/smarthome/develop/process-intents#SYNC """ hass.bus.async_fire( - EVENT_SYNC_RECEIVED, {"request_id": data.request_id}, context=data.context + EVENT_SYNC_RECEIVED, + {"request_id": data.request_id, "source": data.source}, + context=data.context, ) agent_user_id = data.config.get_agent_user_id(data.context) @@ -108,7 +112,11 @@ async def async_devices_query(hass, data, payload): hass.bus.async_fire( EVENT_QUERY_RECEIVED, - {"request_id": data.request_id, ATTR_ENTITY_ID: devid}, + { + "request_id": data.request_id, + ATTR_ENTITY_ID: devid, + "source": data.source, + }, context=data.context, ) @@ -142,6 +150,7 @@ async def handle_devices_execute(hass, data, payload): "request_id": data.request_id, ATTR_ENTITY_ID: entity_id, "execution": execution, + "source": data.source, }, context=data.context, ) @@ -234,7 +243,7 @@ async def async_devices_reachable(hass, data: RequestData, payload): "devices": [ entity.reachable_device_serialize() for entity in async_get_entities(hass, data.config) - if entity.entity_id in google_ids and entity.should_expose() + if entity.entity_id in google_ids and entity.should_expose_local() ] } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 14839066ebefff..9da319226fa01d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -392,9 +392,7 @@ async def execute(self, command, data, params, challenge): if temp < min_temp or temp > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Temperature should be between {} and {}".format( - min_temp, max_temp - ), + f"Temperature should be between {min_temp} and {max_temp}", ) await self.hass.services.async_call( @@ -407,7 +405,7 @@ async def execute(self, command, data, params, challenge): elif "spectrumRGB" in params["color"]: # Convert integer to hex format and left pad with 0's till length 6 - hex_value = "{0:06x}".format(params["color"]["spectrumRGB"]) + hex_value = f"{params['color']['spectrumRGB']:06x}" color = color_util.color_RGB_to_hs( *color_util.rgb_hex_to_rgb_list(hex_value) ) @@ -746,9 +744,7 @@ async def execute(self, command, data, params, challenge): if temp < min_temp or temp > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Temperature should be between {} and {}".format( - min_temp, max_temp - ), + f"Temperature should be between {min_temp} and {max_temp}", ) await self.hass.services.async_call( @@ -769,8 +765,10 @@ async def execute(self, command, data, params, challenge): if temp_high < min_temp or temp_high > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Upper bound for temperature range should be between " - "{} and {}".format(min_temp, max_temp), + ( + f"Upper bound for temperature range should be between " + f"{min_temp} and {max_temp}" + ), ) temp_low = temp_util.convert( @@ -782,8 +780,10 @@ async def execute(self, command, data, params, challenge): if temp_low < min_temp or temp_low > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Lower bound for temperature range should be between " - "{} and {}".format(min_temp, max_temp), + ( + f"Lower bound for temperature range should be between " + f"{min_temp} and {max_temp}" + ), ) supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) @@ -1447,6 +1447,8 @@ def _verify_pin_challenge(data, state, challenge): def _verify_ack_challenge(data, state, challenge): - """Verify a pin challenge.""" + """Verify an ack challenge.""" + if not data.config.should_2fa(state): + return if not challenge or not challenge.get("ack"): raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index d440567d9ad937..ae6cb5c70d5d04 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -18,8 +18,6 @@ DEFAULT_TIMEOUT = 10 -UPDATE_URL = "https://{}:{}@domains.google.com/nic/update" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -62,7 +60,7 @@ async def update_domain_interval(now): async def _update_google_domains(hass, session, domain, user, password, timeout): """Update Google Domains.""" - url = UPDATE_URL.format(user, password) + url = f"https://{user}:{password}@domains.google.com/nic/update" params = {"hostname": domain} diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 9e33ff5f715468..7b48c12cc93d4a 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -55,9 +55,7 @@ def __init__(self, hass, config: ConfigType, see) -> None: self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) self._prev_seen = {} - credfile = "{}.{}".format( - hass.config.path(CREDENTIALS_FILE), slugify(self.username) - ) + credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}" try: self.service = Service(credfile, self.username) self._update_info() @@ -75,7 +73,7 @@ def __init__(self, hass, config: ConfigType, see) -> None: def _update_info(self, now=None): for person in self.service.get_all_people(): try: - dev_id = "google_maps_{0}".format(slugify(person.id)) + dev_id = f"google_maps_{slugify(person.id)}" except TypeError: _LOGGER.warning("No location(s) shared with this account") return diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 3ee72928fc1c16..213f773fb60c78 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -14,6 +14,7 @@ CONF_MODE, CONF_NAME, EVENT_HOMEASSISTANT_START, + TIME_MINUTES, ) from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv @@ -162,7 +163,7 @@ def run_setup(event): options[CONF_MODE] = travel_mode titled_mode = options.get(CONF_MODE).title() - formatted_name = "{} - {}".format(DEFAULT_NAME, titled_mode) + formatted_name = f"{DEFAULT_NAME} - {titled_mode}" name = config.get(CONF_NAME, formatted_name) api_key = config.get(CONF_API_KEY) origin = config.get(CONF_ORIGIN) @@ -188,7 +189,7 @@ def __init__(self, hass, name, api_key, origin, destination, options): self._hass = hass self._name = name self._options = options - self._unit_of_measurement = "min" + self._unit_of_measurement = TIME_MINUTES self._matrix = None self.valid_api_connection = True diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9d6f3ea3d58d10..9dfa26fab75c61 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -11,6 +11,7 @@ CONF_MONITORED_CONDITIONS, CONF_NAME, STATE_UNKNOWN, + TIME_DAYS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -39,7 +40,7 @@ "mdi:checkbox-marked-circle-outline", ], ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], - ATTR_UPTIME: [["system", "uptime"], "days", "mdi:timelapse"], + ATTR_UPTIME: [["system", "uptime"], TIME_DAYS, "mdi:timelapse"], ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"], ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"], ATTR_STATUS: [["wan", "online"], None, "mdi:google"], diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index dcd383a7463ed3..697a96649ab4a1 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -9,6 +9,9 @@ CONF_PORT, CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_STOP, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform @@ -40,10 +43,6 @@ TEMPERATURE_UNIT_CELSIUS = "C" -TIME_UNIT_SECOND = "s" -TIME_UNIT_MINUTE = "min" -TIME_UNIT_HOUR = "h" - TEMPERATURE_SENSOR_SCHEMA = vol.Schema( {vol.Required(CONF_NUMBER): vol.Range(1, 8), vol.Required(CONF_NAME): cv.string} ) @@ -69,8 +68,8 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_COUNTED_QUANTITY): cv.string, vol.Optional(CONF_COUNTED_QUANTITY_PER_PULSE, default=1.0): vol.Coerce(float), - vol.Optional(CONF_TIME_UNIT, default=TIME_UNIT_SECOND): vol.Any( - TIME_UNIT_SECOND, TIME_UNIT_MINUTE, TIME_UNIT_HOUR + vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.Any( + TIME_SECONDS, TIME_MINUTES, TIME_HOURS ), } ) diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index 88183acf918457..b10c4ad01a0c48 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -2,7 +2,7 @@ "domain": "greeneye_monitor", "name": "GreenEye Monitor (GEM)", "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", - "requirements": ["greeneye_monitor==1.0.1"], + "requirements": ["greeneye_monitor==2.0"], "dependencies": [], "codeowners": ["@jkeljo"] } diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 19ef7529b0a7e0..1d53525ab3739e 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,7 +1,14 @@ """Support for the sensors in a GreenEye Monitor.""" import logging -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + POWER_WATT, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, +) from homeassistant.helpers.entity import Entity from . import ( @@ -17,9 +24,6 @@ SENSOR_TYPE_PULSE_COUNTER, SENSOR_TYPE_TEMPERATURE, SENSOR_TYPE_VOLTAGE, - TIME_UNIT_HOUR, - TIME_UNIT_MINUTE, - TIME_UNIT_SECOND, ) _LOGGER = logging.getLogger(__name__) @@ -103,11 +107,7 @@ def should_poll(self): @property def unique_id(self): """Return a unique ID for this sensor.""" - return "{serial}-{sensor_type}-{number}".format( - serial=self._monitor_serial_number, - sensor_type=self._sensor_type, - number=self._number, - ) + return f"{self._monitor_serial_number}-{self._sensor_type }-{self._number}" @property def name(self): @@ -235,19 +235,17 @@ def state(self): @property def _seconds_per_time_unit(self): """Return the number of seconds in the given display time unit.""" - if self._time_unit == TIME_UNIT_SECOND: + if self._time_unit == TIME_SECONDS: return 1 - if self._time_unit == TIME_UNIT_MINUTE: + if self._time_unit == TIME_MINUTES: return 60 - if self._time_unit == TIME_UNIT_HOUR: + if self._time_unit == TIME_HOURS: return 3600 @property def unit_of_measurement(self): """Return the unit of measurement for this pulse counter.""" - return "{counted_quantity}/{time_unit}".format( - counted_quantity=self._counted_quantity, time_unit=self._time_unit - ) + return f"{self._counted_quantity}/{self._time_unit}" @property def device_state_attributes(self): @@ -294,12 +292,10 @@ class VoltageSensor(GEMSensor): def __init__(self, monitor_serial_number, number, name): """Construct the entity.""" super().__init__(monitor_serial_number, name, "volts", number) - self._monitor = None def _get_sensor(self, monitor): - """Wire the updates to a current channel.""" - self._monitor = monitor - return monitor.channels[self._number - 1] + """Wire the updates to the monitor itself, since there is no voltage element in the API.""" + return monitor @property def icon(self): @@ -309,10 +305,10 @@ def icon(self): @property def state(self): """Return the current voltage being reported by this sensor.""" - if not self._monitor.voltage: + if not self._sensor: return None - return self._monitor.voltage + return self._sensor.voltage @property def unit_of_measurement(self): diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index fc37f904e0de86..f8a10017cab1af 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -13,6 +13,8 @@ ATTR_NAME, CONF_ICON, CONF_NAME, + ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, SERVICE_RELOAD, STATE_CLOSED, STATE_HOME, @@ -28,7 +30,6 @@ ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change @@ -42,26 +43,18 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_ENTITIES = "entities" -CONF_VIEW = "view" -CONF_CONTROL = "control" CONF_ALL = "all" ATTR_ADD_ENTITIES = "add_entities" ATTR_AUTO = "auto" -ATTR_CONTROL = "control" ATTR_ENTITIES = "entities" ATTR_OBJECT_ID = "object_id" ATTR_ORDER = "order" -ATTR_VIEW = "view" -ATTR_VISIBLE = "visible" ATTR_ALL = "all" -SERVICE_SET_VISIBILITY = "set_visibility" SERVICE_SET = "set" SERVICE_REMOVE = "remove" -CONTROL_TYPES = vol.In(["hidden", None]) - _LOGGER = logging.getLogger(__name__) @@ -74,18 +67,14 @@ def _conf_preprocess(value): GROUP_SCHEMA = vol.All( - cv.deprecated(CONF_CONTROL, invalidation_version="0.107.0"), - cv.deprecated(CONF_VIEW, invalidation_version="0.107.0"), vol.Schema( { vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), - CONF_VIEW: cv.boolean, CONF_NAME: cv.string, CONF_ICON: cv.icon, - CONF_CONTROL: CONTROL_TYPES, CONF_ALL: cv.boolean, } - ), + ) ) CONFIG_SCHEMA = vol.Schema( @@ -134,7 +123,10 @@ def expand_entity_ids(hass: HomeAssistantType, entity_ids: Iterable[Any]) -> Lis """ found_ids: List[str] = [] for entity_id in entity_ids: - if not isinstance(entity_id, str): + if not isinstance(entity_id, str) or entity_id in ( + ENTITY_MATCH_NONE, + ENTITY_MATCH_ALL, + ): continue entity_id = entity_id.lower() @@ -239,7 +231,7 @@ async def locked_service_handler(service): async def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] - entity_id = ENTITY_ID_FORMAT.format(object_id) + entity_id = f"{DOMAIN}.{object_id}" group = component.get_entity(entity_id) # new group @@ -252,7 +244,7 @@ async def groups_service_handler(service): extra_arg = { attr: service.data[attr] - for attr in (ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL) + for attr in (ATTR_ICON,) if service.data.get(attr) is not None } @@ -288,22 +280,10 @@ async def groups_service_handler(service): group.name = service.data[ATTR_NAME] need_update = True - if ATTR_VISIBLE in service.data: - group.visible = service.data[ATTR_VISIBLE] - need_update = True - if ATTR_ICON in service.data: group.icon = service.data[ATTR_ICON] need_update = True - if ATTR_CONTROL in service.data: - group.control = service.data[ATTR_CONTROL] - need_update = True - - if ATTR_VIEW in service.data: - group.view = service.data[ATTR_VIEW] - need_update = True - if ATTR_ALL in service.data: group.mode = all if service.data[ATTR_ALL] else any need_update = True @@ -322,22 +302,16 @@ async def groups_service_handler(service): SERVICE_SET, locked_service_handler, schema=vol.All( - cv.deprecated(ATTR_CONTROL, invalidation_version="0.107.0"), - cv.deprecated(ATTR_VIEW, invalidation_version="0.107.0"), - cv.deprecated(ATTR_VISIBLE, invalidation_version="0.107.0"), vol.Schema( { vol.Required(ATTR_OBJECT_ID): cv.slug, vol.Optional(ATTR_NAME): cv.string, - vol.Optional(ATTR_VIEW): cv.boolean, vol.Optional(ATTR_ICON): cv.string, - vol.Optional(ATTR_CONTROL): CONTROL_TYPES, - vol.Optional(ATTR_VISIBLE): cv.boolean, vol.Optional(ATTR_ALL): cv.boolean, vol.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, vol.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, } - ), + ) ), ) @@ -348,32 +322,6 @@ async def groups_service_handler(service): schema=vol.Schema({vol.Required(ATTR_OBJECT_ID): cv.slug}), ) - async def visibility_service_handler(service): - """Change visibility of a group.""" - visible = service.data.get(ATTR_VISIBLE) - - _LOGGER.warning( - "The group.set_visibility service has been deprecated and will" - "be removed in Home Assistant 0.107.0." - ) - - tasks = [] - for group in await component.async_extract_from_service( - service, expand_group=False - ): - group.visible = visible - tasks.append(group.async_update_ha_state()) - - if tasks: - await asyncio.wait(tasks) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_VISIBILITY, - visibility_service_handler, - schema=make_entity_service_schema({vol.Required(ATTR_VISIBLE): cv.boolean}), - ) - return True @@ -383,21 +331,12 @@ async def _async_process_config(hass, config, component): name = conf.get(CONF_NAME, object_id) entity_ids = conf.get(CONF_ENTITIES) or [] icon = conf.get(CONF_ICON) - view = conf.get(CONF_VIEW) - control = conf.get(CONF_CONTROL) mode = conf.get(CONF_ALL) # Don't create tasks and await them all. The order is important as # groups get a number based on creation order. await Group.async_create_group( - hass, - name, - entity_ids, - icon=icon, - view=view, - control=control, - object_id=object_id, - mode=mode, + hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode ) @@ -409,10 +348,7 @@ def __init__( hass, name, order=None, - visible=True, icon=None, - view=False, - control=None, user_defined=True, entity_ids=None, mode=None, @@ -425,15 +361,12 @@ def __init__( self._name = name self._state = STATE_UNKNOWN self._icon = icon - self.view = view if entity_ids: self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) else: self.tracking = tuple() self.group_on = None self.group_off = None - self.visible = visible - self.control = control self.user_defined = user_defined self.mode = any if mode: @@ -448,26 +381,14 @@ def create_group( name, entity_ids=None, user_defined=True, - visible=True, icon=None, - view=False, - control=None, object_id=None, mode=None, ): """Initialize a group.""" return asyncio.run_coroutine_threadsafe( Group.async_create_group( - hass, - name, - entity_ids, - user_defined, - visible, - icon, - view, - control, - object_id, - mode, + hass, name, entity_ids, user_defined, icon, object_id, mode ), hass.loop, ).result() @@ -478,10 +399,7 @@ async def async_create_group( name, entity_ids=None, user_defined=True, - visible=True, icon=None, - view=False, - control=None, object_id=None, mode=None, ): @@ -493,10 +411,7 @@ async def async_create_group( hass, name, order=len(hass.states.async_entity_ids(DOMAIN)), - visible=visible, icon=icon, - view=view, - control=control, user_defined=user_defined, entity_ids=entity_ids, mode=mode, @@ -546,23 +461,12 @@ def icon(self, value): """Set Icon for group.""" self._icon = value - @property - def hidden(self): - """If group should be hidden or not.""" - if self.visible and not self.view: - return False - return True - @property def state_attributes(self): """Return the state attributes for the group.""" data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} if not self.user_defined: data[ATTR_AUTO] = True - if self.view: - data[ATTR_VIEW] = True - if self.control: - data[ATTR_CONTROL] = self.control return data @property diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index 68c2f04f06498b..98b0cef69c344a 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -3,37 +3,18 @@ reload: description: Reload group configuration. -set_visibility: - description: Hide or show a group. - fields: - entity_id: - description: Name(s) of entities to set value. - example: 'group.travel' - visible: - description: True if group should be shown or False if it should be hidden. - example: True - set: description: Create/Update a user group. fields: object_id: description: Group id and part of entity id. - example: 'test_group' + example: "test_group" name: description: Name of group - example: 'My test group' - view: - description: Boolean for if the group is a view. - example: True + example: "My test group" icon: description: Name of icon for the group. - example: 'mdi:camera' - control: - description: Value for control the group control. - example: 'hidden' - visible: - description: If the group is visible on UI. - example: True + example: "mdi:camera" entities: description: List of all members in the group. Not compatible with 'delta'. example: domain.entity_id1, domain.entity_id2 @@ -49,5 +30,4 @@ remove: fields: object_id: description: Group id and part of entity id. - example: 'test_group' - + example: "test_group" diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 07b450dd33e157..2bd0ce1b09f70a 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -143,7 +143,7 @@ def get_next_departure( tomorrow_where = f"OR calendar.{tomorrow_name} = 1" tomorrow_order = f"calendar.{tomorrow_name} DESC," - sql_query = """ + sql_query = f""" SELECT trip.trip_id, trip.route_id, time(origin_stop_time.arrival_time) AS origin_arrival_time, time(origin_stop_time.departure_time) AS origin_depart_time, @@ -162,8 +162,8 @@ def get_next_departure( destination_stop_time.stop_headsign AS dest_stop_headsign, destination_stop_time.stop_sequence AS dest_stop_sequence, destination_stop_time.timepoint AS dest_stop_timepoint, - calendar.{yesterday_name} AS yesterday, - calendar.{today_name} AS today, + calendar.{yesterday.strftime("%A").lower()} AS yesterday, + calendar.{now.strftime("%A").lower()} AS today, {tomorrow_select} calendar.start_date AS start_date, calendar.end_date AS end_date @@ -178,8 +178,8 @@ def get_next_departure( ON trip.trip_id = destination_stop_time.trip_id INNER JOIN stops end_station ON destination_stop_time.stop_id = end_station.stop_id - WHERE (calendar.{yesterday_name} = 1 - OR calendar.{today_name} = 1 + WHERE (calendar.{yesterday.strftime("%A").lower()} = 1 + OR calendar.{now.strftime("%A").lower()} = 1 {tomorrow_where} ) AND start_station.stop_id = :origin_station_id @@ -187,18 +187,12 @@ def get_next_departure( AND origin_stop_sequence < dest_stop_sequence AND calendar.start_date <= :today AND calendar.end_date >= :today - ORDER BY calendar.{yesterday_name} DESC, - calendar.{today_name} DESC, + ORDER BY calendar.{yesterday.strftime("%A").lower()} DESC, + calendar.{now.strftime("%A").lower()} DESC, {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """.format( - yesterday_name=yesterday.strftime("%A").lower(), - today_name=now.strftime("%A").lower(), - tomorrow_select=tomorrow_select, - tomorrow_where=tomorrow_where, - tomorrow_order=tomorrow_order, - ) + """ result = schedule.engine.execute( text(sql_query), origin_station_id=start_station_id, @@ -220,7 +214,7 @@ def get_next_departure( if yesterday_start is None: yesterday_start = row["origin_depart_date"] if yesterday_start != row["origin_depart_date"]: - idx = "{} {}".format(now_date, row["origin_depart_time"]) + idx = f"{now_date} {row['origin_depart_time']}" timetable[idx] = {**row, **extras} yesterday_last = idx @@ -233,7 +227,7 @@ def get_next_departure( idx_prefix = now_date else: idx_prefix = tomorrow_date - idx = "{} {}".format(idx_prefix, row["origin_depart_time"]) + idx = f"{idx_prefix} {row['origin_depart_time']}" timetable[idx] = {**row, **extras} today_last = idx @@ -247,7 +241,7 @@ def get_next_departure( tomorrow_start = row["origin_depart_date"] extras["first"] = True if tomorrow_start == row["origin_depart_date"]: - idx = "{} {}".format(tomorrow_date, row["origin_depart_time"]) + idx = f"{tomorrow_date} {row['origin_depart_time']}" timetable[idx] = {**row, **extras} # Flag last departures. @@ -273,24 +267,27 @@ def get_next_departure( origin_arrival = now if item["origin_arrival_time"] > item["origin_depart_time"]: origin_arrival -= datetime.timedelta(days=1) - origin_arrival_time = "{} {}".format( - origin_arrival.strftime(dt_util.DATE_STR_FORMAT), item["origin_arrival_time"] + origin_arrival_time = ( + f"{origin_arrival.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['origin_arrival_time']}" ) - origin_depart_time = "{} {}".format(now_date, item["origin_depart_time"]) + origin_depart_time = f"{now_date} {item['origin_depart_time']}" dest_arrival = now if item["dest_arrival_time"] < item["origin_depart_time"]: dest_arrival += datetime.timedelta(days=1) - dest_arrival_time = "{} {}".format( - dest_arrival.strftime(dt_util.DATE_STR_FORMAT), item["dest_arrival_time"] + dest_arrival_time = ( + f"{dest_arrival.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['dest_arrival_time']}" ) dest_depart = dest_arrival if item["dest_depart_time"] < item["dest_arrival_time"]: dest_depart += datetime.timedelta(days=1) - dest_depart_time = "{} {}".format( - dest_depart.strftime(dt_util.DATE_STR_FORMAT), item["dest_depart_time"] + dest_depart_time = ( + f"{dest_depart.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['dest_depart_time']}" ) depart_time = dt_util.parse_datetime(origin_depart_time) @@ -511,15 +508,13 @@ def update(self) -> None: else: self._icon = ICON - name = "{agency} {origin} to {destination} next departure" - if not self._departure: - name = "{default}" - self._name = self._custom_name or name.format( - agency=getattr(self._agency, "agency_name", DEFAULT_NAME), - default=DEFAULT_NAME, - origin=self.origin, - destination=self.destination, + name = ( + f"{getattr(self._agency, 'agency_name', DEFAULT_NAME)} " + f"{self.origin} to {self.destination} next departure" ) + if not self._departure: + name = f"{DEFAULT_NAME}" + self._name = self._custom_name or name def update_attributes(self) -> None: """Update state attributes.""" diff --git a/homeassistant/components/hangouts/.translations/es-419.json b/homeassistant/components/hangouts/.translations/es-419.json index 3a297eb15ea3dd..011060694a7610 100644 --- a/homeassistant/components/hangouts/.translations/es-419.json +++ b/homeassistant/components/hangouts/.translations/es-419.json @@ -5,6 +5,7 @@ "unknown": "Se produjo un error desconocido." }, "error": { + "invalid_2fa": "Autenticaci\u00f3n de 2 factores no v\u00e1lida, intente nuevamente.", "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo." }, "step": { diff --git a/homeassistant/components/hangouts/.translations/pl.json b/homeassistant/components/hangouts/.translations/pl.json index 5da1e21979970c..1d08296007abc6 100644 --- a/homeassistant/components/hangouts/.translations/pl.json +++ b/homeassistant/components/hangouts/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Google Hangouts jest ju\u017c skonfigurowany", + "already_configured": "Google Hangouts jest ju\u017c skonfigurowany.", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d." }, "error": { diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index fd14ec0b0949fe..b3dfdecac2aeab 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -7,6 +7,7 @@ import hangups from hangups import ChatMessageEvent, ChatMessageSegment, Client, get_auth, hangouts_pb2 +from homeassistant.core import callback from homeassistant.helpers import dispatcher, intent from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -75,6 +76,7 @@ def _resolve_conversation_name(self, name): return conv return None + @callback def async_update_conversation_commands(self): """Refresh the commands for every conversation.""" self._conversation_intents = {} @@ -110,6 +112,7 @@ def async_update_conversation_commands(self): self._async_handle_conversation_event ) + @callback def async_resolve_conversations(self, _): """Resolve the list of default and error suppressed conversations.""" self._default_conv_ids = [] diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index c48d5fb00b0642..bcc9d72ad08601 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -397,7 +397,7 @@ async def sync(self): def write_config_file(self): """Write Harmony configuration file.""" _LOGGER.debug( - "%s: Writing hub config to file: %s", self.name, self._config_path + "%s: Writing hub configuration to file: %s", self.name, self._config_path ) if self._client.config is None: _LOGGER.warning("%s: No configuration received from hub", self.name) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index f70e44cfa550ef..cc03f26085cb3c 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -194,7 +194,7 @@ async def async_setup(hass, config): await hass.components.panel_custom.async_register_panel( frontend_url_path="hassio", webcomponent_name="hassio-main", - sidebar_title="Hass.io", + sidebar_title="Supervisor", sidebar_icon="hass:home-assistant", js_url="/api/hassio/app/entrypoint.js", embed_iframe=True, diff --git a/homeassistant/components/hdmi_cec/services.yaml b/homeassistant/components/hdmi_cec/services.yaml index f2e5f0b837a402..63aee6680622d4 100644 --- a/homeassistant/components/hdmi_cec/services.yaml +++ b/homeassistant/components/hdmi_cec/services.yaml @@ -3,7 +3,7 @@ select_device: description: Select HDMI device. fields: device: {description: 'Address of device to select. Can be entity_id, physical - address or alias from confuguration.', example: '"switch.hdmi_1" or "1.1.0.0" + address or alias from configuration.', example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"'} send_command: description: Sends CEC command into HDMI CEC capable adapter. diff --git a/homeassistant/components/heos/.translations/es-419.json b/homeassistant/components/heos/.translations/es-419.json index 4d442a4543b50f..b0d1d7dc3fb94f 100644 --- a/homeassistant/components/heos/.translations/es-419.json +++ b/homeassistant/components/heos/.translations/es-419.json @@ -3,8 +3,12 @@ "abort": { "already_setup": "Solo puede configurar una sola conexi\u00f3n Heos, ya que ser\u00e1 compatible con todos los dispositivos de la red." }, + "error": { + "connection_failure": "No se puede conectar con el host especificado." + }, "step": { "user": { + "description": "Ingrese el nombre de host o la direcci\u00f3n IP de un dispositivo Heos (preferiblemente uno conectado por cable a la red).", "title": "Con\u00e9ctate a Heos" } }, diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 316e73dc09604b..8113548b5ca73b 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -18,6 +18,7 @@ CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, EVENT_HOMEASSISTANT_START, + TIME_MINUTES, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import location @@ -85,8 +86,6 @@ ATTR_ORIGIN_NAME = "origin_name" ATTR_DESTINATION_NAME = "destination_name" -UNIT_OF_MEASUREMENT = "min" - SCAN_INTERVAL = timedelta(minutes=5) NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input" @@ -209,7 +208,7 @@ def __init__( self._origin_entity_id = origin_entity_id self._destination_entity_id = destination_entity_id self._here_data = here_data - self._unit_of_measurement = UNIT_OF_MEASUREMENT + self._unit_of_measurement = TIME_MINUTES self._attrs = { ATTR_UNIT_SYSTEM: self._here_data.units, ATTR_MODE: self._here_data.travel_mode, diff --git a/homeassistant/components/hisense_aehw4a1/.translations/hu.json b/homeassistant/components/hisense_aehw4a1/.translations/hu.json new file mode 100644 index 00000000000000..02716a96a73926 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "A h\u00e1l\u00f3zaton nem tal\u00e1lhat\u00f3 Hisense AEH-W4A1 eszk\u00f6z.", + "single_instance_allowed": "Csak egy konfigur\u00e1ci\u00f3 lehet Hisense AEH-W4A1 eset\u00e9n." + }, + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Hisense AEH-W4A1-et?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/.translations/sv.json b/homeassistant/components/hisense_aehw4a1/.translations/sv.json new file mode 100644 index 00000000000000..6ec35452e8b332 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Hisense AEH-W4A1-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Hisense AEH-W4A1 \u00e4r m\u00f6jlig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + }, + "title": "Hisense AEH-W4A1" + } +} \ No newline at end of file diff --git a/homeassistant/components/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py deleted file mode 100644 index e132b1d5d4c50d..00000000000000 --- a/homeassistant/components/history_graph/__init__.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Support to graphs card in the UI.""" -import logging - -import voluptuous as vol - -from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITIES, CONF_NAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "history_graph" - -CONF_HOURS_TO_SHOW = "hours_to_show" -CONF_REFRESH = "refresh" -ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW -ATTR_REFRESH = CONF_REFRESH - - -GRAPH_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITIES): cv.entity_ids, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1), - vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0), - } -) - - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: cv.schema_with_slug_keys(GRAPH_SCHEMA)}, extra=vol.ALLOW_EXTRA -) - - -async def async_setup(hass, config): - """Load graph configurations.""" - _LOGGER.warning( - "The history_graph integration has been deprecated and is pending for removal " - "in Home Assistant 0.107.0." - ) - - component = EntityComponent(_LOGGER, DOMAIN, hass) - graphs = [] - - for object_id, cfg in config[DOMAIN].items(): - name = cfg.get(CONF_NAME, object_id) - graph = HistoryGraphEntity(name, cfg) - graphs.append(graph) - - await component.async_add_entities(graphs) - - return True - - -class HistoryGraphEntity(Entity): - """Representation of a graph entity.""" - - def __init__(self, name, cfg): - """Initialize the graph.""" - self._name = name - self._hours = cfg[CONF_HOURS_TO_SHOW] - self._refresh = cfg[CONF_REFRESH] - self._entities = cfg[CONF_ENTITIES] - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - def state_attributes(self): - """Return the state attributes.""" - attrs = { - ATTR_HOURS_TO_SHOW: self._hours, - ATTR_REFRESH: self._refresh, - ATTR_ENTITY_ID: self._entities, - } - return attrs diff --git a/homeassistant/components/history_graph/manifest.json b/homeassistant/components/history_graph/manifest.json deleted file mode 100644 index e34907d05ced33..00000000000000 --- a/homeassistant/components/history_graph/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "history_graph", - "name": "History Graph", - "documentation": "https://www.home-assistant.io/integrations/history_graph", - "requirements": [], - "dependencies": ["history"], - "codeowners": ["@andrey-git"], - "quality_scale": "internal" -} diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 3eb604b3957b47..0b1289ab05f0e7 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -13,6 +13,7 @@ CONF_STATE, CONF_TYPE, EVENT_HOMEASSISTANT_START, + TIME_HOURS, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError @@ -35,7 +36,7 @@ CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT] DEFAULT_NAME = "unnamed statistics" -UNITS = {CONF_TYPE_TIME: "h", CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""} +UNITS = {CONF_TYPE_TIME: TIME_HOURS, CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""} ICON = "mdi:chart-line" ATTR_VALUE = "value" diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 976821513b6183..edd3388e74f549 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -22,7 +22,7 @@ DOMAIN = "hive" DATA_HIVE = "data_hive" -SERVICES = ["Heating", "HotWater"] +SERVICES = ["Heating", "HotWater", "TRV"] SERVICE_BOOST_HOT_WATER = "boost_hot_water" SERVICE_BOOST_HEATING = "boost_heating" ATTR_TIME_PERIOD = "time_period" diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 6572b0dbda216d..96563d5ab3d7d1 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -2,7 +2,7 @@ "domain": "hive", "name": "Hive", "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.2.19.3"], + "requirements": ["pyhiveapi==0.2.20.1"], "dependencies": [], "codeowners": ["@Rendili", "@KJonline"] } diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 8aa1d7e020aca0..7a0ae33345a7a2 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -6,6 +6,7 @@ import voluptuous as vol +from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL import homeassistant.config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, @@ -19,8 +20,8 @@ SERVICE_TURN_ON, ) import homeassistant.core as ha -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, intent +from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_entity_ids _LOGGER = logging.getLogger(__name__) @@ -54,6 +55,15 @@ async def async_handle_turn_service(service): tasks = [] for domain, ent_ids in by_domain: + # This leads to endless loop. + if domain == DOMAIN: + _LOGGER.warning( + "Called service homeassistant.%s with invalid entity IDs %s", + service.service, + ", ".join(ent_ids), + ) + continue + # We want to block for all calls and only return when all calls # have been processed. If a service does not exist it causes a 10 # second delay while we're blocking waiting for a response. @@ -72,25 +82,19 @@ async def async_handle_turn_service(service): hass.services.async_call(domain, service.service, data, blocking) ) - await asyncio.wait(tasks) + if tasks: + await asyncio.gather(*tasks) + + service_schema = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}, extra=vol.ALLOW_EXTRA) - hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) - hass.services.async_register(ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) - hass.services.async_register(ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) - hass.helpers.intent.async_register( - intent.ServiceIntentHandler( - intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on" - ) + hass.services.async_register( + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service, schema=service_schema ) - hass.helpers.intent.async_register( - intent.ServiceIntentHandler( - intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned {} off" - ) + hass.services.async_register( + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service, schema=service_schema ) - hass.helpers.intent.async_register( - intent.ServiceIntentHandler( - intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}" - ) + hass.services.async_register( + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service, schema=service_schema ) async def async_handle_core_service(call): @@ -118,6 +122,25 @@ async def async_handle_core_service(call): async def async_handle_update_service(call): """Service handler for updating an entity.""" + 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, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + ) + + for entity in call.data[ATTR_ENTITY_ID]: + if not user.permissions.check_entity(entity, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + perm_category=CAT_ENTITIES, + ) + tasks = [ hass.helpers.entity_component.async_update_entity(entity) for entity in call.data[ATTR_ENTITY_ID] @@ -126,13 +149,13 @@ async def async_handle_update_service(call): if tasks: await asyncio.wait(tasks) - hass.services.async_register( + hass.helpers.service.async_register_admin_service( ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service ) - hass.services.async_register( + hass.helpers.service.async_register_admin_service( ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service ) - hass.services.async_register( + hass.helpers.service.async_register_admin_service( ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service ) hass.services.async_register( diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index a142c7875061fa..9dad912886d8cc 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -11,6 +11,7 @@ ATTR_ENTITY_ID, ATTR_STATE, CONF_ENTITIES, + CONF_ICON, CONF_ID, CONF_NAME, CONF_PLATFORM, @@ -75,16 +76,21 @@ def _ensure_no_intersection(value): DATA_PLATFORM = f"homeassistant_scene" STATES_SCHEMA = vol.All(dict, _convert_states) + PLATFORM_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): HA_DOMAIN, vol.Required(STATES): vol.All( cv.ensure_list, [ - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ENTITIES): STATES_SCHEMA, - } + vol.Schema( + { + vol.Optional(CONF_ID): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Required(CONF_ENTITIES): STATES_SCHEMA, + } + ) ], ), }, @@ -105,7 +111,7 @@ def _ensure_no_intersection(value): SERVICE_APPLY = "apply" SERVICE_CREATE = "create" -SCENECONFIG = namedtuple("SceneConfig", [CONF_NAME, STATES]) +SCENECONFIG = namedtuple("SceneConfig", [CONF_ID, CONF_NAME, CONF_ICON, STATES]) _LOGGER = logging.getLogger(__name__) @@ -213,7 +219,7 @@ async def create_service(call): _LOGGER.warning("Empty scenes are not allowed") return - scene_config = SCENECONFIG(call.data[CONF_SCENE_ID], entities) + scene_config = SCENECONFIG(None, call.data[CONF_SCENE_ID], None, entities) entity_id = f"{SCENE_DOMAIN}.{scene_config.name}" old = platform.entities.get(entity_id) if old is not None: @@ -239,8 +245,12 @@ def _process_scenes_config(hass, async_add_entities, config): async_add_entities( HomeAssistantScene( hass, - SCENECONFIG(scene[CONF_NAME], scene[CONF_ENTITIES]), - scene.get(CONF_ID), + SCENECONFIG( + scene.get(CONF_ID), + scene[CONF_NAME], + scene.get(CONF_ICON), + scene[CONF_ENTITIES], + ), ) for scene in scene_config ) @@ -249,9 +259,8 @@ def _process_scenes_config(hass, async_add_entities, config): class HomeAssistantScene(Scene): """A scene is a group of entities and the states we want them to be.""" - def __init__(self, hass, scene_config, scene_id=None, from_service=False): + def __init__(self, hass, scene_config, from_service=False): """Initialize the scene.""" - self._id = scene_id self.hass = hass self.scene_config = scene_config self.from_service = from_service @@ -261,12 +270,23 @@ def name(self): """Return the name of the scene.""" return self.scene_config.name + @property + def icon(self): + """Return the icon of the scene.""" + return self.scene_config.icon + + @property + def unique_id(self): + """Return unique ID.""" + return self.scene_config.id + @property def device_state_attributes(self): """Return the scene state attributes.""" attributes = {ATTR_ENTITY_ID: list(self.scene_config.states)} - if self._id is not None: - attributes[CONF_ID] = self._id + unique_id = self.unique_id + if unique_id is not None: + attributes[CONF_ID] = unique_id return attributes async def async_activate(self): diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 69e4554d81bc72..bbbc6561a878ca 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -2,7 +2,7 @@ "domain": "homekit", "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", - "requirements": ["HAP-python==2.6.0"], + "requirements": ["HAP-python==2.7.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 3fc6a0628ffcda..734568606b21b3 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -201,7 +201,7 @@ def update_state(self, new_state): # But if it is set to 0, HomeKit will update the brightness to 100 as # it thinks 0 is off. # - # Therefore, if the the brighness is 0 and the device is still on, + # Therefore, if the the brightness is 0 and the device is still on, # the brightness is mapped to 1 otherwise the update is ignored in # order to avoid this incorrect behavior. if brightness == 0: diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json index f2ed4bd0c21596..1d2331870e1b5c 100644 --- a/homeassistant/components/homekit_controller/.translations/ca.json +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -3,7 +3,7 @@ "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_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.", + "already_in_progress": "El flux de dades de configuraci\u00f3 pel dispositiu ja est\u00e0 en curs.", "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.", "invalid_config_entry": "Aquest dispositiu s'est\u00e0 mostrant com a llest per a ser vinculat per\u00f2, hi ha una entrada de configuraci\u00f3 conflictiva a Home Assistant que s'ha d'eliminar primer.", diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json index e6942a125cd04c..8223616f11e29a 100644 --- a/homeassistant/components/homekit_controller/.translations/de.json +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -24,7 +24,7 @@ "data": { "pairing_code": "Kopplungscode" }, - "description": "Gebe deinen HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", + "description": "Gib deinen HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", "title": "Mit HomeKit Zubeh\u00f6r koppeln" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json index 31731a52203a21..72aa720b44928f 100644 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -6,7 +6,7 @@ "already_in_progress": "Config flow for device is already in progress.", "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.", - "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 configuration entry for it in Home Assistant that must first be removed.", "no_devices": "No unpaired devices could be found" }, "error": { diff --git a/homeassistant/components/homekit_controller/.translations/es-419.json b/homeassistant/components/homekit_controller/.translations/es-419.json index 67a65f752b48f5..a99011cf8b1148 100644 --- a/homeassistant/components/homekit_controller/.translations/es-419.json +++ b/homeassistant/components/homekit_controller/.translations/es-419.json @@ -4,17 +4,26 @@ "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicie el accesorio y vuelva a intentarlo." }, + "error": { + "pairing_failed": "Se produjo un error no controlado al intentar vincularse con este dispositivo. Esto puede ser una falla temporal o su dispositivo puede no ser compatible actualmente.", + "unable_to_pair": "No se puede vincular, por favor intente nuevamente.", + "unknown_error": "El dispositivo inform\u00f3 un error desconocido. Vinculaci\u00f3n fallida." + }, "flow_title": "Accesorio HomeKit: {name}", "step": { "pair": { "data": { "pairing_code": "C\u00f3digo de emparejamiento" - } + }, + "description": "Ingrese su c\u00f3digo de emparejamiento de HomeKit (en el formato XXX-XX-XXX) para usar este accesorio", + "title": "Vincular con el accesorio HomeKit" }, "user": { "data": { "device": "Dispositivo" - } + }, + "description": "Seleccione el dispositivo con el que desea vincular", + "title": "Vincular con el accesorio HomeKit" } }, "title": "Accesorio HomeKit" diff --git a/homeassistant/components/homekit_controller/.translations/hu.json b/homeassistant/components/homekit_controller/.translations/hu.json index 60bd173dc8ecc2..264e635d7f4494 100644 --- a/homeassistant/components/homekit_controller/.translations/hu.json +++ b/homeassistant/components/homekit_controller/.translations/hu.json @@ -1,11 +1,40 @@ { "config": { + "abort": { + "accessory_not_found_error": "Nem adhat\u00f3 hozz\u00e1 p\u00e1ros\u00edt\u00e1s, mert az eszk\u00f6z m\u00e1r nem tal\u00e1lhat\u00f3.", + "already_configured": "A tartoz\u00e9k m\u00e1r konfigur\u00e1lva van ezzel a vez\u00e9rl\u0151vel.", + "already_in_progress": "Az eszk\u00f6z konfigur\u00e1ci\u00f3ja m\u00e1r folyamatban van.", + "already_paired": "Ez a tartoz\u00e9k m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik eszk\u00f6zzel. \u00c1ll\u00edtsa alaphelyzetbe a tartoz\u00e9kot, majd pr\u00f3b\u00e1lkozzon \u00fajra.", + "ignored_model": "A HomeKit t\u00e1mogat\u00e1sa e modelln\u00e9l blokkolva van, mivel a szolg\u00e1ltat\u00e1shoz teljes nat\u00edv integr\u00e1ci\u00f3 \u00e9rhet\u0151 el.", + "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s a Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "no_devices": "Nem tal\u00e1lhat\u00f3 nem p\u00e1ros\u00edtott eszk\u00f6z" + }, + "error": { + "authentication_error": "Helytelen HomeKit k\u00f3d. K\u00e9rj\u00fck, ellen\u0151rizze, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "busy_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik vez\u00e9rl\u0151vel.", + "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs ingyenes p\u00e1ros\u00edt\u00e1si t\u00e1rhely.", + "max_tries_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel t\u00f6bb mint 100 sikertelen hiteles\u00edt\u00e9si k\u00eds\u00e9rletet kapott.", + "pairing_failed": "Nem kezelt hiba t\u00f6rt\u00e9nt az eszk\u00f6zzel val\u00f3 p\u00e1ros\u00edt\u00e1s sor\u00e1n. Lehet, hogy ez \u00e1tmeneti hiba, vagy jelenleg nem t\u00e1mogatja az eszk\u00f6zt.", + "unable_to_pair": "Nem siker\u00fclt p\u00e1ros\u00edtani, pr\u00f3b\u00e1ld \u00fajra.", + "unknown_error": "Az eszk\u00f6z ismeretlen hib\u00e1t jelentett. A p\u00e1ros\u00edt\u00e1s sikertelen." + }, + "flow_title": "HomeKit tartoz\u00e9k: {name}", "step": { + "pair": { + "data": { + "pairing_code": "P\u00e1ros\u00edt\u00e1si k\u00f3d" + }, + "description": "\u00cdrja be a HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban) a kieg\u00e9sz\u00edt\u0151 haszn\u00e1lat\u00e1hoz", + "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" + }, "user": { "data": { "device": "Eszk\u00f6z" - } + }, + "description": "V\u00e1lassza ki azt az eszk\u00f6zt, amelyet p\u00e1ros\u00edtani szeretne", + "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" } - } + }, + "title": "HomeKit tartoz\u00e9k" } } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/it.json b/homeassistant/components/homekit_controller/.translations/it.json index 7ed026a529c2fd..69bb1f13c84cf5 100644 --- a/homeassistant/components/homekit_controller/.translations/it.json +++ b/homeassistant/components/homekit_controller/.translations/it.json @@ -6,7 +6,7 @@ "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Si prega di resettare l'accessorio e riprovare.", "ignored_model": "Il supporto di HomeKit per questo modello \u00e8 bloccato poich\u00e9 \u00e8 disponibile un'integrazione nativa con pi\u00f9 funzionalit\u00e0.", - "invalid_config_entry": "Questo dispositivo viene visualizzato come pronto per l'associazione, ma c'\u00e8 gi\u00e0 una voce di configurazione in conflitto in Home Assistant che deve prima essere rimossa.", + "invalid_config_entry": "Questo dispositivo viene visualizzato come pronto per l'associazione, ma c'\u00e8 gi\u00e0 una voce di configurazione in conflitto in Home Assistant che prima deve essere rimossa.", "no_devices": "Non \u00e8 stato possibile trovare dispositivi non associati" }, "error": { diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json index db8b8b035e0001..a2816fa92f0629 100644 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -6,7 +6,7 @@ "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.", "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.", - "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.", + "invalid_config_entry": "Denne enheten vises som klar til sammenkobling, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Hjelpeassistenten som f\u00f8rst m\u00e5 fjernes.", "no_devices": "Ingen ukoblede enheter ble funnet" }, "error": { diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json index e66353c5000d75..33cd20dc9c9578 100644 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -3,7 +3,7 @@ "abort": { "accessory_not_found_error": "Nie mo\u017cna rozpocz\u0105\u0107 parowania, poniewa\u017c nie znaleziono urz\u0105dzenia.", "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", - "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.", + "already_in_progress": "Konfiguracja urz\u0105dzenia jest ju\u017c w toku.", "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json index 68e87e9aea8bee..c2092c2016b968 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hant.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -6,7 +6,7 @@ "already_in_progress": "\u8a2d\u5099\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u8a2d\u5099\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", - "invalid_config_entry": "\u8a2d\u5099\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002", + "invalid_config_entry": "\u6b64\u8a2d\u5099\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002", "no_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u914d\u5c0d\u8a2d\u5099" }, "error": { diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index dc65796a569cb7..3477e23897a2da 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,8 +1,9 @@ """Support for Homekit device discovery.""" import logging +import os -import homekit -from homekit.model.characteristics import CharacteristicsTypes +import aiohomekit +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -63,7 +64,7 @@ def should_poll(self) -> bool: return False def setup(self): - """Configure an entity baed on its HomeKit characterstics metadata.""" + """Configure an entity baed on its HomeKit characteristics metadata.""" accessories = self._accessory.accessories get_uuid = CharacteristicsTypes.get_uuid @@ -94,7 +95,8 @@ def setup(self): def _setup_characteristic(self, char): """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() - self.pollable_characteristics.append((self._aid, char["iid"])) + if "pr" in char["perms"]: + self.pollable_characteristics.append((self._aid, char["iid"])) # Build a map of ctype -> iid short_name = CharacteristicsTypes.get_short(char["type"]) @@ -124,7 +126,7 @@ def async_state_changed(self): """Collect new data from bridge and update the entity state in hass.""" accessory_state = self._accessory.current_state.get(self._aid, {}) for iid, result in accessory_state.items(): - # No value so dont process this result + # No value so don't process this result if "value" not in result: continue @@ -223,9 +225,19 @@ async def async_setup(hass, config): map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - hass.data[CONTROLLER] = homekit.Controller() + hass.data[CONTROLLER] = aiohomekit.Controller() hass.data[KNOWN_DEVICES] = {} + dothomekit_dir = hass.config.path(".homekit") + if os.path.exists(dothomekit_dir): + _LOGGER.warning( + ( + "Legacy homekit_controller state found in %s. Support for reading " + "the folder is deprecated and will be removed in 0.109.0." + ), + dothomekit_dir, + ) + return True diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py index 0419c0354e61ab..b2145887cef7cf 100644 --- a/homeassistant/components/homekit_controller/air_quality.py +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -1,7 +1,8 @@ """Support for HomeKit Controller air quality sensors.""" -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -83,6 +84,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): if service["stype"] != "air-quality": return False diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 800c988279a35d..d0ddd8ae816bd0 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -1,7 +1,7 @@ """Support for Homekit Alarm Control Panel.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.components.alarm_control_panel.const import ( @@ -17,6 +17,7 @@ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -45,6 +46,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): if service["stype"] != "security-system": return False diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 9fd93cf732abc0..7ca7f7a5711321 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -1,12 +1,16 @@ """Support for Homekit motion sensors.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, BinarySensorDevice, ) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -31,7 +35,7 @@ def _update_motion_detected(self, value): @property def device_class(self): """Define this binary_sensor as a motion sensor.""" - return "motion" + return DEVICE_CLASS_MOTION @property def is_on(self): @@ -54,6 +58,11 @@ def get_characteristic_types(self): def _update_contact_state(self, value): self._state = value + @property + def device_class(self): + """Define this binary_sensor as a opening sensor.""" + return DEVICE_CLASS_OPENING + @property def is_on(self): """Return true if the binary sensor is on/open.""" @@ -86,10 +95,37 @@ def is_on(self): return self._state == 1 +class HomeKitOccupancySensor(HomeKitEntity, BinarySensorDevice): + """Representation of a Homekit smoke sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_OCCUPANCY + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.OCCUPANCY_DETECTED] + + def _update_occupancy_detected(self, value): + self._state = value + + @property + def is_on(self): + """Return true if smoke is currently detected.""" + return self._state == 1 + + ENTITY_TYPES = { "motion": HomeKitMotionSensor, "contact": HomeKitContactSensor, "smoke": HomeKitSmokeSensor, + "occupancy": HomeKitOccupancySensor, } @@ -98,6 +134,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): entity_class = ENTITY_TYPES.get(service["stype"]) if not entity_class: diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index ff234f566c7a0a..b294bb9bb713d5 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -1,7 +1,7 @@ """Support for Homekit climate devices.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.climate import ( DEFAULT_MAX_HUMIDITY, @@ -20,6 +20,7 @@ SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -50,6 +51,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): if service["stype"] != "thermostat": return False diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 507a5cbb70a923..4b713636beb9b9 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -4,8 +4,8 @@ import os import re -import homekit -from homekit.controller.ip_implementation import IpPairing +import aiohomekit +from aiohomekit.controller.ip import IpPairing import voluptuous as vol from homeassistant import config_entries @@ -58,7 +58,7 @@ def normalize_hkid(hkid): def find_existing_host(hass, serial): """Return a set of the configured hosts.""" for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data["AccessoryPairingID"] == serial: + if entry.data.get("AccessoryPairingID") == serial: return entry @@ -72,7 +72,7 @@ def ensure_pin_format(pin): """ match = PIN_FORMAT.search(pin) if not match: - raise homekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") + raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") return "{}-{}-{}".format(*match.groups()) @@ -88,7 +88,7 @@ def __init__(self): self.model = None self.hkid = None self.devices = {} - self.controller = homekit.Controller() + self.controller = aiohomekit.Controller() self.finish_pairing = None async def async_step_user(self, user_input=None): @@ -97,22 +97,22 @@ async def async_step_user(self, user_input=None): if user_input is not None: key = user_input["device"] - self.hkid = self.devices[key]["id"] - self.model = self.devices[key]["md"] + self.hkid = self.devices[key].device_id + self.model = self.devices[key].info["md"] await self.async_set_unique_id( normalize_hkid(self.hkid), raise_on_progress=False ) return await self.async_step_pair() - all_hosts = await self.hass.async_add_executor_job(self.controller.discover, 5) + all_hosts = await self.controller.discover_ip() self.devices = {} for host in all_hosts: - status_flags = int(host["sf"]) + status_flags = int(host.info["sf"]) paired = not status_flags & 0x01 if paired: continue - self.devices[host["name"]] = host + self.devices[host.info["name"]] = host if not self.devices: return self.async_abort(reason="no_devices") @@ -130,10 +130,11 @@ async def async_step_unignore(self, user_input): unique_id = user_input["unique_id"] await self.async_set_unique_id(unique_id) - records = await self.hass.async_add_executor_job(self.controller.discover, 5) - for record in records: - if normalize_hkid(record["id"]) != unique_id: + devices = await self.controller.discover_ip(5) + for device in devices: + if normalize_hkid(device.device_id) != unique_id: continue + record = device.info return await self.async_step_zeroconf( { "host": record["address"], @@ -201,6 +202,14 @@ async def async_step_zeroconf(self, discovery_info): _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # Device isn't paired with us or anyone else. + # But we have a 'complete' config entry for it - that is probably + # invalid. Remove it automatically. + existing = find_existing_host(self.hass, hkid) + if not paired and existing: + await self.hass.config_entries.async_remove(existing.entry_id) + + # Set unique-id and error out if it's already configured await self.async_set_unique_id(normalize_hkid(hkid)) self._abort_if_unique_id_configured() @@ -228,13 +237,6 @@ async def async_step_zeroconf(self, discovery_info): if model in HOMEKIT_IGNORE: return self.async_abort(reason="ignored_model") - # Device isn't paired with us or anyone else. - # But we have a 'complete' config entry for it - that is probably - # invalid. Remove it automatically. - existing = find_existing_host(self.hass, hkid) - if existing: - await self.hass.config_entries.async_remove(existing.entry_id) - self.model = model self.hkid = hkid @@ -248,21 +250,10 @@ async def async_import_legacy_pairing(self, discovery_props, pairing_data): hkid = discovery_props["id"] - existing = find_existing_host(self.hass, hkid) - if existing: - _LOGGER.info( - ( - "Legacy configuration for homekit accessory %s" - "not loaded as already migrated" - ), - hkid, - ) - return self.async_abort(reason="already_configured") - _LOGGER.info( ( "Legacy configuration %s for homekit" - "accessory migrated to config entries" + "accessory migrated to configuration entries" ), hkid, ) @@ -295,55 +286,49 @@ async def async_step_pair(self, pair_info=None): code = pair_info["pairing_code"] try: code = ensure_pin_format(code) - - await self.hass.async_add_executor_job(self.finish_pairing, code) - - pairing = self.controller.pairings.get(self.hkid) - if pairing: - return await self._entry_from_accessory(pairing) - - errors["pairing_code"] = "unable_to_pair" - except homekit.exceptions.MalformedPinError: + pairing = await self.finish_pairing(code) + return await self._entry_from_accessory(pairing) + except aiohomekit.exceptions.MalformedPinError: # Library claimed pin was invalid before even making an API call errors["pairing_code"] = "authentication_error" - except homekit.AuthenticationError: + except aiohomekit.AuthenticationError: # PairSetup M4 - SRP proof failed # PairSetup M6 - Ed25519 signature verification failed # PairVerify M4 - Decryption failed # PairVerify M4 - Device not recognised # PairVerify M4 - Ed25519 signature verification failed errors["pairing_code"] = "authentication_error" - except homekit.UnknownError: + except aiohomekit.UnknownError: # An error occurred on the device whilst performing this # operation. errors["pairing_code"] = "unknown_error" - except homekit.MaxPeersError: + except aiohomekit.MaxPeersError: # The device can't pair with any more accessories. errors["pairing_code"] = "max_peers_error" - except homekit.AccessoryNotFoundError: + except aiohomekit.AccessoryNotFoundError: # Can no longer find the device on the network return self.async_abort(reason="accessory_not_found_error") except Exception: # pylint: disable=broad-except _LOGGER.exception("Pairing attempt failed with an unhandled exception") errors["pairing_code"] = "pairing_failed" - start_pairing = self.controller.start_pairing + discovery = await self.controller.find_ip_by_device_id(self.hkid) + try: - self.finish_pairing = await self.hass.async_add_executor_job( - start_pairing, self.hkid, self.hkid - ) - except homekit.BusyError: + self.finish_pairing = await discovery.start_pairing(self.hkid) + + except aiohomekit.BusyError: # Already performing a pair setup operation with a different # controller errors["pairing_code"] = "busy_error" - except homekit.MaxTriesError: + except aiohomekit.MaxTriesError: # The accessory has received more than 100 unsuccessful auth # attempts. errors["pairing_code"] = "max_tries_error" - except homekit.UnavailableError: + except aiohomekit.UnavailableError: # The accessory is already paired - cannot try to pair again. return self.async_abort(reason="already_paired") - except homekit.AccessoryNotFoundError: + except aiohomekit.AccessoryNotFoundError: # Can no longer find the device on the network return self.async_abort(reason="accessory_not_found_error") except Exception: # pylint: disable=broad-except @@ -352,6 +337,7 @@ async def async_step_pair(self, pair_info=None): return self._async_step_pair_show_form(errors) + @callback def _async_step_pair_show_form(self, errors=None): return self.async_show_form( step_id="pair", @@ -375,9 +361,7 @@ async def _entry_from_accessory(self, pairing): # the same time. accessories = pairing_data.pop("accessories", None) if not accessories: - accessories = await self.hass.async_add_executor_job( - pairing.list_accessories_and_characteristics - ) + accessories = await pairing.list_accessories_and_characteristics() bridge_info = get_bridge_information(accessories) name = get_accessory_name(bridge_info) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index f3e728c6cdc493..154f995577995a 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -3,15 +3,16 @@ import datetime import logging -from homekit.controller.ip_implementation import IpPairing -from homekit.exceptions import ( +from aiohomekit.controller.ip import IpPairing +from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, EncryptionError, ) -from homekit.model.characteristics import CharacteristicsTypes -from homekit.model.services import ServicesTypes +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes +from homeassistant.core import callback from homeassistant.helpers.event import async_track_time_interval from .const import DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH @@ -76,7 +77,7 @@ def __init__(self, hass, config_entry, pairing_data): # The platorms we have forwarded the config entry so far. If a new # accessory is added to a bridge we may have to load additional # platforms. We don't want to load all platforms up front if its just - # a lightbulb. And we dont want to forward a config entry twice + # a lightbulb. And we don't want to forward a config entry twice # (triggers a Config entry already set up error) self.platforms = set() @@ -116,6 +117,7 @@ def remove_pollable_characteristics(self, accessory_id): char for char in self.pollable_characteristics if char[0] != accessory_id ] + @callback def async_set_unavailable(self): """Mark state of all entities on this connection as unavailable.""" self.available = False @@ -184,10 +186,7 @@ async def async_unload(self): async def async_refresh_entity_map(self, config_num): """Handle setup of a HomeKit accessory.""" try: - async with self.pairing_lock: - self.accessories = await self.hass.async_add_executor_job( - self.pairing.list_accessories_and_characteristics - ) + self.accessories = await self.pairing.list_accessories_and_characteristics() except AccessoryDisconnectedError: # If we fail to refresh this data then we will naturally retry # later when Bonjour spots c# is still not up to date. @@ -303,10 +302,7 @@ def process_new_events(self, new_values_dict): async def get_characteristics(self, *args, **kwargs): """Read latest state from homekit accessory.""" async with self.pairing_lock: - chars = await self.hass.async_add_executor_job( - self.pairing.get_characteristics, *args, **kwargs - ) - return chars + return await self.pairing.get_characteristics(*args, **kwargs) async def put_characteristics(self, characteristics): """Control a HomeKit device state from Home Assistant.""" @@ -315,9 +311,7 @@ async def put_characteristics(self, characteristics): chars.append((row["aid"], row["iid"], row["value"])) async with self.pairing_lock: - results = await self.hass.async_add_executor_job( - self.pairing.put_characteristics, chars - ) + results = await self.pairing.put_characteristics(chars) # Feed characteristics back into HA and update the current state # results will only contain failures, so anythin in characteristics @@ -329,7 +323,7 @@ async def put_characteristics(self, characteristics): key = (row["aid"], row["iid"]) # If the key was returned by put_characteristics() then the - # change didnt work + # change didn't work if key in results: continue diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 684f83ba5d4f16..9c750b17e8f642 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -30,4 +30,5 @@ "fan": "fan", "fanv2": "fan", "air-quality": "air_quality", + "occupancy": "binary_sensor", } diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index dec94771b0368b..2799d1d76a6b62 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -1,7 +1,7 @@ """Support for Homekit covers.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.cover import ( ATTR_POSITION, @@ -16,6 +16,7 @@ CoverDevice, ) from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -41,6 +42,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): info = {"aid": aid, "iid": service["iid"]} if service["stype"] == "garage-door-opener": diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index a6c4ae769e2d5c..24bb5b96503194 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -1,7 +1,7 @@ """Support for Homekit fans.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -15,6 +15,7 @@ SUPPORT_SET_SPEED, FanEntity, ) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -235,6 +236,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): entity_class = ENTITY_TYPES.get(service["stype"]) if not entity_class: diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 9ce262291b316f..5978455cf6ff97 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -1,7 +1,7 @@ """Support for Homekit lights.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -12,6 +12,7 @@ SUPPORT_COLOR_TEMP, Light, ) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -23,6 +24,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): if service["stype"] != "lightbulb": return False diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 5183a636f0f636..fc046c704b9101 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -1,10 +1,11 @@ """Support for HomeKit Controller locks.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -22,6 +23,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): if service["stype"] != "lock-mechanism": return False diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c7eb02a479c11b..cd2d0c67b44737 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["homekit[IP]==0.15.0"], + "requirements": ["aiohomekit[IP]==0.2.11"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 0e3680db3468d1..ab8a6fa6672469 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,7 +1,15 @@ """Support for Homekit sensors.""" -from homekit.model.characteristics import CharacteristicsTypes - -from homeassistant.const import DEVICE_CLASS_BATTERY, TEMP_CELSIUS +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -12,7 +20,6 @@ UNIT_PERCENT = "%" UNIT_LUX = "lux" -UNIT_CO2 = "ppm" class HomeKitHumiditySensor(HomeKitEntity): @@ -27,6 +34,11 @@ def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_HUMIDITY + @property def name(self): """Return the name of the device.""" @@ -63,6 +75,11 @@ def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.TEMPERATURE_CURRENT] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE + @property def name(self): """Return the name of the device.""" @@ -99,6 +116,11 @@ def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_ILLUMINANCE + @property def name(self): """Return the name of the device.""" @@ -148,7 +170,7 @@ def icon(self): @property def unit_of_measurement(self): """Return units for the sensor.""" - return UNIT_CO2 + return CONCENTRATION_PARTS_PER_MILLION def _update_carbon_dioxide_level(self, value): self._state = value @@ -246,6 +268,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): entity_class = ENTITY_TYPES.get(service["stype"]) if not entity_class: diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py index ffc2da5fbf213c..ffc5bdc23818b9 100644 --- a/homeassistant/components/homekit_controller/storage.py +++ b/homeassistant/components/homekit_controller/storage.py @@ -45,6 +45,7 @@ def get_map(self, homekit_id): """Get a pairing cache item.""" return self.storage_data.get(homekit_id) + @callback def async_create_or_update_map(self, homekit_id, config_num, accessories): """Create a new pairing cache.""" data = {"config_num": config_num, "accessories": accessories} @@ -52,6 +53,7 @@ def async_create_or_update_map(self, homekit_id, config_num, accessories): self._async_schedule_save() return data + @callback def async_delete_map(self, homekit_id): """Delete pairing cache.""" if homekit_id not in self.storage_data: diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index b51dcb1f6d8549..55718e35b59b6f 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -32,7 +32,7 @@ "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 configuration 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.", "already_in_progress": "Config flow for device is already in progress." } diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 6b71b15daff90c..9f12d59204dfd1 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -1,9 +1,10 @@ """Support for Homekit switches.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -17,6 +18,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hkid = config_entry.data["AccessoryPairingID"] conn = hass.data[KNOWN_DEVICES][hkid] + @callback def async_add_service(aid, service): if service["stype"] not in ("switch", "outlet"): return False diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 4ed893bbf14ec1..54811c3ccdffae 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -40,6 +40,7 @@ def __init__(self, config): self._hmdevice = None self._connected = False self._available = False + self._channel_map = set() # Set parameter to uppercase if self._state: @@ -110,15 +111,12 @@ def update(self): def _hm_event_callback(self, device, caller, attribute, value): """Handle all pyhomematic device events.""" - _LOGGER.debug("%s received event '%s' value: %s", self._name, attribute, value) has_changed = False # Is data needed for this instance? - if attribute in self._data: - # Did data change? - if self._data[attribute] != value: - self._data[attribute] = value - has_changed = True + if f"{attribute}:{device.partition(':')[2]}" in self._channel_map: + self._data[attribute] = value + has_changed = True # Availability has changed if self.available != (not self._hmdevice.UNREACH): @@ -131,9 +129,6 @@ def _hm_event_callback(self, device, caller, attribute, value): def _subscribe_homematic_events(self): """Subscribe all required events to handle job.""" - channels_to_sub = set() - - # Push data to channels_to_sub from hmdevice metadata for metadata in ( self._hmdevice.SENSORNODE, self._hmdevice.BINARYNODE, @@ -150,19 +145,11 @@ def _subscribe_homematic_events(self): channel = channels[0] else: channel = self._channel - - # Prepare for subscription - try: - channels_to_sub.add(int(channel)) - except (ValueError, TypeError): - _LOGGER.error("Invalid channel in metadata from %s", self._name) + # Remember the channel for this attribute to ignore invalid events later + self._channel_map.add(f"{node}:{channel!s}") # Set callbacks - for channel in channels_to_sub: - _LOGGER.debug("Subscribe channel %d from %s", channel, self._name) - self._hmdevice.setEventCallback( - callback=self._hm_event_callback, bequeath=False, channel=channel - ) + self._hmdevice.setEventCallback(callback=self._hm_event_callback, bequeath=True) def _load_data_from_hm(self): """Load first value from pyhomematic.""" diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index c4e09c36b8e808..20ea0d6acb159d 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,7 +2,7 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.63"], + "requirements": ["pyhomematic==0.1.65"], "dependencies": [], "codeowners": ["@pvizeli", "@danielperna84"] } diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index bba8325650deab..09d1e2f59cfaf2 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -8,6 +8,8 @@ DEVICE_CLASS_TEMPERATURE, ENERGY_WATT_HOUR, POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, + VOLUME_CUBIC_METERS, ) from .const import ATTR_DISCOVER_DEVICES @@ -38,8 +40,8 @@ "CURRENT": "mA", "VOLTAGE": "V", "ENERGY_COUNTER": ENERGY_WATT_HOUR, - "GAS_POWER": "m3", - "GAS_ENERGY_COUNTER": "m3", + "GAS_POWER": VOLUME_CUBIC_METERS, + "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, "LUX": "lx", "ILLUMINATION": "lx", "CURRENT_ILLUMINATION": "lx", @@ -47,7 +49,7 @@ "LOWEST_ILLUMINATION": "lx", "HIGHEST_ILLUMINATION": "lx", "RAIN_COUNTER": "mm", - "WIND_SPEED": "km/h", + "WIND_SPEED": SPEED_KILOMETERS_PER_HOUR, "WIND_DIRECTION": "°", "WIND_DIRECTION_RANGE": "°", "SUNSHINEDURATION": "#", diff --git a/homeassistant/components/homematicip_cloud/.translations/es-419.json b/homeassistant/components/homematicip_cloud/.translations/es-419.json index 5102b25aaee92f..0919e211617c5b 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es-419.json +++ b/homeassistant/components/homematicip_cloud/.translations/es-419.json @@ -16,7 +16,8 @@ "hapid": "ID de punto de acceso (SGTIN)", "name": "Nombre (opcional, usado como prefijo de nombre para todos los dispositivos)", "pin": "C\u00f3digo PIN (opcional)" - } + }, + "title": "Elija el 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 en el puente] (/static/images/config_flows/config_homematicip_cloud.png)" diff --git a/homeassistant/components/homematicip_cloud/.translations/pl.json b/homeassistant/components/homematicip_cloud/.translations/pl.json index 7c8714c2c113fd..78905da208eeaa 100644 --- a/homeassistant/components/homematicip_cloud/.translations/pl.json +++ b/homeassistant/components/homematicip_cloud/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany", + "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany.", "connection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" }, diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index f3e1fc9fbece9c..d1982e289a3269 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,23 +1,15 @@ """Support for HomematicIP Cloud devices.""" import logging -from pathlib import Path -from typing import Optional -from homematicip.aio.device import AsyncSwitchMeasuring -from homematicip.aio.group import AsyncHeatingGroup -from homematicip.aio.home import AsyncHome -from homematicip.base.helpers import handle_config import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import comp_entity_ids from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .config_flow import configured_haps from .const import ( CONF_ACCESSPOINT, CONF_AUTHTOKEN, @@ -28,29 +20,10 @@ ) from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 +from .services import async_setup_services, async_unload_services _LOGGER = logging.getLogger(__name__) -ATTR_ACCESSPOINT_ID = "accesspoint_id" -ATTR_ANONYMIZE = "anonymize" -ATTR_CLIMATE_PROFILE_INDEX = "climate_profile_index" -ATTR_CONFIG_OUTPUT_FILE_PREFIX = "config_output_file_prefix" -ATTR_CONFIG_OUTPUT_PATH = "config_output_path" -ATTR_DURATION = "duration" -ATTR_ENDTIME = "endtime" -ATTR_TEMPERATURE = "temperature" - -DEFAULT_CONFIG_FILE_PREFIX = "hmip-config" - -SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION = "activate_eco_mode_with_duration" -SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD = "activate_eco_mode_with_period" -SERVICE_ACTIVATE_VACATION = "activate_vacation" -SERVICE_DEACTIVATE_ECO_MODE = "deactivate_eco_mode" -SERVICE_DEACTIVATE_VACATION = "deactivate_vacation" -SERVICE_DUMP_HAP_CONFIG = "dump_hap_config" -SERVICE_RESET_ENERGY_COUNTER = "reset_energy_counter" -SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile" - CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=[]): vol.All( @@ -69,59 +42,6 @@ extra=vol.ALLOW_EXTRA, ) -SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION = vol.Schema( - { - vol.Required(ATTR_DURATION): cv.positive_int, - vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), - } -) - -SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD = vol.Schema( - { - vol.Required(ATTR_ENDTIME): cv.datetime, - vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), - } -) - -SCHEMA_ACTIVATE_VACATION = vol.Schema( - { - vol.Required(ATTR_ENDTIME): cv.datetime, - vol.Required(ATTR_TEMPERATURE, default=18.0): vol.All( - vol.Coerce(float), vol.Range(min=0, max=55) - ), - vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), - } -) - -SCHEMA_DEACTIVATE_ECO_MODE = vol.Schema( - {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} -) - -SCHEMA_DEACTIVATE_VACATION = vol.Schema( - {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} -) - -SCHEMA_SET_ACTIVE_CLIMATE_PROFILE = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): comp_entity_ids, - vol.Required(ATTR_CLIMATE_PROFILE_INDEX): cv.positive_int, - } -) - -SCHEMA_DUMP_HAP_CONFIG = vol.Schema( - { - vol.Optional(ATTR_CONFIG_OUTPUT_PATH): cv.string, - vol.Optional( - ATTR_CONFIG_OUTPUT_FILE_PREFIX, default=DEFAULT_CONFIG_FILE_PREFIX - ): cv.string, - vol.Optional(ATTR_ANONYMIZE, default=True): cv.boolean, - } -) - -SCHEMA_RESET_ENERGY_COUNTER = vol.Schema( - {vol.Required(ATTR_ENTITY_ID): comp_entity_ids} -) - async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the HomematicIP Cloud component.""" @@ -130,7 +50,10 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: accesspoints = config.get(DOMAIN, []) for conf in accesspoints: - if conf[CONF_ACCESSPOINT] not in configured_haps(hass): + if conf[CONF_ACCESSPOINT] not in set( + entry.data[HMIPC_HAPID] + for entry in hass.config_entries.async_entries(DOMAIN) + ): hass.async_add_job( hass.config_entries.flow.async_init( DOMAIN, @@ -143,201 +66,33 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: ) ) - async def _async_activate_eco_mode_with_duration(service) -> None: - """Service to activate eco mode with duration.""" - duration = service.data[ATTR_DURATION] - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hapid) - if home: - await home.activate_absence_with_duration(duration) - else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_duration(duration) - - hass.services.async_register( - DOMAIN, - SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, - _async_activate_eco_mode_with_duration, - schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION, - ) - - async def _async_activate_eco_mode_with_period(service) -> None: - """Service to activate eco mode with period.""" - endtime = service.data[ATTR_ENDTIME] - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hapid) - if home: - await home.activate_absence_with_period(endtime) - else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_period(endtime) - - hass.services.async_register( - DOMAIN, - SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, - _async_activate_eco_mode_with_period, - schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD, - ) - - async def _async_activate_vacation(service) -> None: - """Service to activate vacation.""" - endtime = service.data[ATTR_ENDTIME] - temperature = service.data[ATTR_TEMPERATURE] - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hapid) - if home: - await home.activate_vacation(endtime, temperature) - else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_vacation(endtime, temperature) - - hass.services.async_register( - DOMAIN, - SERVICE_ACTIVATE_VACATION, - _async_activate_vacation, - schema=SCHEMA_ACTIVATE_VACATION, - ) - - async def _async_deactivate_eco_mode(service) -> None: - """Service to deactivate eco mode.""" - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hapid) - if home: - await home.deactivate_absence() - else: - for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_absence() - - hass.services.async_register( - DOMAIN, - SERVICE_DEACTIVATE_ECO_MODE, - _async_deactivate_eco_mode, - schema=SCHEMA_DEACTIVATE_ECO_MODE, - ) - - async def _async_deactivate_vacation(service) -> None: - """Service to deactivate vacation.""" - hapid = service.data.get(ATTR_ACCESSPOINT_ID) - - if hapid: - home = _get_home(hapid) - if home: - await home.deactivate_vacation() - else: - for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_vacation() - - hass.services.async_register( - DOMAIN, - SERVICE_DEACTIVATE_VACATION, - _async_deactivate_vacation, - schema=SCHEMA_DEACTIVATE_VACATION, - ) + return True - async def _set_active_climate_profile(service) -> None: - """Service to set the active climate profile.""" - entity_id_list = service.data[ATTR_ENTITY_ID] - climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 - for hap in hass.data[DOMAIN].values(): - if entity_id_list != "all": - for entity_id in entity_id_list: - group = hap.hmip_device_by_entity_id.get(entity_id) - if group and isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) - else: - for group in hap.home.groups: - if isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up an access point from a config entry.""" - hass.services.async_register( - DOMAIN, - SERVICE_SET_ACTIVE_CLIMATE_PROFILE, - _set_active_climate_profile, - schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE, - ) + # 0.104 introduced config entry unique id, this makes upgrading possible + if entry.unique_id is None: + new_data = dict(entry.data) - async def _async_dump_hap_config(service) -> None: - """Service to dump the configuration of a Homematic IP Access Point.""" - config_path = ( - service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + hass.config_entries.async_update_entry( + entry, unique_id=new_data[HMIPC_HAPID], data=new_data ) - config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] - anonymize = service.data[ATTR_ANONYMIZE] - - for hap in hass.data[DOMAIN].values(): - hap_sgtin = hap.config_entry.title - - if anonymize: - hap_sgtin = hap_sgtin[-4:] - file_name = f"{config_file_prefix}_{hap_sgtin}.json" - path = Path(config_path) - config_file = path / file_name - - json_state = await hap.home.download_configuration() - json_state = handle_config(json_state, anonymize) - - config_file.write_text(json_state, encoding="utf8") - - hass.services.async_register( - DOMAIN, - SERVICE_DUMP_HAP_CONFIG, - _async_dump_hap_config, - schema=SCHEMA_DUMP_HAP_CONFIG, - ) - - async def _async_reset_energy_counter(service): - """Service to reset the energy counter.""" - entity_id_list = service.data[ATTR_ENTITY_ID] - - for hap in hass.data[DOMAIN].values(): - if entity_id_list != "all": - for entity_id in entity_id_list: - device = hap.hmip_device_by_entity_id.get(entity_id) - if device and isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() - else: - for device in hap.home.devices: - if isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() - - hass.helpers.service.async_register_admin_service( - DOMAIN, - SERVICE_RESET_ENERGY_COUNTER, - _async_reset_energy_counter, - schema=SCHEMA_RESET_ENERGY_COUNTER, - ) - - def _get_home(hapid: str) -> Optional[AsyncHome]: - """Return a HmIP home.""" - hap = hass.data[DOMAIN].get(hapid) - if hap: - return hap.home - - _LOGGER.info("No matching access point found for access point id %s", hapid) - return None - - return True - - -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: - """Set up an access point from a config entry.""" hap = HomematicipHAP(hass, entry) - hapid = entry.data[HMIPC_HAPID].replace("-", "").upper() - hass.data[DOMAIN][hapid] = hap + hass.data[DOMAIN][entry.unique_id] = hap if not await hap.async_setup(): return False + await async_setup_services(hass) + + # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection + hap.reset_connection_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, hap.shutdown + ) + # Register hap as device in registry. device_registry = await dr.async_get_registry(hass) home = hap.home @@ -356,5 +111,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Unload a config entry.""" - hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID]) + hap = hass.data[DOMAIN].pop(entry.unique_id) + hap.reset_connection_listener() + + await async_unload_services(hass) + return await hap.async_reset() diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index f9a91203426476..f5316350091e12 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -16,9 +16,10 @@ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) +from homeassistant.core import callback from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID +from . import DOMAIN as HMIPC_DOMAIN from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -26,18 +27,11 @@ CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel" -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud alarm control devices.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP alrm control panel from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] async_add_entities([HomematicipAlarmControlPanel(hap)]) @@ -102,10 +96,18 @@ async def async_added_to_hass(self) -> None: """Register callbacks.""" self._home.on_update(self._async_device_changed) + @callback def _async_device_changed(self, *args, **kwargs) -> None: """Handle device state changes.""" - _LOGGER.debug("Event %s (%s)", self.name, CONST_ALARM_CONTROL_PANEL_NAME) - self.async_schedule_update_ha_state() + # Don't update disabled entities + if self.enabled: + _LOGGER.debug("Event %s (%s)", self.name, CONST_ALARM_CONTROL_PANEL_NAME) + self.async_schedule_update_ha_state() + else: + _LOGGER.debug( + "Device Changed Event for %s (Alarm Control Panel) not fired. Entity is disabled.", + self.name, + ) @property def name(self) -> str: diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 3efd4ad91bc687..52a4583be46c01 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -41,7 +41,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -75,18 +75,11 @@ } -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud binary sensor devices.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [] for device in hap.home.devices: if isinstance(device, AsyncAccelerationSensor): @@ -234,7 +227,11 @@ def device_class(self) -> str: @property def is_on(self) -> bool: """Return true if smoke is detected.""" - return self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF + if self._device.smokeDetectorAlarmType: + return ( + self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF + ) + return False class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index e3c922dc5775a5..c5fb978e690f1c 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -27,7 +27,7 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .hap import HomematicipHAP HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2} @@ -43,18 +43,11 @@ HMIP_ECO_CM = "ECO" -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud climate devices.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP climate from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [] for device in hap.home.groups: if isinstance(device, AsyncHeatingGroup): diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 8d85dfda3289e1..547289f871a089 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,11 +1,9 @@ """Config flow to configure the HomematicIP Cloud component.""" -from typing import Any, Dict, Set +from typing import Any, Dict import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback -from homeassistant.helpers.typing import HomeAssistantType from .const import ( _LOGGER, @@ -18,15 +16,6 @@ from .hap import HomematicipAuth -@callback -def configured_haps(hass: HomeAssistantType) -> 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) - ) - - @config_entries.HANDLERS.register(HMIPC_DOMAIN) class HomematicipCloudFlowHandler(config_entries.ConfigFlow): """Config flow for the HomematicIP Cloud component.""" @@ -48,8 +37,9 @@ async def async_step_init(self, user_input=None) -> Dict[str, Any]: if user_input is not None: user_input[HMIPC_HAPID] = user_input[HMIPC_HAPID].replace("-", "").upper() - if user_input[HMIPC_HAPID] in configured_haps(self.hass): - return self.async_abort(reason="already_configured") + + await self.async_set_unique_id(user_input[HMIPC_HAPID]) + self._abort_if_unique_id_configured() self.auth = HomematicipAuth(self.hass, user_input) connected = await self.auth.async_setup() @@ -93,16 +83,14 @@ async def async_step_link(self, user_input=None) -> Dict[str, Any]: async def async_step_import(self, import_info) -> Dict[str, Any]: """Import a new access point as a config entry.""" - hapid = import_info[HMIPC_HAPID] + hapid = import_info[HMIPC_HAPID].replace("-", "").upper() authtoken = import_info[HMIPC_AUTHTOKEN] name = import_info[HMIPC_NAME] - hapid = hapid.replace("-", "").upper() - if hapid in configured_haps(self.hass): - return self.async_abort(reason="already_configured") + await self.async_set_unique_id(hapid) + self._abort_if_unique_id_configured() _LOGGER.info("Imported authentication for %s", hapid) - return self.async_create_entry( title=hapid, data={HMIPC_AUTHTOKEN: authtoken, HMIPC_HAPID: hapid, HMIPC_NAME: name}, diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 32f38637e3635c..768c893a100b71 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -7,6 +7,7 @@ AsyncFullFlushShutter, AsyncGarageDoorModuleTormatic, ) +from homematicip.aio.group import AsyncExtendedLinkedShutterGroup from homematicip.base.enums import DoorCommand, DoorState from homeassistant.components.cover import ( @@ -17,7 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -27,18 +29,11 @@ HMIP_SLATS_CLOSED = 1 -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud cover devices.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP cover from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [] for device in hap.home.devices: if isinstance(device, AsyncFullFlushBlind): @@ -48,6 +43,10 @@ async def async_setup_entry( elif isinstance(device, AsyncGarageDoorModuleTormatic): entities.append(HomematicipGarageDoorModuleTormatic(hap, device)) + for group in hap.home.groups: + if isinstance(group, AsyncExtendedLinkedShutterGroup): + entities.append(HomematicipCoverShutterGroup(hap, group)) + if entities: async_add_entities(entities) @@ -149,3 +148,12 @@ async def async_close_cover(self, **kwargs) -> None: async def async_stop_cover(self, **kwargs) -> None: """Stop the cover.""" await self._device.send_door_command(DoorCommand.STOP) + + +class HomematicipCoverShutterGroup(HomematicipCoverSlats, CoverDevice): + """Representation of a HomematicIP Cloud cover shutter group.""" + + def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: + """Initialize switching group.""" + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 63bdf3166ebf1f..0d6fc726050c3f 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -81,6 +81,7 @@ def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry) -> None: self._tries = 0 self._accesspoint_connected = True self.hmip_device_by_entity_id = {} + self.reset_connection_listener = None async def async_setup(self, tries: int = 0) -> bool: """Initialize connection.""" @@ -93,10 +94,12 @@ async def async_setup(self, tries: int = 0) -> bool: ) except HmipcConnectionError: raise ConfigEntryNotReady + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Error connecting with HomematicIP Cloud: %s", err) + return False _LOGGER.info( - "Connected to HomematicIP with HAP %s", - self.config_entry.data.get(HMIPC_HAPID), + "Connected to HomematicIP with HAP %s", self.config_entry.unique_id ) for component in COMPONENTS: @@ -193,7 +196,7 @@ async def async_connect(self) -> None: _LOGGER.error( "Error connecting to HomematicIP with HAP %s. " "Retrying in %d seconds", - self.config_entry.data.get(HMIPC_HAPID), + self.config_entry.unique_id, retry_delay, ) @@ -224,6 +227,17 @@ async def async_reset(self) -> bool: self.hmip_device_by_entity_id = {} return True + @callback + def shutdown(self, event) -> None: + """Wrap the call to async_reset. + + Used as an argument to EventBus.async_listen_once. + """ + self.hass.async_create_task(self.async_reset()) + _LOGGER.debug( + "Reset connection to access point id %s", self.config_entry.unique_id + ) + async def get_hap( self, hass: HomeAssistantType, hapid: str, authtoken: str, name: str ) -> AsyncHome: diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 79083f031ae30d..4e081f4d8fa6fd 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -25,7 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -34,18 +34,11 @@ ATTR_CURRENT_POWER_W = "current_power_w" -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Old way of setting up HomematicIP Cloud lights.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index e920a847292a4b..9ecdb0ad80dac3 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -3,7 +3,8 @@ "name": "HomematicIP Cloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", - "requirements": ["homematicip==0.10.15"], + "requirements": ["homematicip==0.10.17"], "dependencies": [], - "codeowners": ["@SukramJ"] + "codeowners": ["@SukramJ"], + "quality_scale": "platinum" } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index a8ca3d17eb9422..4335eebb8b81fc 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -14,6 +14,7 @@ AsyncPassageDetector, AsyncPlugableSwitchMeasuring, AsyncPresenceDetectorIndoor, + AsyncRoomControlDeviceAnalog, AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorOutdoor, AsyncTemperatureHumiditySensorWithoutDisplay, @@ -30,11 +31,12 @@ DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .device import ATTR_IS_GROUP, ATTR_MODEL_TYPE from .hap import HomematicipHAP @@ -56,18 +58,11 @@ } -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud sensors devices.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [HomematicipAccesspointStatus(hap)] for device in hap.home.devices: if isinstance(device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact)): @@ -86,6 +81,8 @@ async def async_setup_entry( ): entities.append(HomematicipTemperatureSensor(hap, device)) entities.append(HomematicipHumiditySensor(hap, device)) + elif isinstance(device, (AsyncRoomControlDeviceAnalog,)): + entities.append(HomematicipTemperatureSensor(hap, device)) if isinstance( device, ( @@ -312,7 +309,7 @@ def device_class(self) -> str: @property def state(self) -> float: - """Representation of the HomematicIP power comsumption value.""" + """Representation of the HomematicIP power consumption value.""" return self._device.currentPowerConsumption @property @@ -336,7 +333,7 @@ def state(self) -> float: @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "km/h" + return SPEED_KILOMETERS_PER_HOUR @property def device_state_attributes(self) -> Dict[str, Any]: @@ -363,7 +360,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def state(self) -> float: - """Representation of the HomematicIP todays rain value.""" + """Representation of the HomematicIP today's rain value.""" return round(self._device.todayRainCounter, 2) @property diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py new file mode 100644 index 00000000000000..d8535edda50e97 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/services.py @@ -0,0 +1,353 @@ +"""Support for HomematicIP Cloud devices.""" +import logging +from pathlib import Path +from typing import Optional + +from homematicip.aio.device import AsyncSwitchMeasuring +from homematicip.aio.group import AsyncHeatingGroup +from homematicip.aio.home import AsyncHome +from homematicip.base.helpers import handle_config +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import comp_entity_ids +from homeassistant.helpers.service import ( + async_register_admin_service, + verify_domain_control, +) +from homeassistant.helpers.typing import HomeAssistantType, ServiceCallType + +from .const import DOMAIN as HMIPC_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ATTR_ACCESSPOINT_ID = "accesspoint_id" +ATTR_ANONYMIZE = "anonymize" +ATTR_CLIMATE_PROFILE_INDEX = "climate_profile_index" +ATTR_CONFIG_OUTPUT_FILE_PREFIX = "config_output_file_prefix" +ATTR_CONFIG_OUTPUT_PATH = "config_output_path" +ATTR_DURATION = "duration" +ATTR_ENDTIME = "endtime" +ATTR_TEMPERATURE = "temperature" + +DEFAULT_CONFIG_FILE_PREFIX = "hmip-config" + +SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION = "activate_eco_mode_with_duration" +SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD = "activate_eco_mode_with_period" +SERVICE_ACTIVATE_VACATION = "activate_vacation" +SERVICE_DEACTIVATE_ECO_MODE = "deactivate_eco_mode" +SERVICE_DEACTIVATE_VACATION = "deactivate_vacation" +SERVICE_DUMP_HAP_CONFIG = "dump_hap_config" +SERVICE_RESET_ENERGY_COUNTER = "reset_energy_counter" +SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile" + +HMIPC_SERVICES = [ + SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, + SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, + SERVICE_ACTIVATE_VACATION, + SERVICE_DEACTIVATE_ECO_MODE, + SERVICE_DEACTIVATE_VACATION, + SERVICE_DUMP_HAP_CONFIG, + SERVICE_RESET_ENERGY_COUNTER, + SERVICE_SET_ACTIVE_CLIMATE_PROFILE, +] + +SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION = vol.Schema( + { + vol.Required(ATTR_DURATION): cv.positive_int, + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD = vol.Schema( + { + vol.Required(ATTR_ENDTIME): cv.datetime, + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_ACTIVATE_VACATION = vol.Schema( + { + vol.Required(ATTR_ENDTIME): cv.datetime, + vol.Required(ATTR_TEMPERATURE, default=18.0): vol.All( + vol.Coerce(float), vol.Range(min=0, max=55) + ), + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_DEACTIVATE_ECO_MODE = vol.Schema( + {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} +) + +SCHEMA_DEACTIVATE_VACATION = vol.Schema( + {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} +) + +SCHEMA_SET_ACTIVE_CLIMATE_PROFILE = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): comp_entity_ids, + vol.Required(ATTR_CLIMATE_PROFILE_INDEX): cv.positive_int, + } +) + +SCHEMA_DUMP_HAP_CONFIG = vol.Schema( + { + vol.Optional(ATTR_CONFIG_OUTPUT_PATH): cv.string, + vol.Optional( + ATTR_CONFIG_OUTPUT_FILE_PREFIX, default=DEFAULT_CONFIG_FILE_PREFIX + ): cv.string, + vol.Optional(ATTR_ANONYMIZE, default=True): cv.boolean, + } +) + +SCHEMA_RESET_ENERGY_COUNTER = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): comp_entity_ids} +) + + +async def async_setup_services(hass: HomeAssistantType) -> None: + """Set up the HomematicIP Cloud services.""" + + if hass.services.async_services().get(HMIPC_DOMAIN): + return + + @verify_domain_control(hass, HMIPC_DOMAIN) + async def async_call_hmipc_service(service: ServiceCallType): + """Call correct HomematicIP Cloud service.""" + service_name = service.service + + if service_name == SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION: + await _async_activate_eco_mode_with_duration(hass, service) + elif service_name == SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD: + await _async_activate_eco_mode_with_period(hass, service) + elif service_name == SERVICE_ACTIVATE_VACATION: + await _async_activate_vacation(hass, service) + elif service_name == SERVICE_DEACTIVATE_ECO_MODE: + await _async_deactivate_eco_mode(hass, service) + elif service_name == SERVICE_DEACTIVATE_VACATION: + await _async_deactivate_vacation(hass, service) + elif service_name == SERVICE_DUMP_HAP_CONFIG: + await _async_dump_hap_config(hass, service) + elif service_name == SERVICE_RESET_ENERGY_COUNTER: + await _async_reset_energy_counter(hass, service) + elif service_name == SERVICE_SET_ACTIVE_CLIMATE_PROFILE: + await _set_active_climate_profile(hass, service) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, + service_func=async_call_hmipc_service, + schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, + service_func=async_call_hmipc_service, + schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_VACATION, + service_func=async_call_hmipc_service, + schema=SCHEMA_ACTIVATE_VACATION, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_DEACTIVATE_ECO_MODE, + service_func=async_call_hmipc_service, + schema=SCHEMA_DEACTIVATE_ECO_MODE, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_DEACTIVATE_VACATION, + service_func=async_call_hmipc_service, + schema=SCHEMA_DEACTIVATE_VACATION, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_SET_ACTIVE_CLIMATE_PROFILE, + service_func=async_call_hmipc_service, + schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE, + ) + + async_register_admin_service( + hass=hass, + domain=HMIPC_DOMAIN, + service=SERVICE_DUMP_HAP_CONFIG, + service_func=async_call_hmipc_service, + schema=SCHEMA_DUMP_HAP_CONFIG, + ) + + async_register_admin_service( + hass=hass, + domain=HMIPC_DOMAIN, + service=SERVICE_RESET_ENERGY_COUNTER, + service_func=async_call_hmipc_service, + schema=SCHEMA_RESET_ENERGY_COUNTER, + ) + + +async def async_unload_services(hass: HomeAssistantType): + """Unload HomematicIP Cloud services.""" + if hass.data[HMIPC_DOMAIN]: + return + + for hmipc_service in HMIPC_SERVICES: + hass.services.async_remove(domain=HMIPC_DOMAIN, service=hmipc_service) + + +async def _async_activate_eco_mode_with_duration( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to activate eco mode with duration.""" + duration = service.data[ATTR_DURATION] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.activate_absence_with_duration(duration) + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.activate_absence_with_duration(duration) + + +async def _async_activate_eco_mode_with_period( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to activate eco mode with period.""" + endtime = service.data[ATTR_ENDTIME] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.activate_absence_with_period(endtime) + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.activate_absence_with_period(endtime) + + +async def _async_activate_vacation( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to activate vacation.""" + endtime = service.data[ATTR_ENDTIME] + temperature = service.data[ATTR_TEMPERATURE] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.activate_vacation(endtime, temperature) + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.activate_vacation(endtime, temperature) + + +async def _async_deactivate_eco_mode( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to deactivate eco mode.""" + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.deactivate_absence() + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.deactivate_absence() + + +async def _async_deactivate_vacation( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to deactivate vacation.""" + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.deactivate_vacation() + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.deactivate_vacation() + + +async def _set_active_climate_profile( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to set the active climate profile.""" + entity_id_list = service.data[ATTR_ENTITY_ID] + climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 + + for hap in hass.data[HMIPC_DOMAIN].values(): + if entity_id_list != "all": + for entity_id in entity_id_list: + group = hap.hmip_device_by_entity_id.get(entity_id) + if group and isinstance(group, AsyncHeatingGroup): + await group.set_active_profile(climate_profile_index) + else: + for group in hap.home.groups: + if isinstance(group, AsyncHeatingGroup): + await group.set_active_profile(climate_profile_index) + + +async def _async_dump_hap_config( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to dump the configuration of a Homematic IP Access Point.""" + config_path = service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] + anonymize = service.data[ATTR_ANONYMIZE] + + for hap in hass.data[HMIPC_DOMAIN].values(): + hap_sgtin = hap.config_entry.unique_id + + if anonymize: + hap_sgtin = hap_sgtin[-4:] + + file_name = f"{config_file_prefix}_{hap_sgtin}.json" + path = Path(config_path) + config_file = path / file_name + + json_state = await hap.home.download_configuration() + json_state = handle_config(json_state, anonymize) + + config_file.write_text(json_state, encoding="utf8") + + +async def _async_reset_energy_counter( + hass: HomeAssistantType, service: ServiceCallType +): + """Service to reset the energy counter.""" + entity_id_list = service.data[ATTR_ENTITY_ID] + + for hap in hass.data[HMIPC_DOMAIN].values(): + if entity_id_list != "all": + for entity_id in entity_id_list: + device = hap.hmip_device_by_entity_id.get(entity_id) + if device and isinstance(device, AsyncSwitchMeasuring): + await device.reset_energy_counter() + else: + for device in hap.home.devices: + if isinstance(device, AsyncSwitchMeasuring): + await device.reset_energy_counter() + + +def _get_home(hass: HomeAssistantType, hapid: str) -> Optional[AsyncHome]: + """Return a HmIP home.""" + hap = hass.data[HMIPC_DOMAIN].get(hapid) + if hap: + return hap.home + + _LOGGER.info("No matching access point found for access point id %s", hapid) + return None diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 6fdb0b8c95c7a0..45adf54df2b600 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -19,25 +19,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .device import ATTR_GROUP_MEMBER_UNREACHABLE from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud switch devices.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP switch from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index ebc7eacf78ea35..04f3b06cbb0b0b 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -13,7 +13,7 @@ from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -37,18 +37,11 @@ } -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -) -> None: - """Set up the HomematicIP Cloud weather sensor.""" - pass - - async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]] + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] entities = [] for device in hap.home.devices: if isinstance(device, AsyncWeatherSensorPro): diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index f8537bfe96a3cd..ece8257a713184 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -141,7 +141,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.warning( "The honeywell component has been deprecated for EU (i.e. non-US) " "systems. For EU-based systems, use the evohome component, " - "see: https://home-assistant.io/integrations/evohome" + "see: https://www.home-assistant.io/integrations/evohome" ) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 58cfb4b9cc135c..565f84fdb8a20a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -166,7 +166,16 @@ async def start_server(event): # If we are set up successful, we store the HTTP settings for safe mode. store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save(conf) + + if CONF_TRUSTED_PROXIES in conf: + conf_to_save = dict(conf) + conf_to_save[CONF_TRUSTED_PROXIES] = [ + str(ip.network_address) for ip in conf_to_save[CONF_TRUSTED_PROXIES] + ] + else: + conf_to_save = conf + + await store.async_save(conf_to_save) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) diff --git a/homeassistant/components/huawei_lte/.translations/ca.json b/homeassistant/components/huawei_lte/.translations/ca.json index 594c2e3b16de27..a52d101a486be6 100644 --- a/homeassistant/components/huawei_lte/.translations/ca.json +++ b/homeassistant/components/huawei_lte/.translations/ca.json @@ -24,7 +24,7 @@ "username": "Nom d'usuari" }, "description": "Introdueix les dades d\u2019acc\u00e9s del dispositiu. El nom d\u2019usuari i contrasenya s\u00f3n opcionals, per\u00f2 habiliten m\u00e9s funcions de la integraci\u00f3. D'altra banda, (mentre la integraci\u00f3 estigui activa) l'\u00fas d'una connexi\u00f3 autoritzada pot causar problemes per accedir a la interf\u00edcie web del dispositiu des de fora de Home Assistant i viceversa.", - "title": "Con de Huawei LTE" + "title": "Configuraci\u00f3 de Huawei LTE" } }, "title": "Huawei LTE" @@ -33,7 +33,7 @@ "step": { "init": { "data": { - "name": "Nom del servei de notificacions", + "name": "Nom del servei de notificacions (reinici necessari si canvia)", "recipient": "Destinataris de notificacions SMS", "track_new_devices": "Segueix dispositius nous" } diff --git a/homeassistant/components/huawei_lte/.translations/hu.json b/homeassistant/components/huawei_lte/.translations/hu.json new file mode 100644 index 00000000000000..9f012c1c4050f4 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/hu.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Ez az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "Ez az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z" + }, + "error": { + "connection_failed": "Kapcsol\u00f3d\u00e1s sikertelen", + "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9se", + "incorrect_password": "Hib\u00e1s jelsz\u00f3", + "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", + "incorrect_username_or_password": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v vagy jelsz\u00f3", + "invalid_url": "\u00c9rv\u00e9nytelen URL" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Huawei LTE konfigur\u00e1l\u00e1sa" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u00c9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s neve (a m\u00f3dos\u00edt\u00e1s \u00fajraind\u00edt\u00e1st ig\u00e9nyel)", + "recipient": "SMS-\u00e9rtes\u00edt\u00e9s c\u00edmzettjei", + "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomk\u00f6vet\u00e9se" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/pl.json b/homeassistant/components/huawei_lte/.translations/pl.json index a4e7d72852a31a..4029b24df3f0d8 100644 --- a/homeassistant/components/huawei_lte/.translations/pl.json +++ b/homeassistant/components/huawei_lte/.translations/pl.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "already_in_progress": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_configured": "To urz\u0105dzenie jest ju\u017c skonfigurowane.", + "already_in_progress": "To urz\u0105dzenie jest ju\u017c skonfigurowane.", "not_huawei_lte": "To nie jest urz\u0105dzenie Huawei LTE" }, "error": { diff --git a/homeassistant/components/huawei_lte/.translations/sv.json b/homeassistant/components/huawei_lte/.translations/sv.json new file mode 100644 index 00000000000000..16b192d16a1931 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/sv.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r enheten har redan konfigurerats", + "already_in_progress": "Den h\u00e4r enheten har redan konfigurerats", + "not_huawei_lte": "Inte en Huawei LTE-enhet" + }, + "error": { + "connection_failed": "Anslutningen misslyckades", + "connection_timeout": "Timeout f\u00f6r anslutning", + "incorrect_password": "Felaktigt l\u00f6senord", + "incorrect_username": "Felaktigt anv\u00e4ndarnamn", + "incorrect_username_or_password": "Felaktigt anv\u00e4ndarnamn eller l\u00f6senord", + "invalid_url": "Ogiltig URL", + "login_attempts_exceeded": "Maximala inloggningsf\u00f6rs\u00f6k har \u00f6verskridits, f\u00f6rs\u00f6k igen senare", + "response_error": "Ok\u00e4nt fel fr\u00e5n enheten", + "unknown_connection_error": "Ok\u00e4nt fel vid anslutning till enheten" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "url": "URL", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Ange information om enhets\u00e5tkomst. Det \u00e4r valfritt att ange anv\u00e4ndarnamn och l\u00f6senord, men st\u00f6djer d\u00e5 fler integrationsfunktioner. \u00c5 andra sidan kan anv\u00e4ndning av en auktoriserad anslutning orsaka problem med att komma \u00e5t enhetens webbgr\u00e4nssnitt utanf\u00f6r Home Assistant medan integrationen \u00e4r aktiv och tv\u00e4rtom.", + "title": "Konfigurera Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "name": "Namn p\u00e5 meddelandetj\u00e4nsten (\u00e4ndring kr\u00e4ver omstart)", + "recipient": "Mottagare av SMS-meddelanden", + "track_new_devices": "Sp\u00e5ra nya enheter" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 97a57405ae07be..d3b2d5b1abdef5 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -5,6 +5,7 @@ from functools import partial import ipaddress import logging +import time from typing import Any, Callable, Dict, List, Set, Tuple from urllib.parse import urlparse @@ -65,6 +66,7 @@ KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, KEY_WLAN_HOST_LIST, + NOTIFY_SUPPRESS_TIMEOUT, SERVICE_CLEAR_TRAFFIC_STATISTICS, SERVICE_REBOOT, SERVICE_RESUME_INTEGRATION, @@ -138,9 +140,11 @@ class Router: init=False, factory=lambda: defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)), ) + inflight_gets: Set[str] = attr.ib(init=False, factory=set) unload_handlers: List[CALLBACK_TYPE] = attr.ib(init=False, factory=list) client: Client suspended = attr.ib(init=False, default=False) + notify_last_attempt: float = attr.ib(init=False, default=-1) def __attrs_post_init__(self): """Set up internal state on init.""" @@ -167,6 +171,10 @@ def device_connections(self) -> Set[Tuple[str, str]]: def _get_data(self, key: str, func: Callable[[None], Any]) -> None: if not self.subscriptions.get(key): return + if key in self.inflight_gets: + _LOGGER.debug("Skipping already inflight get for %s", key) + return + self.inflight_gets.add(key) _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) try: self.data[key] = func() @@ -189,7 +197,21 @@ def _get_data(self, key: str, func: Callable[[None], Any]) -> None: "%s requires authorization, excluding from future updates", key ) self.subscriptions.pop(key) + except Timeout: + grace_left = ( + self.notify_last_attempt - time.monotonic() + NOTIFY_SUPPRESS_TIMEOUT + ) + if grace_left > 0: + _LOGGER.debug( + "%s timed out, %.1fs notify timeout suppress grace remaining", + key, + grace_left, + exc_info=True, + ) + else: + raise finally: + self.inflight_gets.discard(key) _LOGGER.debug("%s=%s", key, self.data.get(key)) def update(self) -> None: @@ -506,6 +528,19 @@ async def async_signal_options_update( async_dispatcher_send(hass, UPDATE_OPTIONS_SIGNAL, config_entry) +async def async_migrate_entry(hass: HomeAssistantType, config_entry: ConfigEntry): + """Migrate config entry to new version.""" + if config_entry.version == 1: + options = config_entry.options + recipient = options.get(CONF_RECIPIENT) + if isinstance(recipient, str): + options[CONF_RECIPIENT] = [x.strip() for x in recipient.split(",")] + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=options) + _LOGGER.info("Migrated config entry to version %d", config_entry.version) + return True + + @attr.s class HuaweiLteBaseEntity(Entity): """Huawei LTE entity base class.""" diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 0dcdb6636c690d..223ca9dc34aa0e 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -40,7 +40,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Huawei LTE config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL @staticmethod @@ -247,9 +247,16 @@ def __init__(self, config_entry: config_entries.ConfigEntry): async def async_step_init(self, user_input=None): """Handle options flow.""" + + # Recipients are persisted as a list, but handled as comma separated string in UI + if user_input is not None: # Preserve existing options, for example *_from_yaml markers data = {**self.config_entry.options, **user_input} + if not isinstance(data[CONF_RECIPIENT], list): + data[CONF_RECIPIENT] = [ + x.strip() for x in data[CONF_RECIPIENT].split(",") + ] return self.async_create_entry(title="", data=data) data_schema = vol.Schema( @@ -262,7 +269,9 @@ async def async_step_init(self, user_input=None): ): str, vol.Optional( CONF_RECIPIENT, - default=self.config_entry.options.get(CONF_RECIPIENT, ""), + default=", ".join( + self.config_entry.options.get(CONF_RECIPIENT, []) + ), ): str, } ) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index c6837fce06c656..41814d5ae103a7 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -8,10 +8,8 @@ UPDATE_SIGNAL = f"{DOMAIN}_update" UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update" -UNIT_BYTES = "B" -UNIT_SECONDS = "s" - CONNECTION_TIMEOUT = 10 +NOTIFY_SUPPRESS_TIMEOUT = 30 SERVICE_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" SERVICE_REBOOT = "reboot" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index a9c61831fdd023..54e8f318cf68b9 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -13,6 +13,7 @@ ) from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.const import CONF_URL +from homeassistant.core import callback from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -70,6 +71,7 @@ async def _async_maybe_add_new_entities(url: str) -> None: async_add_new_entities(hass, router.url, async_add_entities, tracked) +@callback def async_add_new_entities(hass, router_url, async_add_entities, tracked): """Add new entities that are not already being tracked.""" router = hass.data[DOMAIN].routers[router_url] diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 5b930802c61478..8525b9eeaadfc6 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ "getmac==0.8.1", - "huawei-lte-api==1.4.6", + "huawei-lte-api==1.4.7", "stringcase==1.2.0", "url-normalize==1.4.1" ], diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 5619a5d702c41b..91cc8864eb0c9f 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,6 +1,7 @@ """Support for Huawei LTE router notifications.""" import logging +import time from typing import Any, List import attr @@ -57,3 +58,5 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: _LOGGER.debug("Sent to %s: %s", targets, resp) except ResponseErrorException as ex: _LOGGER.error("Could not send to %s: %s", targets, ex) + finally: + self.router.notify_last_attempt = time.monotonic() diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 3b6b75edfba68f..8ca5e02dcdd740 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -10,7 +10,7 @@ DEVICE_CLASS_SIGNAL_STRENGTH, DOMAIN as SENSOR_DOMAIN, ) -from homeassistant.const import CONF_URL, STATE_UNKNOWN +from homeassistant.const import CONF_URL, DATA_BYTES, STATE_UNKNOWN, TIME_SECONDS from . import HuaweiLteBaseEntity from .const import ( @@ -18,8 +18,6 @@ KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, KEY_MONITORING_TRAFFIC_STATISTICS, - UNIT_BYTES, - UNIT_SECONDS, ) _LOGGER = logging.getLogger(__name__) @@ -123,22 +121,22 @@ exclude=re.compile(r"^showtraffic$", re.IGNORECASE) ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentConnectTime"): dict( - name="Current connection duration", unit=UNIT_SECONDS, icon="mdi:timer" + name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): dict( - name="Current connection download", unit=UNIT_BYTES, icon="mdi:download" + name="Current connection download", unit=DATA_BYTES, icon="mdi:download" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUpload"): dict( - name="Current connection upload", unit=UNIT_BYTES, icon="mdi:upload" + name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): dict( - name="Total connected duration", unit=UNIT_SECONDS, icon="mdi:timer" + name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): dict( - name="Total download", unit=UNIT_BYTES, icon="mdi:download" + name="Total download", unit=DATA_BYTES, icon="mdi:download" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): dict( - name="Total upload", unit=UNIT_BYTES, icon="mdi:upload" + name="Total upload", unit=DATA_BYTES, icon="mdi:upload" ), } diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json index 3866af9d7fcaa1..00b9374459c38e 100644 --- a/homeassistant/components/hue/.translations/pl.json +++ b/homeassistant/components/hue/.translations/pl.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "all_configured": "Wszystkie mostki Hue s\u0105 ju\u017c skonfigurowane", - "already_configured": "Mostek jest ju\u017c skonfigurowany", - "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.", + "all_configured": "Wszystkie mostki Hue s\u0105 ju\u017c skonfigurowane.", + "already_configured": "Mostek jest ju\u017c skonfigurowany.", + "already_in_progress": "Konfiguracja mostka jest ju\u017c w toku.", "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem", "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue", "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue", diff --git a/homeassistant/components/hue/.translations/zh-Hans.json b/homeassistant/components/hue/.translations/zh-Hans.json index 1c6d78f934308f..5e2f35bfea8598 100644 --- a/homeassistant/components/hue/.translations/zh-Hans.json +++ b/homeassistant/components/hue/.translations/zh-Hans.json @@ -5,7 +5,7 @@ "already_configured": "\u98de\u5229\u6d66 Hue Bridge \u5df2\u914d\u7f6e\u5b8c\u6210", "already_in_progress": "\u7f51\u6865\u7684\u914d\u7f6e\u6d41\u5df2\u5728\u8fdb\u884c\u4e2d\u3002", "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 \u98de\u5229\u6d66 Hue Bridge", - "discover_timeout": "\u65e0\u6cd5\u55c5\u63a2 Hue \u6865\u63a5\u5668", + "discover_timeout": "\u65e0\u6cd5\u55c5\u63a2\u5230 Hue \u6865\u63a5\u5668", "no_bridges": "\u672a\u53d1\u73b0\u98de\u5229\u6d66 Hue Bridge", "unknown": "\u51fa\u73b0\u672a\u77e5\u7684\u9519\u8bef" }, diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 7349f4fe6a6e71..7510ff22f16350 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -6,26 +6,27 @@ import voluptuous as vol from homeassistant import config_entries, core +from homeassistant.components import persistent_notification from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv, device_registry as dr from .bridge import HueBridge -from .const import DOMAIN +from .const import ( + CONF_ALLOW_HUE_GROUPS, + CONF_ALLOW_UNREACHABLE, + DEFAULT_ALLOW_HUE_GROUPS, + DEFAULT_ALLOW_UNREACHABLE, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) CONF_BRIDGES = "bridges" -CONF_ALLOW_UNREACHABLE = "allow_unreachable" -DEFAULT_ALLOW_UNREACHABLE = False - DATA_CONFIGS = "hue_configs" PHUE_CONFIG_FILE = "phue.conf" -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" -DEFAULT_ALLOW_HUE_GROUPS = True - BRIDGE_CONFIG_SCHEMA = vol.Schema( { # Validate as IP address and then convert back to a string. @@ -45,13 +46,7 @@ DOMAIN: vol.Schema( { vol.Optional(CONF_BRIDGES): vol.All( - cv.ensure_list, - [ - vol.All( - cv.deprecated("filename", invalidation_version="0.106.0"), - BRIDGE_CONFIG_SCHEMA, - ), - ], + cv.ensure_list, [BRIDGE_CONFIG_SCHEMA], ) } ) @@ -111,8 +106,10 @@ async def async_setup_entry( config = hass.data[DATA_CONFIGS].get(host) if config is None: - allow_unreachable = DEFAULT_ALLOW_UNREACHABLE - allow_groups = DEFAULT_ALLOW_HUE_GROUPS + allow_unreachable = entry.data.get( + CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE + ) + allow_groups = entry.data.get(CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS) else: allow_unreachable = config[CONF_ALLOW_UNREACHABLE] allow_groups = config[CONF_ALLOW_HUE_GROUPS] @@ -122,7 +119,7 @@ async def async_setup_entry( if not await bridge.async_setup(): return False - hass.data[DOMAIN][host] = bridge + hass.data[DOMAIN][entry.entry_id] = bridge config = bridge.api.config # For backwards compat @@ -142,8 +139,20 @@ async def async_setup_entry( sw_version=config.swversion, ) - if config.swupdate2_bridge_state == "readytoinstall": - err = "Please check for software updates of the bridge in the Philips Hue App." + if config.modelid == "BSB002" and config.swversion < "1935144040": + persistent_notification.async_create( + hass, + "Your Hue hub has a known security vulnerability ([CVE-2020-6007](https://cve.circl.lu/cve/CVE-2020-6007)). Go to the Hue app and check for software updates.", + "Signify Hue", + "hue_hub_firmware", + ) + + elif config.swupdate2_bridge_state == "readytoinstall": + err = ( + "Please check for software updates of the bridge in the Philips Hue App.", + "Signify Hue", + "hue_hub_firmware", + ) _LOGGER.warning(err) return True @@ -151,5 +160,5 @@ async def async_setup_entry( async def async_unload_entry(hass, entry): """Unload a config entry.""" - bridge = hass.data[DOMAIN].pop(entry.data["host"]) + bridge = hass.data[DOMAIN].pop(entry.entry_id) return await bridge.async_reset() diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index e4b7dd85e37ed2..319f8f5fa1995f 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -6,27 +6,18 @@ DEVICE_CLASS_MOTION, BinarySensorDevice, ) -from homeassistant.components.hue.sensor_base import ( - GenericZLLSensor, - SensorManager, - async_setup_entry as shared_async_setup_entry, -) + +from .const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor PRESENCE_NAME_FORMAT = "{} motion" async def async_setup_entry(hass, config_entry, async_add_entities): """Defer binary sensor setup to the shared sensor module.""" - SensorManager.sensor_config_map.update( - { - TYPE_ZLL_PRESENCE: { - "binary": True, - "name_format": PRESENCE_NAME_FORMAT, - "class": HuePresence, - } - } - ) - await shared_async_setup_entry(hass, config_entry, async_add_entities, binary=True) + await hass.data[HUE_DOMAIN][ + config_entry.entry_id + ].sensor_manager.async_register_component(True, async_add_entities) class HuePresence(GenericZLLSensor, BinarySensorDevice): @@ -34,9 +25,6 @@ class HuePresence(GenericZLLSensor, BinarySensorDevice): device_class = DEVICE_CLASS_MOTION - async def _async_update_ha_state(self, *args, **kwargs): - await self.async_update_ha_state(self, *args, **kwargs) - @property def is_on(self): """Return true if the binary sensor is on.""" @@ -51,3 +39,14 @@ def device_state_attributes(self): if "sensitivitymax" in self.sensor.config: attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"] return attributes + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_PRESENCE: { + "binary": True, + "name_format": PRESENCE_NAME_FORMAT, + "class": HuePresence, + } + } +) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 58a744dd5b0feb..2c164e5769ab04 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -1,6 +1,8 @@ """Code to handle a Hue bridge.""" import asyncio +from functools import partial +from aiohttp import client_exceptions import aiohue import async_timeout import slugify as unicode_slug @@ -13,6 +15,7 @@ from .const import DOMAIN, LOGGER from .errors import AuthenticationRequired, CannotConnect from .helpers import create_config_flow +from .sensor_base import SensorManager SERVICE_HUE_SCENE = "hue_activate_scene" ATTR_GROUP_NAME = "group_name" @@ -20,6 +23,8 @@ SCENE_SCHEMA = vol.Schema( {vol.Required(ATTR_GROUP_NAME): cv.string, vol.Required(ATTR_SCENE_NAME): cv.string} ) +# How long should we sleep if the hub is busy +HUB_BUSY_SLEEP = 0.01 class HueBridge: @@ -35,6 +40,9 @@ def __init__(self, hass, config_entry, allow_unreachable, allow_groups): self.authorized = False self.api = None self.parallel_updates_semaphore = None + # Jobs to be executed when API is reset. + self.reset_jobs = [] + self.sensor_manager = None @property def host(self): @@ -72,6 +80,7 @@ async def async_setup(self, tries=0): return False self.api = bridge + self.sensor_manager = SensorManager(self) hass.async_create_task( hass.config_entries.async_forward_entry_setup(self.config_entry, "light") @@ -96,11 +105,33 @@ async def async_setup(self, tries=0): self.authorized = True return True - async def async_request_call(self, coro): - """Process request batched.""" + async def async_request_call(self, task): + """Limit parallel requests to Hue hub. + The Hue hub can only handle a certain amount of parallel requests, total. + Although we limit our parallel requests, we still will run into issues because + other products are hitting up Hue. + + ClientOSError means hub closed the socket on us. + ContentResponseError means hub raised an error. + Since we don't make bad requests, this is on them. + """ async with self.parallel_updates_semaphore: - return await coro + for tries in range(4): + try: + return await task() + except ( + client_exceptions.ClientOSError, + client_exceptions.ClientResponseError, + ) as err: + if tries == 3 or ( + # We only retry if it's a server error. So raise on all 4XX errors. + isinstance(err, client_exceptions.ClientResponseError) + and err.status < 500 + ): + raise + + await asyncio.sleep(HUB_BUSY_SLEEP * tries) async def async_reset(self): """Reset this bridge to default state. @@ -118,6 +149,9 @@ async def async_reset(self): self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + while self.reset_jobs: + self.reset_jobs.pop()() + # If setup was successful, we set api variable, forwarded entry and # register service results = await asyncio.gather( @@ -131,6 +165,7 @@ async def async_reset(self): self.config_entry, "sensor" ), ) + # None and True are OK return False not in results @@ -158,8 +193,8 @@ async def hue_activate_scene(self, call, updated=False): # If we can't find it, fetch latest info. if not updated and (group is None or scene is None): - await self.api.groups.update() - await self.api.scenes.update() + await self.async_request_call(self.api.groups.update) + await self.async_request_call(self.api.scenes.update) await self.hue_activate_scene(call, updated=True) return @@ -171,7 +206,7 @@ async def hue_activate_scene(self, call, updated=False): LOGGER.warning("Unable to find scene %s", scene_name) return - await group.set_action(scene=scene.id) + await self.async_request_call(partial(group.set_action, scene=scene.id)) async def handle_unauthorized_error(self): """Create a new config flow when the authorization is no longer valid.""" @@ -201,7 +236,7 @@ async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge): except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized): raise AuthenticationRequired - except (asyncio.TimeoutError, aiohue.RequestError): + except (asyncio.TimeoutError, client_exceptions.ClientOSError): raise CannotConnect except aiohue.AiohueException: LOGGER.exception("Unknown Hue linking error occurred") diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 66b9c97a58ae94..77c24caa389872 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -10,10 +10,15 @@ from homeassistant import config_entries, core from homeassistant.components import ssdp +from homeassistant.const import CONF_HOST from homeassistant.helpers import aiohttp_client from .bridge import authenticate_bridge -from .const import DOMAIN, LOGGER # pylint: disable=unused-import +from .const import ( # pylint: disable=unused-import + CONF_ALLOW_HUE_GROUPS, + DOMAIN, + LOGGER, +) from .errors import AuthenticationRequired, CannotConnect HUE_MANUFACTURERURL = "http://www.philips.com" @@ -55,10 +60,8 @@ async def async_step_init(self, user_input=None): if ( user_input is not None and self.discovered_bridges is not None - # pylint: disable=unsupported-membership-test and user_input["id"] in self.discovered_bridges ): - # pylint: disable=unsubscriptable-object self.bridge = self.discovered_bridges[user_input["id"]] await self.async_set_unique_id(self.bridge.id, raise_on_progress=False) # We pass user input to link so it will attempt to link right away @@ -124,7 +127,11 @@ async def async_step_link(self, user_input=None): return self.async_create_entry( title=bridge.config.name, - data={"host": bridge.host, "username": bridge.username}, + data={ + "host": bridge.host, + "username": bridge.username, + CONF_ALLOW_HUE_GROUPS: False, + }, ) except AuthenticationRequired: errors["base"] = "register_failed" @@ -169,7 +176,8 @@ async def async_step_ssdp(self, discovery_info): bridge = self._async_get_bridge(host, discovery_info[ssdp.ATTR_UPNP_SERIAL]) await self.async_set_unique_id(bridge.id) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: bridge.host}) + self.bridge = bridge return await self.async_step_link() @@ -180,7 +188,8 @@ async def async_step_homekit(self, homekit_info): ) await self.async_set_unique_id(bridge.id) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: bridge.host}) + self.bridge = bridge return await self.async_step_link() diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index d884389c0c18c9..e2189515482819 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -3,4 +3,13 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "hue" -API_NUPNP = "https://www.meethue.com/api/nupnp" + +# How long to wait to actually do the refresh after requesting it. +# We wait some time so if we control multiple lights, we batch requests. +REQUEST_REFRESH_DELAY = 0.3 + +CONF_ALLOW_UNREACHABLE = "allow_unreachable" +DEFAULT_ALLOW_UNREACHABLE = False + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True diff --git a/homeassistant/components/hue/helpers.py b/homeassistant/components/hue/helpers.py index 8a5fa973e4f2da..885677dc2699ae 100644 --- a/homeassistant/components/hue/helpers.py +++ b/homeassistant/components/hue/helpers.py @@ -6,7 +6,7 @@ from .const import DOMAIN -async def remove_devices(hass, config_entry, api_ids, current): +async def remove_devices(bridge, api_ids, current): """Get items that are removed from api.""" removed_items = [] @@ -18,16 +18,16 @@ async def remove_devices(hass, config_entry, api_ids, current): entity = current[item_id] removed_items.append(item_id) await entity.async_remove() - ent_registry = await get_ent_reg(hass) + ent_registry = await get_ent_reg(bridge.hass) if entity.entity_id in ent_registry.entities: ent_registry.async_remove(entity.entity_id) - dev_registry = await get_dev_reg(hass) + dev_registry = await get_dev_reg(bridge.hass) device = dev_registry.async_get_device( identifiers={(DOMAIN, entity.device_id)}, connections=set() ) if device is not None: dev_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id + device.id, remove_config_entry_id=bridge.config_entry.entry_id ) for item_id in removed_items: diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 2a668779cb5757..1678dbbfc62c09 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -1,14 +1,13 @@ """Support for the Philips Hue lights.""" import asyncio from datetime import timedelta +from functools import partial import logging import random -from time import monotonic import aiohue import async_timeout -from homeassistant.components import hue from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -28,8 +27,13 @@ SUPPORT_TRANSITION, Light, ) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import color +from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY from .helpers import remove_devices SCAN_INTERVAL = timedelta(seconds=5) @@ -68,11 +72,59 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= pass +def create_light(item_class, coordinator, bridge, is_group, api, item_id): + """Create the light.""" + if is_group: + supported_features = 0 + for light_id in api[item_id].lights: + if light_id not in bridge.api.lights: + continue + light = bridge.api.lights[light_id] + supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED) + supported_features = supported_features or SUPPORT_HUE_EXTENDED + else: + supported_features = SUPPORT_HUE.get(api[item_id].type, SUPPORT_HUE_EXTENDED) + return item_class(coordinator, bridge, is_group, api[item_id], supported_features) + + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Hue lights from a config entry.""" - bridge = hass.data[hue.DOMAIN][config_entry.data["host"]] - cur_lights = {} - cur_groups = {} + bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + + light_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) + + # First do a refresh to see if we can reach the hub. + # Otherwise we will declare not ready. + await light_coordinator.async_refresh() + + if not light_coordinator.last_update_success: + raise PlatformNotReady + + update_lights = partial( + async_update_items, + bridge, + bridge.api.lights, + {}, + async_add_entities, + partial(create_light, HueLight, light_coordinator, bridge, False), + ) + + # We add a listener after fetching the data, so manually trigger listener + light_coordinator.async_add_listener(update_lights) + update_lights() + + bridge.reset_jobs.append( + lambda: light_coordinator.async_remove_listener(update_lights) + ) api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) @@ -81,168 +133,62 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _LOGGER.warning("Please update your Hue bridge to support groups") allow_groups = False - # Hue updates all lights via a single API call. - # - # If we call a service to update 2 lights, we only want the API to be - # called once. - # - # The throttle decorator will return right away if a call is currently - # in progress. This means that if we are updating 2 lights, the first one - # is in the update method, the second one will skip it and assume the - # update went through and updates it's data, not good! - # - # The current mechanism will make sure that all lights will wait till - # the update call is done before writing their data to the state machine. - # - # An alternative approach would be to disable automatic polling by Home - # Assistant and take control ourselves. This works great for polling as now - # we trigger from 1 time update an update to all entities. However it gets - # tricky from inside async_turn_on and async_turn_off. - # - # If automatic polling is enabled, Home Assistant will call the entity - # update method after it is done calling all the services. This means that - # when we update, we know all commands have been processed. If we trigger - # the update from inside async_turn_on, the update will not capture the - # changes to the second entity until the next polling update because the - # throttle decorator will prevent the call. - - progress = None - light_progress = set() - group_progress = set() - - async def request_update(is_group, object_id): - """Request an update. - - We will only make 1 request to the server for updating at a time. If a - request is in progress, we will join the request that is in progress. - - This approach is possible because should_poll=True. That means that - Home Assistant will ask lights for updates during a polling cycle or - after it has called a service. - - We keep track of the lights that are waiting for the request to finish. - When new data comes in, we'll trigger an update for all non-waiting - lights. This covers the case where a service is called to enable 2 - lights but in the meanwhile some other light has changed too. - """ - nonlocal progress - - progress_set = group_progress if is_group else light_progress - progress_set.add(object_id) - - if progress is not None: - return await progress - - progress = asyncio.ensure_future(update_bridge()) - result = await progress - progress = None - light_progress.clear() - group_progress.clear() - return result - - async def update_bridge(): - """Update the values of the bridge. - - Will update lights and, if enabled, groups from the bridge. - """ - tasks = [] - tasks.append( - async_update_items( - hass, - config_entry, - bridge, - async_add_entities, - request_update, - False, - cur_lights, - light_progress, - ) - ) - - if allow_groups: - tasks.append( - async_update_items( - hass, - config_entry, - bridge, - async_add_entities, - request_update, - True, - cur_groups, - group_progress, - ) - ) - - await asyncio.wait(tasks) - - await update_bridge() - - -async def async_update_items( - hass, - config_entry, - bridge, - async_add_entities, - request_bridge_update, - is_group, - current, - progress_waiting, -): - """Update either groups or lights from the bridge.""" - if not bridge.authorized: + if not allow_groups: return - if is_group: - api_type = "group" - api = bridge.api.groups - else: - api_type = "light" - api = bridge.api.lights - + group_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="group", + update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) + + update_groups = partial( + async_update_items, + bridge, + bridge.api.groups, + {}, + async_add_entities, + partial(create_light, HueLight, group_coordinator, bridge, True), + ) + + group_coordinator.async_add_listener(update_groups) + await group_coordinator.async_refresh() + + bridge.reset_jobs.append( + lambda: group_coordinator.async_remove_listener(update_groups) + ) + + +async def async_safe_fetch(bridge, fetch_method): + """Safely fetch data.""" try: - start = monotonic() with async_timeout.timeout(4): - await bridge.async_request_call(api.update()) + return await bridge.async_request_call(fetch_method) except aiohue.Unauthorized: await bridge.handle_unauthorized_error() - return - except (asyncio.TimeoutError, aiohue.AiohueException) as err: - _LOGGER.debug("Failed to fetch %s: %s", api_type, err) - - if not bridge.available: - return - - _LOGGER.error("Unable to reach bridge %s (%s)", bridge.host, err) - bridge.available = False + raise UpdateFailed + except (asyncio.TimeoutError, aiohue.AiohueException): + raise UpdateFailed - for item_id, item in current.items(): - if item_id not in progress_waiting: - item.async_schedule_update_ha_state() - - return - - finally: - _LOGGER.debug( - "Finished %s request in %.3f seconds", api_type, monotonic() - start - ) - - if not bridge.available: - _LOGGER.info("Reconnected to bridge %s", bridge.host) - bridge.available = True +@callback +def async_update_items(bridge, api, current, async_add_entities, create_item): + """Update items.""" new_items = [] for item_id in api: - if item_id not in current: - current[item_id] = HueLight( - api[item_id], request_bridge_update, bridge, is_group - ) + if item_id in current: + continue - new_items.append(current[item_id]) - elif item_id not in progress_waiting: - current[item_id].async_schedule_update_ha_state() + current[item_id] = create_item(api, item_id) + new_items.append(current[item_id]) - await remove_devices(hass, config_entry, api, current) + bridge.hass.async_create_task(remove_devices(bridge, api, current)) if new_items: async_add_entities(new_items) @@ -251,12 +197,13 @@ async def async_update_items( class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light, request_bridge_update, bridge, is_group=False): + def __init__(self, coordinator, bridge, is_group, light, supported_features): """Initialize the light.""" self.light = light - self.async_request_bridge_update = request_bridge_update + self.coordinator = coordinator self.bridge = bridge self.is_group = is_group + self._supported_features = supported_features if is_group: self.is_osram = False @@ -289,6 +236,11 @@ def unique_id(self): """Return the unique ID of this Hue light.""" return self.light.uniqueid + @property + def should_poll(self): + """No polling required.""" + return False + @property def device_id(self): """Return the ID of this Hue light.""" @@ -345,20 +297,16 @@ def is_on(self): @property def available(self): """Return if light is available.""" - return ( - self.bridge.available - and self.bridge.authorized - and ( - self.is_group - or self.bridge.allow_unreachable - or self.light.state["reachable"] - ) + return self.coordinator.last_update_success and ( + self.is_group + or self.bridge.allow_unreachable + or self.light.state["reachable"] ) @property def supported_features(self): """Flag supported features.""" - return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED) + return self._supported_features @property def effect(self): @@ -379,7 +327,7 @@ def device_info(self): return None return { - "identifiers": {(hue.DOMAIN, self.device_id)}, + "identifiers": {(HUE_DOMAIN, self.device_id)}, "name": self.name, "manufacturer": self.light.manufacturername, # productname added in Hue Bridge API 1.24 @@ -387,9 +335,17 @@ def device_info(self): "model": self.light.productname or self.light.modelid, # Not yet exposed as properties in aiohue "sw_version": self.light.raw["swversion"], - "via_device": (hue.DOMAIN, self.bridge.api.config.bridgeid), + "via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), } + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) + async def async_turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {"on": True} @@ -436,9 +392,15 @@ async def async_turn_on(self, **kwargs): command["effect"] = "none" if self.is_group: - await self.bridge.async_request_call(self.light.set_action(**command)) + await self.bridge.async_request_call( + partial(self.light.set_action, **command) + ) else: - await self.bridge.async_request_call(self.light.set_state(**command)) + await self.bridge.async_request_call( + partial(self.light.set_state, **command) + ) + + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs): """Turn the specified or all lights off.""" @@ -459,13 +421,22 @@ async def async_turn_off(self, **kwargs): command["alert"] = "none" if self.is_group: - await self.bridge.async_request_call(self.light.set_action(**command)) + await self.bridge.async_request_call( + partial(self.light.set_action, **command) + ) else: - await self.bridge.async_request_call(self.light.set_state(**command)) + await self.bridge.async_request_call( + partial(self.light.set_state, **command) + ) + + await self.coordinator.async_request_refresh() async def async_update(self): - """Synchronize state with bridge.""" - await self.async_request_bridge_update(self.is_group, self.light.id) + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() @property def device_state_attributes(self): diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index f8d7295a1730ad..5471632f9c5520 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==1.10.1"], + "requirements": ["aiohue==2.0.0"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -12,6 +12,10 @@ { "manufacturer": "Royal Philips Electronics", "modelName": "Philips hue bridge 2015" + }, + { + "manufacturer": "Signify", + "modelName": "Philips hue bridge 2015" } ], "homekit": { diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index f2e02d49ecfcc1..5fa2ed683895ba 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -1,11 +1,6 @@ """Hue sensor entities.""" from aiohue.sensors import TYPE_ZLL_LIGHTLEVEL, TYPE_ZLL_TEMPERATURE -from homeassistant.components.hue.sensor_base import ( - GenericZLLSensor, - SensorManager, - async_setup_entry as shared_async_setup_entry, -) from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, @@ -13,27 +8,18 @@ ) from homeassistant.helpers.entity import Entity +from .const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor + LIGHT_LEVEL_NAME_FORMAT = "{} light level" TEMPERATURE_NAME_FORMAT = "{} temperature" async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" - SensorManager.sensor_config_map.update( - { - TYPE_ZLL_LIGHTLEVEL: { - "binary": False, - "name_format": LIGHT_LEVEL_NAME_FORMAT, - "class": HueLightLevel, - }, - TYPE_ZLL_TEMPERATURE: { - "binary": False, - "name_format": TEMPERATURE_NAME_FORMAT, - "class": HueTemperature, - }, - } - ) - await shared_async_setup_entry(hass, config_entry, async_add_entities, binary=False) + await hass.data[HUE_DOMAIN][ + config_entry.entry_id + ].sensor_manager.async_register_component(False, async_add_entities) class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity): @@ -91,3 +77,19 @@ def state(self): return None return self.sensor.temperature / 100 + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_LIGHTLEVEL: { + "binary": False, + "name_format": LIGHT_LEVEL_NAME_FORMAT, + "class": HueLightLevel, + }, + TYPE_ZLL_TEMPERATURE: { + "binary": False, + "name_format": TEMPERATURE_NAME_FORMAT, + "class": HueTemperature, + }, + } +) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index f7882b102c0742..0bc7cd53536ef3 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -2,22 +2,19 @@ import asyncio from datetime import timedelta import logging -from time import monotonic from aiohue import AiohueException, Unauthorized from aiohue.sensors import TYPE_ZLL_PRESENCE import async_timeout -from homeassistant.components import hue -from homeassistant.exceptions import NoEntitySpecifiedError -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow +from homeassistant.core import callback +from homeassistant.helpers import debounce, entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY from .helpers import remove_devices -CURRENT_SENSORS_FORMAT = "{}_current_sensors" -SENSOR_MANAGER_FORMAT = "{}_sensor_manager" - +SENSOR_CONFIG_MAP = {} _LOGGER = logging.getLogger(__name__) @@ -29,22 +26,6 @@ def _device_id(aiohue_sensor): return device_id -async def async_setup_entry(hass, config_entry, async_add_entities, binary=False): - """Set up the Hue sensors from a config entry.""" - sensor_key = CURRENT_SENSORS_FORMAT.format(config_entry.data["host"]) - bridge = hass.data[hue.DOMAIN][config_entry.data["host"]] - hass.data[hue.DOMAIN].setdefault(sensor_key, {}) - - 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, config_entry) - hass.data[hue.DOMAIN][sm_key] = manager - - manager.register_component(binary, async_add_entities) - await manager.start() - - class SensorManager: """Class that handles registering and updating Hue sensor entities. @@ -52,84 +33,62 @@ class SensorManager: """ SCAN_INTERVAL = timedelta(seconds=5) - sensor_config_map = {} - def __init__(self, hass, bridge, config_entry): + def __init__(self, bridge): """Initialize the sensor manager.""" - self.hass = hass self.bridge = bridge - self.config_entry = config_entry self._component_add_entities = {} - self._started = False + self.current = {} + self.coordinator = DataUpdateCoordinator( + bridge.hass, + _LOGGER, + name="sensor", + update_method=self.async_update_data, + update_interval=self.SCAN_INTERVAL, + request_refresh_debouncer=debounce.Debouncer( + bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True + ), + ) - def register_component(self, binary, async_add_entities): + async def async_update_data(self): + """Update sensor data.""" + try: + with async_timeout.timeout(4): + return await self.bridge.async_request_call( + self.bridge.api.sensors.update + ) + except Unauthorized: + await self.bridge.handle_unauthorized_error() + raise UpdateFailed + except (asyncio.TimeoutError, AiohueException): + raise UpdateFailed + + async def async_register_component(self, binary, async_add_entities): """Register async_add_entities methods for components.""" self._component_add_entities[binary] = async_add_entities - async def start(self): - """Start updating sensors from the bridge on a schedule.""" - # but only if it's not already started, and when we've got both - # async_add_entities methods - if self._started or len(self._component_add_entities) < 2: + if len(self._component_add_entities) < 2: return - self._started = True - _LOGGER.info( - "Starting sensor polling loop with %s second interval", - self.SCAN_INTERVAL.total_seconds(), + # We have all components available, start the updating. + self.coordinator.async_add_listener(self.async_update_items) + self.bridge.reset_jobs.append( + lambda: self.coordinator.async_remove_listener(self.async_update_items) ) + await self.coordinator.async_refresh() - async def async_update_bridge(now): - """Will update sensors from the bridge.""" - - # don't update when we are not authorized - if not self.bridge.authorized: - return - - await self.async_update_items() - - async_track_point_in_utc_time( - self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL - ) - - await async_update_bridge(None) - - async def async_update_items(self): + @callback + def async_update_items(self): """Update sensors from the bridge.""" api = self.bridge.api.sensors - try: - start = monotonic() - with async_timeout.timeout(4): - await self.bridge.async_request_call(api.update()) - except Unauthorized: - await self.bridge.handle_unauthorized_error() + if len(self._component_add_entities) < 2: return - except (asyncio.TimeoutError, AiohueException) as err: - _LOGGER.debug("Failed to fetch sensor: %s", err) - - if not self.bridge.available: - return - - _LOGGER.error("Unable to reach bridge %s (%s)", self.bridge.host, err) - self.bridge.available = False - - return - - finally: - _LOGGER.debug( - "Finished sensor request in %.3f seconds", monotonic() - start - ) - - if not self.bridge.available: - _LOGGER.info("Reconnected to bridge %s", self.bridge.host) - self.bridge.available = True new_sensors = [] new_binary_sensors = [] primary_sensor_devices = {} - sensor_key = CURRENT_SENSORS_FORMAT.format(self.config_entry.data["host"]) - current = self.hass.data[hue.DOMAIN][sensor_key] + current = self.current # Physical Hue motion sensors present as three sensors in the API: a # presence sensor, a temperature sensor, and a light level sensor. Of @@ -155,11 +114,10 @@ async def async_update_items(self): for item_id in api: existing = current.get(api[item_id].uniqueid) if existing is not None: - self.hass.async_create_task(existing.async_maybe_update_ha_state()) continue primary_sensor = None - sensor_config = self.sensor_config_map.get(api[item_id].type) + sensor_config = SENSOR_CONFIG_MAP.get(api[item_id].type) if sensor_config is None: continue @@ -177,22 +135,19 @@ async def async_update_items(self): else: new_sensors.append(current[api[item_id].uniqueid]) - await remove_devices( - self.hass, - self.config_entry, - [value.uniqueid for value in api.values()], - current, + self.bridge.hass.async_create_task( + remove_devices( + self.bridge, [value.uniqueid for value in api.values()], current, + ) ) - async_add_sensor_entities = self._component_add_entities.get(False) - async_add_binary_entities = self._component_add_entities.get(True) - if new_sensors and async_add_sensor_entities: - async_add_sensor_entities(new_sensors) - if new_binary_sensors and async_add_binary_entities: - async_add_binary_entities(new_binary_sensors) + if new_sensors: + self._component_add_entities[False](new_sensors) + if new_binary_sensors: + self._component_add_entities[True](new_binary_sensors) -class GenericHueSensor: +class GenericHueSensor(entity.Entity): """Representation of a Hue sensor.""" should_poll = False @@ -230,10 +185,8 @@ def name(self): @property def available(self): """Return if sensor is available.""" - return ( - self.bridge.available - and self.bridge.authorized - and (self.bridge.allow_unreachable or self.sensor.config["reachable"]) + return self.bridge.sensor_manager.coordinator.last_update_success and ( + self.bridge.allow_unreachable or self.sensor.config["reachable"] ) @property @@ -241,15 +194,24 @@ def swupdatestate(self): """Return detail of available software updates for this device.""" return self.primary_sensor.raw.get("swupdate", {}).get("state") - async def async_maybe_update_ha_state(self): - """Try to update Home Assistant with current state of entity. + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.bridge.sensor_manager.coordinator.async_add_listener( + self.async_write_ha_state + ) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self.bridge.sensor_manager.coordinator.async_remove_listener( + self.async_write_ha_state + ) + + async def async_update(self): + """Update the entity. - But if it's not been added to hass yet, then don't throw an error. + Only used by the generic entity update service. """ - try: - await self._async_update_ha_state() - except (RuntimeError, NoEntitySpecifiedError): - _LOGGER.debug("Hue sensor update requested before it has been added.") + await self.bridge.sensor_manager.coordinator.async_request_refresh() @property def device_info(self): @@ -258,12 +220,12 @@ def device_info(self): Links individual entities together in the hass device registry. """ return { - "identifiers": {(hue.DOMAIN, self.device_id)}, + "identifiers": {(HUE_DOMAIN, self.device_id)}, "name": self.primary_sensor.name, "manufacturer": self.primary_sensor.manufacturername, "model": (self.primary_sensor.productname or self.primary_sensor.modelid), "sw_version": self.primary_sensor.swversion, - "via_device": (hue.DOMAIN, self.bridge.api.config.bridgeid), + "via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), } diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 57ed29d9780e43..b8ed596d28699b 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -6,7 +6,12 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_ACCESS_TOKEN, + CONF_SCAN_INTERVAL, + TIME_MINUTES, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -40,7 +45,7 @@ "manual_watering": ["Manual Watering", "mdi:water-pump", "", ""], "next_cycle": ["Next Cycle", "mdi:calendar-clock", "", ""], "status": ["Status", "", "connectivity", ""], - "watering_time": ["Watering Time", "mdi:water-pump", "", "min"], + "watering_time": ["Watering Time", "mdi:water-pump", "", TIME_MINUTES], "rain_sensor": ["Rain Sensor", "", "moisture", ""], } diff --git a/homeassistant/components/iaqualink/.translations/hu.json b/homeassistant/components/iaqualink/.translations/hu.json new file mode 100644 index 00000000000000..b0b9393acded99 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v / e-mail c\u00edm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/pl.json b/homeassistant/components/iaqualink/.translations/pl.json index 211a65f5ccb4fb..d14a2775c15721 100644 --- a/homeassistant/components/iaqualink/.translations/pl.json +++ b/homeassistant/components/iaqualink/.translations/pl.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika / adres e-mail" + "username": "Nazwa u\u017cytkownika/adres e-mail" }, "description": "Wprowad\u017a nazw\u0119 u\u017cytkownika i has\u0142o do konta iAqualink.", "title": "Po\u0142\u0105cz z iAqualink" diff --git a/homeassistant/components/iaqualink/.translations/sv.json b/homeassistant/components/iaqualink/.translations/sv.json new file mode 100644 index 00000000000000..aa2b41426165c6 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bara konfigurera en enda iAqualink-anslutning." + }, + "error": { + "connection_failure": "Det g\u00e5r inte att ansluta till iAqualink. Kontrollera ditt anv\u00e4ndarnamn och l\u00f6senord." + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn / E-postadress" + }, + "description": "V\u00e4nligen ange anv\u00e4ndarnamn och l\u00f6senord f\u00f6r ditt iAqualink-konto.", + "title": "Anslut till iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index d577fe448aa01e..d64ec711198c68 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -6,7 +6,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import ConfigType +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 85392e6371bee8..ea3b1eef8d06c8 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink/", "dependencies": [], "codeowners": ["@flz"], - "requirements": ["iaqualink==0.3.0"] + "requirements": ["iaqualink==0.3.1"] } diff --git a/homeassistant/components/icloud/.translations/ca.json b/homeassistant/components/icloud/.translations/ca.json index 33fd399d33e962..aa8f837412445b 100644 --- a/homeassistant/components/icloud/.translations/ca.json +++ b/homeassistant/components/icloud/.translations/ca.json @@ -22,7 +22,7 @@ "username": "Correu electr\u00f2nic" }, "description": "Introdueix les teves credencials", - "title": "credencials d'iCloud" + "title": "Credencials d'iCloud" }, "verification_code": { "data": { diff --git a/homeassistant/components/icloud/.translations/hu.json b/homeassistant/components/icloud/.translations/hu.json new file mode 100644 index 00000000000000..14c8c8e4e2fb9e --- /dev/null +++ b/homeassistant/components/icloud/.translations/hu.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "login": "Bejelentkez\u00e9si hiba: k\u00e9rj\u00fck, ellen\u0151rizze e-mail c\u00edm\u00e9t \u00e9s jelszav\u00e1t", + "send_verification_code": "Nem siker\u00fclt elk\u00fcldeni az ellen\u0151rz\u0151 k\u00f3dot", + "validate_verification_code": "Nem siker\u00fclt ellen\u0151rizni az ellen\u0151rz\u0151 k\u00f3dot, ki kell v\u00e1lasztania egy megb\u00edzhat\u00f3s\u00e1gi eszk\u00f6zt, \u00e9s \u00fajra kell ind\u00edtania az ellen\u0151rz\u00e9st" + }, + "step": { + "trusted_device": { + "data": { + "trusted_device": "Megb\u00edzhat\u00f3 eszk\u00f6z" + }, + "description": "V\u00e1lassza ki a megb\u00edzhat\u00f3 eszk\u00f6zt", + "title": "iCloud megb\u00edzhat\u00f3 eszk\u00f6z" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "E-mail" + }, + "description": "Adja meg hiteles\u00edt\u0151 adatait", + "title": "iCloud hiteles\u00edt\u0151 adatok" + }, + "verification_code": { + "data": { + "verification_code": "Ellen\u0151rz\u0151 k\u00f3d" + }, + "description": "K\u00e9rj\u00fck, \u00edrja be az iCloud-t\u00f3l \u00e9ppen kapott ellen\u0151rz\u0151 k\u00f3dot", + "title": "iCloud ellen\u0151rz\u0151 k\u00f3d" + } + }, + "title": "Apple iCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/icloud/.translations/pl.json b/homeassistant/components/icloud/.translations/pl.json index 169fe2eac2dd57..41e182eceee511 100644 --- a/homeassistant/components/icloud/.translations/pl.json +++ b/homeassistant/components/icloud/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane." }, "error": { "login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o", diff --git a/homeassistant/components/icloud/.translations/sv.json b/homeassistant/components/icloud/.translations/sv.json index 8c4c45f9c897c8..fc5b81b6591ca6 100644 --- a/homeassistant/components/icloud/.translations/sv.json +++ b/homeassistant/components/icloud/.translations/sv.json @@ -1,5 +1,37 @@ { "config": { - "title": "" + "abort": { + "already_configured": "Kontot har redan konfigurerats" + }, + "error": { + "login": "Inloggningsfel: var god att kontrollera din e-postadress och l\u00f6senord", + "send_verification_code": "Det gick inte att skicka verifieringskod", + "validate_verification_code": "Det gick inte att verifiera verifieringskoden, v\u00e4lj en betrodd enhet och starta verifieringen igen" + }, + "step": { + "trusted_device": { + "data": { + "trusted_device": "Betrodd enhet" + }, + "description": "V\u00e4lj din betrodda enhet", + "title": "Betrodd iCloud-enhet" + }, + "user": { + "data": { + "password": "L\u00f6senord", + "username": "E-post" + }, + "description": "Ange dina autentiseringsuppgifter", + "title": "iCloud-autentiseringsuppgifter" + }, + "verification_code": { + "data": { + "verification_code": "Verifieringskod" + }, + "description": "V\u00e4nligen ange verifieringskoden som du just f\u00e5tt fr\u00e5n iCloud", + "title": "iCloud-verifieringskod" + } + }, + "title": "Apple iCloud" } } \ No newline at end of file diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 525831ce214967..687c6bf93de920 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -1,51 +1,23 @@ """The iCloud component.""" -from datetime import timedelta +import asyncio import logging -import operator -from typing import Dict -from pyicloud import PyiCloudService -from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudNoDevicesException -from pyicloud.services.findmyiphone import AppleDevice import voluptuous as vol -from homeassistant.components.zone import async_active_zone from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceDataType from homeassistant.util import slugify -from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.dt import utcnow -from homeassistant.util.location import distance +from .account import IcloudAccount from .const import ( CONF_GPS_ACCURACY_THRESHOLD, CONF_MAX_INTERVAL, DEFAULT_GPS_ACCURACY_THRESHOLD, DEFAULT_MAX_INTERVAL, - DEVICE_BATTERY_LEVEL, - DEVICE_BATTERY_STATUS, - DEVICE_CLASS, - DEVICE_DISPLAY_NAME, - DEVICE_ID, - DEVICE_LOCATION, - DEVICE_LOCATION_LATITUDE, - DEVICE_LOCATION_LONGITUDE, - DEVICE_LOST_MODE_CAPABLE, - DEVICE_LOW_POWER_MODE, - DEVICE_NAME, - DEVICE_PERSON_ID, - DEVICE_RAW_DEVICE_MODEL, - DEVICE_STATUS, - DEVICE_STATUS_CODES, - DEVICE_STATUS_SET, DOMAIN, - ICLOUD_COMPONENTS, - SERVICE_UPDATE, + PLATFORMS, STORAGE_KEY, STORAGE_VERSION, ) @@ -151,11 +123,14 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass, username, password, icloud_dir, max_interval, gps_accuracy_threshold, ) await hass.async_add_executor_job(account.setup) + if not account.devices: + return False + hass.data[DOMAIN][username] = account - for component in ICLOUD_COMPONENTS: + for platform in PLATFORMS: hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) + hass.config_entries.async_forward_entry_setup(entry, platform) ) def play_sound(service: ServiceDataType) -> None: @@ -240,361 +215,17 @@ def _get_account(account_identifier: str) -> any: return True -class IcloudAccount: - """Representation of an iCloud account.""" - - def __init__( - self, - hass: HomeAssistantType, - username: str, - password: str, - icloud_dir: Store, - max_interval: int, - gps_accuracy_threshold: int, - ): - """Initialize an iCloud account.""" - self.hass = hass - self._username = username - self._password = password - self._fetch_interval = max_interval - self._max_interval = max_interval - self._gps_accuracy_threshold = gps_accuracy_threshold - - self._icloud_dir = icloud_dir - - self.api: PyiCloudService = None - self._owner_fullname = None - self._family_members_fullname = {} - self._devices = {} - - self.unsub_device_tracker = None - - def setup(self) -> None: - """Set up an iCloud account.""" - try: - self.api = PyiCloudService( - self._username, self._password, self._icloud_dir.path - ) - except PyiCloudFailedLoginException as error: - self.api = None - _LOGGER.error("Error logging into iCloud Service: %s", error) - return - - user_info = None - try: - # Gets device owners infos - user_info = self.api.devices.response["userInfo"] - except PyiCloudNoDevicesException: - _LOGGER.error("No iCloud device found") - return - - self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" - - self._family_members_fullname = {} - if user_info.get("membersInfo") is not None: - for prs_id, member in user_info["membersInfo"].items(): - self._family_members_fullname[ - prs_id - ] = f"{member['firstName']} {member['lastName']}" - - self._devices = {} - self.update_devices() - - def update_devices(self) -> None: - """Update iCloud devices.""" - if self.api is None: - return - - api_devices = {} - try: - api_devices = self.api.devices - except PyiCloudNoDevicesException: - _LOGGER.error("No iCloud device found") - return - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unknown iCloud error: %s", err) - self._fetch_interval = 5 - dispatcher_send(self.hass, SERVICE_UPDATE) - track_point_in_utc_time( - self.hass, - self.keep_alive, - utcnow() + timedelta(minutes=self._fetch_interval), - ) - return - - # Gets devices infos - for device in api_devices: - status = device.status(DEVICE_STATUS_SET) - device_id = status[DEVICE_ID] - device_name = status[DEVICE_NAME] - - if self._devices.get(device_id, None) is not None: - # Seen device -> updating - _LOGGER.debug("Updating iCloud device: %s", device_name) - self._devices[device_id].update(status) - else: - # New device, should be unique - _LOGGER.debug( - "Adding iCloud device: %s [model: %s]", - device_name, - status[DEVICE_RAW_DEVICE_MODEL], - ) - self._devices[device_id] = IcloudDevice(self, device, status) - self._devices[device_id].update(status) - - self._fetch_interval = self._determine_interval() - dispatcher_send(self.hass, SERVICE_UPDATE) - track_point_in_utc_time( - self.hass, - self.keep_alive, - utcnow() + timedelta(minutes=self._fetch_interval), - ) - - def _determine_interval(self) -> int: - """Calculate new interval between two API fetch (in minutes).""" - intervals = {} - for device in self._devices.values(): - if device.location is None: - continue - - current_zone = run_callback_threadsafe( - self.hass.loop, - async_active_zone, - self.hass, - device.location[DEVICE_LOCATION_LATITUDE], - device.location[DEVICE_LOCATION_LONGITUDE], - ).result() - - if current_zone is not None: - intervals[device.name] = self._max_interval - continue - - zones = ( - self.hass.states.get(entity_id) - for entity_id in sorted(self.hass.states.entity_ids("zone")) - ) - - distances = [] - for zone_state in zones: - zone_state_lat = zone_state.attributes[DEVICE_LOCATION_LATITUDE] - zone_state_long = zone_state.attributes[DEVICE_LOCATION_LONGITUDE] - zone_distance = distance( - device.location[DEVICE_LOCATION_LATITUDE], - device.location[DEVICE_LOCATION_LONGITUDE], - zone_state_lat, - zone_state_long, - ) - distances.append(round(zone_distance / 1000, 1)) - - if not distances: - continue - mindistance = min(distances) - - # Calculate out how long it would take for the device to drive - # to the nearest zone at 120 km/h: - interval = round(mindistance / 2, 0) - - # Never poll more than once per minute - interval = max(interval, 1) - - if interval > 180: - # Three hour drive? - # This is far enough that they might be flying - interval = self._max_interval - - if ( - device.battery_level is not None - and device.battery_level <= 33 - and mindistance > 3 - ): - # Low battery - let's check half as often - interval = interval * 2 - - intervals[device.name] = interval - - return max( - int(min(intervals.items(), key=operator.itemgetter(1))[1]), - self._max_interval, - ) - - def keep_alive(self, now=None) -> None: - """Keep the API alive.""" - if self.api is None: - self.setup() - - if self.api is None: - return - - self.api.authenticate() - self.update_devices() - - def get_devices_with_name(self, name: str) -> [any]: - """Get devices by name.""" - result = [] - name_slug = slugify(name.replace(" ", "", 99)) - for device in self.devices.values(): - if slugify(device.name.replace(" ", "", 99)) == name_slug: - result.append(device) - if not result: - raise Exception(f"No device with name {name}") - return result - - @property - def username(self) -> str: - """Return the account username.""" - return self._username - - @property - def owner_fullname(self) -> str: - """Return the account owner fullname.""" - return self._owner_fullname - - @property - def family_members_fullname(self) -> Dict[str, str]: - """Return the account family members fullname.""" - return self._family_members_fullname - - @property - def fetch_interval(self) -> int: - """Return the account fetch interval.""" - return self._fetch_interval - - @property - def devices(self) -> Dict[str, any]: - """Return the account devices.""" - return self._devices - - -class IcloudDevice: - """Representation of a iCloud device.""" - - def __init__(self, account: IcloudAccount, device: AppleDevice, status): - """Initialize the iCloud device.""" - self._account = account - - self._device = device - self._status = status - - self._name = self._status[DEVICE_NAME] - self._device_id = self._status[DEVICE_ID] - self._device_class = self._status[DEVICE_CLASS] - self._device_model = self._status[DEVICE_DISPLAY_NAME] - - if self._status[DEVICE_PERSON_ID]: - owner_fullname = account.family_members_fullname[ - self._status[DEVICE_PERSON_ID] +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS ] - else: - owner_fullname = account.owner_fullname - - self._battery_level = None - self._battery_status = None - self._location = None - - self._attrs = { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_ACCOUNT_FETCH_INTERVAL: self._account.fetch_interval, - ATTR_DEVICE_NAME: self._device_model, - ATTR_DEVICE_STATUS: None, - ATTR_OWNER_NAME: owner_fullname, - } - - def update(self, status) -> None: - """Update the iCloud device.""" - self._status = status - - self._status[ATTR_ACCOUNT_FETCH_INTERVAL] = self._account.fetch_interval - - device_status = DEVICE_STATUS_CODES.get(self._status[DEVICE_STATUS], "error") - self._attrs[ATTR_DEVICE_STATUS] = device_status - - if self._status[DEVICE_BATTERY_STATUS] != "Unknown": - self._battery_level = int(self._status.get(DEVICE_BATTERY_LEVEL, 0) * 100) - self._battery_status = self._status[DEVICE_BATTERY_STATUS] - low_power_mode = self._status[DEVICE_LOW_POWER_MODE] - - self._attrs[ATTR_BATTERY] = self._battery_level - self._attrs[ATTR_BATTERY_STATUS] = self._battery_status - self._attrs[ATTR_LOW_POWER_MODE] = low_power_mode - - if ( - self._status[DEVICE_LOCATION] - and self._status[DEVICE_LOCATION][DEVICE_LOCATION_LATITUDE] - ): - location = self._status[DEVICE_LOCATION] - self._location = location - - def play_sound(self) -> None: - """Play sound on the device.""" - if self._account.api is None: - return - - self._account.api.authenticate() - _LOGGER.debug("Playing sound for %s", self.name) - self.device.play_sound() - - def display_message(self, message: str, sound: bool = False) -> None: - """Display a message on the device.""" - if self._account.api is None: - return - - self._account.api.authenticate() - _LOGGER.debug("Displaying message for %s", self.name) - self.device.display_message("Subject not working", message, sound) - - def lost_device(self, number: str, message: str) -> None: - """Make the device in lost state.""" - if self._account.api is None: - return + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.data[CONF_USERNAME]) - self._account.api.authenticate() - if self._status[DEVICE_LOST_MODE_CAPABLE]: - _LOGGER.debug("Make device lost for %s", self.name) - self.device.lost_device(number, message, None) - else: - _LOGGER.error("Cannot make device lost for %s", self.name) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device_id - - @property - def name(self) -> str: - """Return the Apple device name.""" - return self._name - - @property - def device(self) -> AppleDevice: - """Return the Apple device.""" - return self._device - - @property - def device_class(self) -> str: - """Return the Apple device class.""" - return self._device_class - - @property - def device_model(self) -> str: - """Return the Apple device model.""" - return self._device_model - - @property - def battery_level(self) -> int: - """Return the Apple device battery level.""" - return self._battery_level - - @property - def battery_status(self) -> str: - """Return the Apple device battery status.""" - return self._battery_status - - @property - def location(self) -> Dict[str, any]: - """Return the Apple device location.""" - return self._location - - @property - def state_attributes(self) -> Dict[str, any]: - """Return the attributes.""" - return self._attrs + return unload_ok diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py new file mode 100644 index 00000000000000..5d6815396681ec --- /dev/null +++ b/homeassistant/components/icloud/account.py @@ -0,0 +1,426 @@ +"""iCloud account.""" +from datetime import timedelta +import logging +import operator +from typing import Dict + +from pyicloud import PyiCloudService +from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudNoDevicesException +from pyicloud.services.findmyiphone import AppleDevice + +from homeassistant.components.zone import async_active_zone +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify +from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.dt import utcnow +from homeassistant.util.location import distance + +from .const import ( + DEVICE_BATTERY_LEVEL, + DEVICE_BATTERY_STATUS, + DEVICE_CLASS, + DEVICE_DISPLAY_NAME, + DEVICE_ID, + DEVICE_LOCATION, + DEVICE_LOCATION_HORIZONTAL_ACCURACY, + DEVICE_LOCATION_LATITUDE, + DEVICE_LOCATION_LONGITUDE, + DEVICE_LOST_MODE_CAPABLE, + DEVICE_LOW_POWER_MODE, + DEVICE_NAME, + DEVICE_PERSON_ID, + DEVICE_RAW_DEVICE_MODEL, + DEVICE_STATUS, + DEVICE_STATUS_CODES, + DEVICE_STATUS_SET, + SERVICE_UPDATE, +) + +ATTRIBUTION = "Data provided by Apple iCloud" + +# entity attributes +ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" +ATTR_BATTERY = "battery" +ATTR_BATTERY_STATUS = "battery_status" +ATTR_DEVICE_NAME = "device_name" +ATTR_DEVICE_STATUS = "device_status" +ATTR_LOW_POWER_MODE = "low_power_mode" +ATTR_OWNER_NAME = "owner_fullname" + +# services +SERVICE_ICLOUD_PLAY_SOUND = "play_sound" +SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" +SERVICE_ICLOUD_LOST_DEVICE = "lost_device" +SERVICE_ICLOUD_UPDATE = "update" +ATTR_ACCOUNT = "account" +ATTR_LOST_DEVICE_MESSAGE = "message" +ATTR_LOST_DEVICE_NUMBER = "number" +ATTR_LOST_DEVICE_SOUND = "sound" + +_LOGGER = logging.getLogger(__name__) + + +class IcloudAccount: + """Representation of an iCloud account.""" + + def __init__( + self, + hass: HomeAssistantType, + username: str, + password: str, + icloud_dir: Store, + max_interval: int, + gps_accuracy_threshold: int, + ): + """Initialize an iCloud account.""" + self.hass = hass + self._username = username + self._password = password + self._fetch_interval = max_interval + self._max_interval = max_interval + self._gps_accuracy_threshold = gps_accuracy_threshold + + self._icloud_dir = icloud_dir + + self.api: PyiCloudService = None + self._owner_fullname = None + self._family_members_fullname = {} + self._devices = {} + + self.unsub_device_tracker = None + + def setup(self) -> None: + """Set up an iCloud account.""" + try: + self.api = PyiCloudService( + self._username, self._password, self._icloud_dir.path + ) + except PyiCloudFailedLoginException as error: + self.api = None + _LOGGER.error("Error logging into iCloud Service: %s", error) + return + + user_info = None + try: + # Gets device owners infos + user_info = self.api.devices.response["userInfo"] + except PyiCloudNoDevicesException: + _LOGGER.error("No iCloud device found") + return + + self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" + + self._family_members_fullname = {} + if user_info.get("membersInfo") is not None: + for prs_id, member in user_info["membersInfo"].items(): + self._family_members_fullname[ + prs_id + ] = f"{member['firstName']} {member['lastName']}" + + self._devices = {} + self.update_devices() + + def update_devices(self) -> None: + """Update iCloud devices.""" + if self.api is None: + return + + api_devices = {} + try: + api_devices = self.api.devices + except PyiCloudNoDevicesException: + _LOGGER.error("No iCloud device found") + return + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unknown iCloud error: %s", err) + self._fetch_interval = 5 + dispatcher_send(self.hass, SERVICE_UPDATE) + track_point_in_utc_time( + self.hass, + self.keep_alive, + utcnow() + timedelta(minutes=self._fetch_interval), + ) + return + + # Gets devices infos + for device in api_devices: + status = device.status(DEVICE_STATUS_SET) + device_id = status[DEVICE_ID] + device_name = status[DEVICE_NAME] + + if self._devices.get(device_id, None) is not None: + # Seen device -> updating + _LOGGER.debug("Updating iCloud device: %s", device_name) + self._devices[device_id].update(status) + else: + # New device, should be unique + _LOGGER.debug( + "Adding iCloud device: %s [model: %s]", + device_name, + status[DEVICE_RAW_DEVICE_MODEL], + ) + self._devices[device_id] = IcloudDevice(self, device, status) + self._devices[device_id].update(status) + + self._fetch_interval = self._determine_interval() + dispatcher_send(self.hass, SERVICE_UPDATE) + track_point_in_utc_time( + self.hass, + self.keep_alive, + utcnow() + timedelta(minutes=self._fetch_interval), + ) + + def _determine_interval(self) -> int: + """Calculate new interval between two API fetch (in minutes).""" + intervals = {"default": self._max_interval} + for device in self._devices.values(): + # Max interval if no location + if device.location is None: + continue + + current_zone = run_callback_threadsafe( + self.hass.loop, + async_active_zone, + self.hass, + device.location[DEVICE_LOCATION_LATITUDE], + device.location[DEVICE_LOCATION_LONGITUDE], + device.location[DEVICE_LOCATION_HORIZONTAL_ACCURACY], + ).result() + + # Max interval if in zone + if current_zone is not None: + continue + + zones = ( + self.hass.states.get(entity_id) + for entity_id in sorted(self.hass.states.entity_ids("zone")) + ) + + distances = [] + for zone_state in zones: + zone_state_lat = zone_state.attributes[DEVICE_LOCATION_LATITUDE] + zone_state_long = zone_state.attributes[DEVICE_LOCATION_LONGITUDE] + zone_distance = distance( + device.location[DEVICE_LOCATION_LATITUDE], + device.location[DEVICE_LOCATION_LONGITUDE], + zone_state_lat, + zone_state_long, + ) + distances.append(round(zone_distance / 1000, 1)) + + # Max interval if no zone + if not distances: + continue + mindistance = min(distances) + + # Calculate out how long it would take for the device to drive + # to the nearest zone at 120 km/h: + interval = round(mindistance / 2, 0) + + # Never poll more than once per minute + interval = max(interval, 1) + + if interval > 180: + # Three hour drive? + # This is far enough that they might be flying + interval = self._max_interval + + if ( + device.battery_level is not None + and device.battery_level <= 33 + and mindistance > 3 + ): + # Low battery - let's check half as often + interval = interval * 2 + + intervals[device.name] = interval + + return max( + int(min(intervals.items(), key=operator.itemgetter(1))[1]), + self._max_interval, + ) + + def keep_alive(self, now=None) -> None: + """Keep the API alive.""" + if self.api is None: + self.setup() + + if self.api is None: + return + + self.api.authenticate() + self.update_devices() + + def get_devices_with_name(self, name: str) -> [any]: + """Get devices by name.""" + result = [] + name_slug = slugify(name.replace(" ", "", 99)) + for device in self.devices.values(): + if slugify(device.name.replace(" ", "", 99)) == name_slug: + result.append(device) + if not result: + raise Exception(f"No device with name {name}") + return result + + @property + def username(self) -> str: + """Return the account username.""" + return self._username + + @property + def owner_fullname(self) -> str: + """Return the account owner fullname.""" + return self._owner_fullname + + @property + def family_members_fullname(self) -> Dict[str, str]: + """Return the account family members fullname.""" + return self._family_members_fullname + + @property + def fetch_interval(self) -> int: + """Return the account fetch interval.""" + return self._fetch_interval + + @property + def devices(self) -> Dict[str, any]: + """Return the account devices.""" + return self._devices + + +class IcloudDevice: + """Representation of a iCloud device.""" + + def __init__(self, account: IcloudAccount, device: AppleDevice, status): + """Initialize the iCloud device.""" + self._account = account + + self._device = device + self._status = status + + self._name = self._status[DEVICE_NAME] + self._device_id = self._status[DEVICE_ID] + self._device_class = self._status[DEVICE_CLASS] + self._device_model = self._status[DEVICE_DISPLAY_NAME] + + if self._status[DEVICE_PERSON_ID]: + owner_fullname = account.family_members_fullname[ + self._status[DEVICE_PERSON_ID] + ] + else: + owner_fullname = account.owner_fullname + + self._battery_level = None + self._battery_status = None + self._location = None + + self._attrs = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_ACCOUNT_FETCH_INTERVAL: self._account.fetch_interval, + ATTR_DEVICE_NAME: self._device_model, + ATTR_DEVICE_STATUS: None, + ATTR_OWNER_NAME: owner_fullname, + } + + def update(self, status) -> None: + """Update the iCloud device.""" + self._status = status + + self._status[ATTR_ACCOUNT_FETCH_INTERVAL] = self._account.fetch_interval + + device_status = DEVICE_STATUS_CODES.get(self._status[DEVICE_STATUS], "error") + self._attrs[ATTR_DEVICE_STATUS] = device_status + + self._battery_status = self._status[DEVICE_BATTERY_STATUS] + self._attrs[ATTR_BATTERY_STATUS] = self._battery_status + device_battery_level = self._status.get(DEVICE_BATTERY_LEVEL, 0) + if self._battery_status != "Unknown" and device_battery_level is not None: + self._battery_level = int(device_battery_level * 100) + self._attrs[ATTR_BATTERY] = self._battery_level + self._attrs[ATTR_LOW_POWER_MODE] = self._status[DEVICE_LOW_POWER_MODE] + + if ( + self._status[DEVICE_LOCATION] + and self._status[DEVICE_LOCATION][DEVICE_LOCATION_LATITUDE] + ): + location = self._status[DEVICE_LOCATION] + self._location = location + + def play_sound(self) -> None: + """Play sound on the device.""" + if self._account.api is None: + return + + self._account.api.authenticate() + _LOGGER.debug("Playing sound for %s", self.name) + self.device.play_sound() + + def display_message(self, message: str, sound: bool = False) -> None: + """Display a message on the device.""" + if self._account.api is None: + return + + self._account.api.authenticate() + _LOGGER.debug("Displaying message for %s", self.name) + self.device.display_message("Subject not working", message, sound) + + def lost_device(self, number: str, message: str) -> None: + """Make the device in lost state.""" + if self._account.api is None: + return + + self._account.api.authenticate() + if self._status[DEVICE_LOST_MODE_CAPABLE]: + _LOGGER.debug("Make device lost for %s", self.name) + self.device.lost_device(number, message, None) + else: + _LOGGER.error("Cannot make device lost for %s", self.name) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._device_id + + @property + def name(self) -> str: + """Return the Apple device name.""" + return self._name + + @property + def device(self) -> AppleDevice: + """Return the Apple device.""" + return self._device + + @property + def device_class(self) -> str: + """Return the Apple device class.""" + return self._device_class + + @property + def device_model(self) -> str: + """Return the Apple device model.""" + return self._device_model + + @property + def battery_level(self) -> int: + """Return the Apple device battery level.""" + return self._battery_level + + @property + def battery_status(self) -> str: + """Return the Apple device battery status.""" + return self._battery_status + + @property + def location(self) -> Dict[str, any]: + """Return the Apple device location.""" + return self._location + + @property + def state_attributes(self) -> Dict[str, any]: + """Return the attributes.""" + return self._attrs diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index 9b00ccb2a8d470..b3cb9c2818166f 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -98,7 +98,7 @@ async def async_step_user(self, user_input=None): errors[CONF_USERNAME] = "login" return await self._show_setup_form(user_input, errors) - if self.api.requires_2fa: + if self.api.requires_2sa: return await self.async_step_trusted_device() return self.async_create_entry( diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index 57a3f48936c2fd..3349615ed5752a 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -13,7 +13,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -ICLOUD_COMPONENTS = ["device_tracker", "sensor"] +PLATFORMS = ["device_tracker", "sensor"] # pyicloud.AppleDevice status DEVICE_BATTERY_LEVEL = "batteryLevel" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 79627eec4aaa78..00f35fbee857eb 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType -from . import IcloudDevice +from .account import IcloudDevice from .const import ( DEVICE_LOCATION_HORIZONTAL_ACCURACY, DEVICE_LOCATION_LATITUDE, diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 9652ef1046914c..a4a51f9e1a251e 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -3,7 +3,7 @@ "name": "Apple iCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/icloud", - "requirements": ["pyicloud==0.9.1"], + "requirements": ["pyicloud==0.9.2"], "dependencies": [], "codeowners": ["@Quentame"] } diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index f6c87ed12d0118..e24016795d36d2 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -9,7 +9,7 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import HomeAssistantType -from . import IcloudDevice +from .account import IcloudDevice from .const import DOMAIN, SERVICE_UPDATE _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index b246943b6addc8..9acf710a58e8a3 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -191,7 +191,10 @@ def validate_name(config): ) SET_RUNTIME_VALUE_INT_SCHEMA = vol.Schema( - {vol.Required(ATTR_IHC_ID): cv.positive_int, vol.Required(ATTR_VALUE): int} + { + vol.Required(ATTR_IHC_ID): cv.positive_int, + vol.Required(ATTR_VALUE): vol.Coerce(int), + } ) SET_RUNTIME_VALUE_FLOAT_SCHEMA = vol.Schema( diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 4c5ab49c83e6e0..559ed7c90600fd 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -2,7 +2,10 @@ "domain": "ihc", "name": "IHC Controller", "documentation": "https://www.home-assistant.io/integrations/ihc", - "requirements": ["defusedxml==0.6.0", "ihcsdk==2.4.0"], + "requirements": [ + "defusedxml==0.6.0", + "ihcsdk==2.6.0" + ], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/ihc/util.py b/homeassistant/components/ihc/util.py index 40434dadc8e30b..1b6b1dbd3e0fa5 100644 --- a/homeassistant/components/ihc/util.py +++ b/homeassistant/components/ihc/util.py @@ -2,6 +2,8 @@ import asyncio +from homeassistant.core import callback + async def async_pulse(hass, ihc_controller, ihc_id: int): """Send a short on/off pulse to an IHC controller resource.""" @@ -10,6 +12,7 @@ async def async_pulse(hass, ihc_controller, ihc_id: int): await async_set_bool(hass, ihc_controller, ihc_id, False) +@callback def async_set_bool(hass, ihc_controller, ihc_id: int, value: bool): """Set a bool value on an IHC controller resource.""" return hass.async_add_executor_job( @@ -17,6 +20,7 @@ def async_set_bool(hass, ihc_controller, ihc_id: int, value: bool): ) +@callback def async_set_int(hass, ihc_controller, ihc_id: int, value: int): """Set a int value on an IHC controller resource.""" return hass.async_add_executor_job( diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index a8f5f0f097e79c..84ba5b45fc4d17 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -107,12 +107,9 @@ def process_image(self, image): """Process image.""" raise NotImplementedError() - def async_process_image(self, image): - """Process image. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.process_image, image) + async def async_process_image(self, image): + """Process image.""" + return await self.hass.async_add_job(self.process_image, image) async def async_update(self): """Update image and process it. diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 03b468313f8978..371e0dea185a5f 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -326,6 +326,7 @@ def unique_id(self) -> typing.Optional[str]: """Return unique id of the entity.""" return self._config[CONF_ID] + @callback def async_set_datetime(self, date_val, time_val): """Set a new date / time.""" if self.has_date and self.has_time and date_val and time_val: diff --git a/homeassistant/components/input_datetime/services.yaml b/homeassistant/components/input_datetime/services.yaml index 472bd1b83b9888..4c5c998d0a527a 100644 --- a/homeassistant/components/input_datetime/services.yaml +++ b/homeassistant/components/input_datetime/services.yaml @@ -4,11 +4,11 @@ set_datetime: entity_id: {description: Entity id of the input datetime to set the new value., example: input_datetime.test_date_time} date: {description: The target date the entity should be set to. Do not use with datetime., - example: '"date": "2019-04-22"'} + example: '"2019-04-20"'} time: {description: The target time the entity should be set to. Do not use with datetime., - example: '"time": "05:30:00"'} + example: '"05:04:20"'} datetime: {description: The target date & time the entity should be set to. Do not use with date or time., - example: '"datetime": "2019-04-22 05:30:00"'} + example: '"2019-04-20 05:04:20"'} reload: description: Reload the input_datetime configuration. diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index f78fc485e4068e..eb781baf2ca728 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -288,44 +288,22 @@ async def async_added_to_hass(self): async def async_set_value(self, value): """Set new value.""" num_value = float(value) + if num_value < self._minimum or num_value > self._maximum: - _LOGGER.warning( - "Invalid value: %s (range %s - %s)", - num_value, - self._minimum, - self._maximum, + raise vol.Invalid( + f"Invalid value for {self.entity_id}: {value} (range {self._minimum} - {self._maximum})" ) - return + self._current_value = num_value self.async_write_ha_state() async def async_increment(self): """Increment value.""" - new_value = self._current_value + self._step - if new_value > self._maximum: - _LOGGER.warning( - "Invalid value: %s (range %s - %s)", - new_value, - self._minimum, - self._maximum, - ) - return - self._current_value = new_value - self.async_write_ha_state() + await self.async_set_value(min(self._current_value + self._step, self._maximum)) async def async_decrement(self): """Decrement value.""" - new_value = self._current_value - self._step - if new_value < self._minimum: - _LOGGER.warning( - "Invalid value: %s (range %s - %s)", - new_value, - self._minimum, - self._maximum, - ) - return - self._current_value = new_value - self.async_write_ha_state() + await self.async_set_value(max(self._current_value - self._step, self._minimum)) async def async_update_config(self, config: typing.Dict) -> None: """Handle when the config is updated.""" diff --git a/homeassistant/components/input_number/reproduce_state.py b/homeassistant/components/input_number/reproduce_state.py index 22a91f7400077f..a81c7041607140 100644 --- a/homeassistant/components/input_number/reproduce_state.py +++ b/homeassistant/components/input_number/reproduce_state.py @@ -3,6 +3,8 @@ import logging from typing import Iterable, Optional +import voluptuous as vol + from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, State from homeassistant.helpers.typing import HomeAssistantType @@ -37,9 +39,13 @@ async def _async_reproduce_state( service = SERVICE_SET_VALUE service_data = {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: state.state} - await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True - ) + try: + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + except vol.Invalid as err: + # If value out of range. + _LOGGER.warning("Unable to reproduce state for %s: %s", state.entity_id, err) async def async_reproduce_states( diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 26a07e600f318d..6044375d8a8f29 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -143,11 +143,15 @@ async def reload_service_handler(service_call: ServiceCallType) -> None: ) component.async_register_entity_service( - SERVICE_SELECT_NEXT, {}, lambda entity, call: entity.async_offset_index(1) + SERVICE_SELECT_NEXT, + {}, + callback(lambda entity, call: entity.async_offset_index(1)), ) component.async_register_entity_service( - SERVICE_SELECT_PREVIOUS, {}, lambda entity, call: entity.async_offset_index(-1) + SERVICE_SELECT_PREVIOUS, + {}, + callback(lambda entity, call: entity.async_offset_index(-1)), ) component.async_register_entity_service( @@ -248,7 +252,8 @@ def unique_id(self) -> typing.Optional[str]: """Return unique id for the entity.""" return self._config[CONF_ID] - async def async_select_option(self, option): + @callback + def async_select_option(self, option): """Select new option.""" if option not in self._options: _LOGGER.warning( @@ -260,14 +265,16 @@ async def async_select_option(self, option): self._current_option = option self.async_write_ha_state() - async def async_offset_index(self, offset): + @callback + def async_offset_index(self, offset): """Offset current index.""" current_index = self._options.index(self._current_option) new_index = (current_index + offset) % len(self._options) self._current_option = self._options[new_index] self.async_write_ha_state() - async def async_set_options(self, options): + @callback + def async_set_options(self, options): """Set options.""" self._current_option = options[0] self._config[CONF_OPTIONS] = options diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index df6fa626a4fa79..ce17cc6c77df7a 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -1,278 +1,43 @@ """Support for INSTEON Modems (PLM and Hub).""" -import collections import logging -from typing import Dict import insteonplm -from insteonplm.devices import ALDBStatus -from insteonplm.states.cover import Cover -from insteonplm.states.dimmable import ( - DimmableKeypadA, - DimmableRemote, - DimmableSwitch, - DimmableSwitch_Fan, -) -from insteonplm.states.onOff import ( - OnOffKeypad, - OnOffKeypadA, - OnOffSwitch, - OnOffSwitch_OutletBottom, - OnOffSwitch_OutletTop, - OpenClosedRelay, -) -from insteonplm.states.sensor import ( - IoLincSensor, - LeakSensorDryWet, - OnOffSensor, - SmokeCO2Sensor, - VariableSensor, -) -from insteonplm.states.x10 import ( - X10AllLightsOffSensor, - X10AllLightsOnSensor, - X10AllUnitsOffSensor, - X10DimmableSwitch, - X10OnOffSensor, - X10OnOffSwitch, -) -import voluptuous as vol from homeassistant.const import ( - CONF_ADDRESS, - CONF_ENTITY_ID, CONF_HOST, CONF_PLATFORM, CONF_PORT, - ENTITY_MATCH_ALL, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "insteon" -INSTEON_ENTITIES = "entities" - -CONF_IP_PORT = "ip_port" -CONF_HUB_USERNAME = "username" -CONF_HUB_PASSWORD = "password" -CONF_HUB_VERSION = "hub_version" -CONF_OVERRIDE = "device_override" -CONF_PLM_HUB_MSG = "Must configure either a PLM port or a Hub host" -CONF_CAT = "cat" -CONF_SUBCAT = "subcat" -CONF_FIRMWARE = "firmware" -CONF_PRODUCT_KEY = "product_key" -CONF_X10 = "x10_devices" -CONF_HOUSECODE = "housecode" -CONF_UNITCODE = "unitcode" -CONF_DIM_STEPS = "dim_steps" -CONF_X10_ALL_UNITS_OFF = "x10_all_units_off" -CONF_X10_ALL_LIGHTS_ON = "x10_all_lights_on" -CONF_X10_ALL_LIGHTS_OFF = "x10_all_lights_off" - -SRV_ADD_ALL_LINK = "add_all_link" -SRV_DEL_ALL_LINK = "delete_all_link" -SRV_LOAD_ALDB = "load_all_link_database" -SRV_PRINT_ALDB = "print_all_link_database" -SRV_PRINT_IM_ALDB = "print_im_all_link_database" -SRV_X10_ALL_UNITS_OFF = "x10_all_units_off" -SRV_X10_ALL_LIGHTS_OFF = "x10_all_lights_off" -SRV_X10_ALL_LIGHTS_ON = "x10_all_lights_on" -SRV_ALL_LINK_GROUP = "group" -SRV_ALL_LINK_MODE = "mode" -SRV_LOAD_DB_RELOAD = "reload" -SRV_CONTROLLER = "controller" -SRV_RESPONDER = "responder" -SRV_HOUSECODE = "housecode" -SRV_SCENE_ON = "scene_on" -SRV_SCENE_OFF = "scene_off" - -SIGNAL_LOAD_ALDB = "load_aldb" -SIGNAL_PRINT_ALDB = "print_aldb" - -HOUSECODES = [ - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", -] - -BUTTON_PRESSED_STATE_NAME = "onLevelButton" -EVENT_BUTTON_ON = "insteon.button_on" -EVENT_BUTTON_OFF = "insteon.button_off" -EVENT_CONF_BUTTON = "button" - - -def set_default_port(schema: Dict) -> Dict: - """Set the default port based on the Hub version.""" - # If the ip_port is found do nothing - # If it is not found the set the default - ip_port = schema.get(CONF_IP_PORT) - if not ip_port: - hub_version = schema.get(CONF_HUB_VERSION) - # Found hub_version but not ip_port - if hub_version == 1: - schema[CONF_IP_PORT] = 9761 - else: - schema[CONF_IP_PORT] = 25105 - return schema - -CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( - cv.deprecated(CONF_PLATFORM), - vol.Schema( - { - vol.Required(CONF_ADDRESS): cv.string, - vol.Optional(CONF_CAT): cv.byte, - vol.Optional(CONF_SUBCAT): cv.byte, - vol.Optional(CONF_FIRMWARE): cv.byte, - vol.Optional(CONF_PRODUCT_KEY): cv.byte, - vol.Optional(CONF_PLATFORM): cv.string, - } - ), +from .const import ( + CONF_CAT, + CONF_DIM_STEPS, + CONF_FIRMWARE, + CONF_HOUSECODE, + CONF_HUB_PASSWORD, + CONF_HUB_USERNAME, + CONF_HUB_VERSION, + CONF_IP_PORT, + CONF_OVERRIDE, + CONF_PRODUCT_KEY, + CONF_SUBCAT, + CONF_UNITCODE, + CONF_X10, + CONF_X10_ALL_LIGHTS_OFF, + CONF_X10_ALL_LIGHTS_ON, + CONF_X10_ALL_UNITS_OFF, + DOMAIN, + INSTEON_ENTITIES, ) +from .schemas import CONFIG_SCHEMA # noqa F440 +from .utils import async_register_services, register_new_device_callback - -CONF_X10_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOUSECODE): cv.string, - vol.Required(CONF_UNITCODE): vol.Range(min=1, max=16), - vol.Required(CONF_PLATFORM): cv.string, - vol.Optional(CONF_DIM_STEPS): vol.Range(min=2, max=255), - } - ) -) - - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - vol.Schema( - { - vol.Exclusive( - CONF_PORT, "plm_or_hub", msg=CONF_PLM_HUB_MSG - ): cv.string, - vol.Exclusive( - CONF_HOST, "plm_or_hub", msg=CONF_PLM_HUB_MSG - ): cv.string, - vol.Optional(CONF_IP_PORT): cv.port, - vol.Optional(CONF_HUB_USERNAME): cv.string, - vol.Optional(CONF_HUB_PASSWORD): cv.string, - vol.Optional(CONF_HUB_VERSION, default=2): vol.In([1, 2]), - vol.Optional(CONF_OVERRIDE): vol.All( - cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA] - ), - vol.Optional(CONF_X10_ALL_UNITS_OFF): vol.In(HOUSECODES), - vol.Optional(CONF_X10_ALL_LIGHTS_ON): vol.In(HOUSECODES), - vol.Optional(CONF_X10_ALL_LIGHTS_OFF): vol.In(HOUSECODES), - vol.Optional(CONF_X10): vol.All( - cv.ensure_list_csv, [CONF_X10_SCHEMA] - ), - }, - extra=vol.ALLOW_EXTRA, - required=True, - ), - cv.has_at_least_one_key(CONF_PORT, CONF_HOST), - set_default_port, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -ADD_ALL_LINK_SCHEMA = vol.Schema( - { - vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255), - vol.Required(SRV_ALL_LINK_MODE): vol.In([SRV_CONTROLLER, SRV_RESPONDER]), - } -) - - -DEL_ALL_LINK_SCHEMA = vol.Schema( - {vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255)} -) - - -LOAD_ALDB_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY_ID): vol.Any(cv.entity_id, ENTITY_MATCH_ALL), - vol.Optional(SRV_LOAD_DB_RELOAD, default=False): cv.boolean, - } -) - - -PRINT_ALDB_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) - - -X10_HOUSECODE_SCHEMA = vol.Schema({vol.Required(SRV_HOUSECODE): vol.In(HOUSECODES)}) - - -TRIGGER_SCENE_SCHEMA = vol.Schema( - {vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255)} -) - - -STATE_NAME_LABEL_MAP = { - "keypadButtonA": "Button A", - "keypadButtonB": "Button B", - "keypadButtonC": "Button C", - "keypadButtonD": "Button D", - "keypadButtonE": "Button E", - "keypadButtonF": "Button F", - "keypadButtonG": "Button G", - "keypadButtonH": "Button H", - "keypadButtonMain": "Main", - "onOffButtonA": "Button A", - "onOffButtonB": "Button B", - "onOffButtonC": "Button C", - "onOffButtonD": "Button D", - "onOffButtonE": "Button E", - "onOffButtonF": "Button F", - "onOffButtonG": "Button G", - "onOffButtonH": "Button H", - "onOffButtonMain": "Main", - "fanOnLevel": "Fan", - "lightOnLevel": "Light", - "coolSetPoint": "Cool Set", - "heatSetPoint": "HeatSet", - "statusReport": "Status", - "generalSensor": "Sensor", - "motionSensor": "Motion", - "lightSensor": "Light", - "batterySensor": "Battery", - "dryLeakSensor": "Dry", - "wetLeakSensor": "Wet", - "heartbeatLeakSensor": "Heartbeat", - "openClosedRelay": "Relay", - "openClosedSensor": "Sensor", - "lightOnOff": "Light", - "outletTopOnOff": "Top", - "outletBottomOnOff": "Bottom", - "coverOpenLevel": "Cover", -} +_LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up the connection to the modem.""" - ipdb = IPDB() insteon_modem = None conf = config[DOMAIN] @@ -288,163 +53,6 @@ async def async_setup(hass, config): x10_all_lights_on_housecode = conf.get(CONF_X10_ALL_LIGHTS_ON) x10_all_lights_off_housecode = conf.get(CONF_X10_ALL_LIGHTS_OFF) - @callback - def async_new_insteon_device(device): - """Detect device from transport to be delegated to platform.""" - for state_key in device.states: - platform_info = ipdb[device.states[state_key]] - if platform_info and platform_info.platform: - platform = platform_info.platform - - if platform == "on_off_events": - device.states[state_key].register_updates(_fire_button_on_off_event) - - else: - _LOGGER.info( - "New INSTEON device: %s (%s) %s", - device.address, - device.states[state_key].name, - platform, - ) - - hass.async_create_task( - discovery.async_load_platform( - hass, - platform, - DOMAIN, - discovered={ - "address": device.address.id, - "state_key": state_key, - }, - hass_config=config, - ) - ) - - def add_all_link(service): - """Add an INSTEON All-Link between two devices.""" - group = service.data.get(SRV_ALL_LINK_GROUP) - mode = service.data.get(SRV_ALL_LINK_MODE) - link_mode = 1 if mode.lower() == SRV_CONTROLLER else 0 - insteon_modem.start_all_linking(link_mode, group) - - def del_all_link(service): - """Delete an INSTEON All-Link between two devices.""" - group = service.data.get(SRV_ALL_LINK_GROUP) - insteon_modem.start_all_linking(255, group) - - def load_aldb(service): - """Load the device All-Link database.""" - entity_id = service.data[CONF_ENTITY_ID] - reload = service.data[SRV_LOAD_DB_RELOAD] - if entity_id.lower() == ENTITY_MATCH_ALL: - for entity_id in hass.data[DOMAIN].get(INSTEON_ENTITIES): - _send_load_aldb_signal(entity_id, reload) - else: - _send_load_aldb_signal(entity_id, reload) - - def _send_load_aldb_signal(entity_id, reload): - """Send the load All-Link database signal to INSTEON entity.""" - signal = f"{entity_id}_{SIGNAL_LOAD_ALDB}" - dispatcher_send(hass, signal, reload) - - def print_aldb(service): - """Print the All-Link Database for a device.""" - # For now this sends logs to the log file. - # Furture direction is to create an INSTEON control panel. - entity_id = service.data[CONF_ENTITY_ID] - signal = f"{entity_id}_{SIGNAL_PRINT_ALDB}" - dispatcher_send(hass, signal) - - def print_im_aldb(service): - """Print the All-Link Database for a device.""" - # For now this sends logs to the log file. - # Furture direction is to create an INSTEON control panel. - print_aldb_to_log(insteon_modem.aldb) - - def x10_all_units_off(service): - """Send the X10 All Units Off command.""" - housecode = service.data.get(SRV_HOUSECODE) - insteon_modem.x10_all_units_off(housecode) - - def x10_all_lights_off(service): - """Send the X10 All Lights Off command.""" - housecode = service.data.get(SRV_HOUSECODE) - insteon_modem.x10_all_lights_off(housecode) - - def x10_all_lights_on(service): - """Send the X10 All Lights On command.""" - housecode = service.data.get(SRV_HOUSECODE) - insteon_modem.x10_all_lights_on(housecode) - - def scene_on(service): - """Trigger an INSTEON scene ON.""" - group = service.data.get(SRV_ALL_LINK_GROUP) - insteon_modem.trigger_group_on(group) - - def scene_off(service): - """Trigger an INSTEON scene ON.""" - group = service.data.get(SRV_ALL_LINK_GROUP) - insteon_modem.trigger_group_off(group) - - def _register_services(): - hass.services.register( - DOMAIN, SRV_ADD_ALL_LINK, add_all_link, schema=ADD_ALL_LINK_SCHEMA - ) - hass.services.register( - DOMAIN, SRV_DEL_ALL_LINK, del_all_link, schema=DEL_ALL_LINK_SCHEMA - ) - hass.services.register( - DOMAIN, SRV_LOAD_ALDB, load_aldb, schema=LOAD_ALDB_SCHEMA - ) - hass.services.register( - DOMAIN, SRV_PRINT_ALDB, print_aldb, schema=PRINT_ALDB_SCHEMA - ) - hass.services.register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None) - hass.services.register( - DOMAIN, - SRV_X10_ALL_UNITS_OFF, - x10_all_units_off, - schema=X10_HOUSECODE_SCHEMA, - ) - hass.services.register( - DOMAIN, - SRV_X10_ALL_LIGHTS_OFF, - x10_all_lights_off, - schema=X10_HOUSECODE_SCHEMA, - ) - hass.services.register( - DOMAIN, - SRV_X10_ALL_LIGHTS_ON, - x10_all_lights_on, - schema=X10_HOUSECODE_SCHEMA, - ) - hass.services.register( - DOMAIN, SRV_SCENE_ON, scene_on, schema=TRIGGER_SCENE_SCHEMA - ) - hass.services.register( - DOMAIN, SRV_SCENE_OFF, scene_off, schema=TRIGGER_SCENE_SCHEMA - ) - _LOGGER.debug("Insteon Services registered") - - def _fire_button_on_off_event(address, group, val): - # Firing an event when a button is pressed. - device = insteon_modem.devices[address.hex] - state_name = device.states[group].name - button = ( - "" if state_name == BUTTON_PRESSED_STATE_NAME else state_name[-1].lower() - ) - schema = {CONF_ADDRESS: address.hex} - if button != "": - schema[EVENT_CONF_BUTTON] = button - if val: - event = EVENT_BUTTON_ON - else: - event = EVENT_BUTTON_OFF - _LOGGER.debug( - "Firing event %s with address %s and button %s", event, address.hex, button - ) - hass.bus.fire(event, schema) - if host: _LOGGER.info("Connecting to Insteon Hub on %s", host) conn = await insteonplm.Connection.create( @@ -464,6 +72,14 @@ def _fire_button_on_off_event(address, group, val): insteon_modem = conn.protocol + hass.data[DOMAIN] = {} + hass.data[DOMAIN]["modem"] = insteon_modem + hass.data[DOMAIN][INSTEON_ENTITIES] = set() + + register_new_device_callback(hass, config, insteon_modem) + async_register_services(hass, config, insteon_modem) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close) + for device_override in overrides: # # Override the device default capabilities for a specific address @@ -477,14 +93,6 @@ def _fire_button_on_off_event(address, group, val): address, CONF_PRODUCT_KEY, device_override[prop] ) - hass.data[DOMAIN] = {} - hass.data[DOMAIN]["modem"] = insteon_modem - hass.data[DOMAIN][INSTEON_ENTITIES] = {} - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close) - - insteon_modem.devices.add_device_callback(async_new_insteon_device) - if x10_all_units_off_housecode: device = insteon_modem.add_x10_device( x10_all_units_off_housecode, 20, "allunitsoff" @@ -513,199 +121,4 @@ def _fire_button_on_off_event(address, group, val): if device and hasattr(device.states[0x01], "steps"): device.states[0x01].steps = steps - hass.async_add_job(_register_services) - return True - - -State = collections.namedtuple("Product", "stateType platform") - - -class IPDB: - """Embodies the INSTEON Product Database static data and access methods.""" - - def __init__(self): - """Create the INSTEON Product Database (IPDB).""" - self.states = [ - State(Cover, "cover"), - State(OnOffSwitch_OutletTop, "switch"), - State(OnOffSwitch_OutletBottom, "switch"), - State(OpenClosedRelay, "switch"), - State(OnOffSwitch, "switch"), - State(OnOffKeypadA, "switch"), - State(OnOffKeypad, "switch"), - State(LeakSensorDryWet, "binary_sensor"), - State(IoLincSensor, "binary_sensor"), - State(SmokeCO2Sensor, "sensor"), - State(OnOffSensor, "binary_sensor"), - State(VariableSensor, "sensor"), - State(DimmableSwitch_Fan, "fan"), - State(DimmableSwitch, "light"), - State(DimmableRemote, "on_off_events"), - State(DimmableKeypadA, "light"), - State(X10DimmableSwitch, "light"), - State(X10OnOffSwitch, "switch"), - State(X10OnOffSensor, "binary_sensor"), - State(X10AllUnitsOffSensor, "binary_sensor"), - State(X10AllLightsOnSensor, "binary_sensor"), - State(X10AllLightsOffSensor, "binary_sensor"), - ] - - def __len__(self): - """Return the number of INSTEON state types mapped to HA platforms.""" - return len(self.states) - - def __iter__(self): - """Itterate through the INSTEON state types to HA platforms.""" - for product in self.states: - yield product - - def __getitem__(self, key): - """Return a Home Assistant platform from an INSTEON state type.""" - for state in self.states: - if isinstance(key, state.stateType): - return state - return None - - -class InsteonEntity(Entity): - """INSTEON abstract base entity.""" - - def __init__(self, device, state_key): - """Initialize the INSTEON binary sensor.""" - self._insteon_device_state = device.states[state_key] - self._insteon_device = device - self._insteon_device.aldb.add_loaded_callback(self._aldb_loaded) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._insteon_device.address.human - - @property - def group(self): - """Return the INSTEON group that the entity responds to.""" - return self._insteon_device_state.group - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - if self._insteon_device_state.group == 0x01: - uid = self._insteon_device.id - else: - uid = "{:s}_{:d}".format( - self._insteon_device.id, self._insteon_device_state.group - ) - return uid - - @property - def name(self): - """Return the name of the node (used for Entity_ID).""" - # Set a base description - description = self._insteon_device.description - if self._insteon_device.description is None: - description = "Unknown Device" - - # Get an extension label if there is one - extension = self._get_label() - if extension: - extension = f" {extension}" - name = "{:s} {:s}{:s}".format( - description, self._insteon_device.address.human, extension - ) - return name - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - attributes = {"INSTEON Address": self.address, "INSTEON Group": self.group} - return attributes - - @callback - def async_entity_update(self, deviceid, group, val): - """Receive notification from transport that new data exists.""" - _LOGGER.debug( - "Received update for device %s group %d value %s", - deviceid.human, - group, - val, - ) - self.async_schedule_update_ha_state() - - async def async_added_to_hass(self): - """Register INSTEON update events.""" - _LOGGER.debug( - "Tracking updates for device %s group %d statename %s", - self.address, - self.group, - self._insteon_device_state.name, - ) - self._insteon_device_state.register_updates(self.async_entity_update) - self.hass.data[DOMAIN][INSTEON_ENTITIES][self.entity_id] = self - load_signal = f"{self.entity_id}_{SIGNAL_LOAD_ALDB}" - async_dispatcher_connect(self.hass, load_signal, self._load_aldb) - print_signal = f"{self.entity_id}_{SIGNAL_PRINT_ALDB}" - async_dispatcher_connect(self.hass, print_signal, self._print_aldb) - - def _load_aldb(self, reload=False): - """Load the device All-Link Database.""" - if reload: - self._insteon_device.aldb.clear() - self._insteon_device.read_aldb() - - def _print_aldb(self): - """Print the device ALDB to the log file.""" - print_aldb_to_log(self._insteon_device.aldb) - - @callback - def _aldb_loaded(self): - """All-Link Database loaded for the device.""" - self._print_aldb() - - def _get_label(self): - """Get the device label for grouped devices.""" - label = "" - if len(self._insteon_device.states) > 1: - if self._insteon_device_state.name in STATE_NAME_LABEL_MAP: - label = STATE_NAME_LABEL_MAP[self._insteon_device_state.name] - else: - label = f"Group {self.group:d}" - return label - - -def print_aldb_to_log(aldb): - """Print the All-Link Database to the log file.""" - _LOGGER.info("ALDB load status is %s", aldb.status.name) - if aldb.status not in [ALDBStatus.LOADED, ALDBStatus.PARTIAL]: - _LOGGER.warning("Device All-Link database not loaded") - _LOGGER.warning("Use service insteon.load_aldb first") - return - - _LOGGER.info("RecID In Use Mode HWM Group Address Data 1 Data 2 Data 3") - _LOGGER.info("----- ------ ---- --- ----- -------- ------ ------ ------") - for mem_addr in aldb: - rec = aldb[mem_addr] - # For now we write this to the log - # Roadmap is to create a configuration panel - in_use = "Y" if rec.control_flags.is_in_use else "N" - mode = "C" if rec.control_flags.is_controller else "R" - hwm = "Y" if rec.control_flags.is_high_water_mark else "N" - _LOGGER.info( - " {:04x} {:s} {:s} {:s} {:3d} {:s}" - " {:3d} {:3d} {:3d}".format( - rec.mem_addr, - in_use, - mode, - hwm, - rec.group, - rec.address.human, - rec.data1, - rec.data2, - rec.data3, - ) - ) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 68ea07cdb49b2a..395c0a3ac20f41 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorDevice -from . import InsteonEntity +from .insteon_entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py new file mode 100644 index 00000000000000..b01409f49ff506 --- /dev/null +++ b/homeassistant/components/insteon/const.py @@ -0,0 +1,106 @@ +"""Constants used by insteon component.""" + +DOMAIN = "insteon" +INSTEON_ENTITIES = "entities" + +CONF_IP_PORT = "ip_port" +CONF_HUB_USERNAME = "username" +CONF_HUB_PASSWORD = "password" +CONF_HUB_VERSION = "hub_version" +CONF_OVERRIDE = "device_override" +CONF_PLM_HUB_MSG = "Must configure either a PLM port or a Hub host" +CONF_CAT = "cat" +CONF_SUBCAT = "subcat" +CONF_FIRMWARE = "firmware" +CONF_PRODUCT_KEY = "product_key" +CONF_X10 = "x10_devices" +CONF_HOUSECODE = "housecode" +CONF_UNITCODE = "unitcode" +CONF_DIM_STEPS = "dim_steps" +CONF_X10_ALL_UNITS_OFF = "x10_all_units_off" +CONF_X10_ALL_LIGHTS_ON = "x10_all_lights_on" +CONF_X10_ALL_LIGHTS_OFF = "x10_all_lights_off" + +SRV_ADD_ALL_LINK = "add_all_link" +SRV_DEL_ALL_LINK = "delete_all_link" +SRV_LOAD_ALDB = "load_all_link_database" +SRV_PRINT_ALDB = "print_all_link_database" +SRV_PRINT_IM_ALDB = "print_im_all_link_database" +SRV_X10_ALL_UNITS_OFF = "x10_all_units_off" +SRV_X10_ALL_LIGHTS_OFF = "x10_all_lights_off" +SRV_X10_ALL_LIGHTS_ON = "x10_all_lights_on" +SRV_ALL_LINK_GROUP = "group" +SRV_ALL_LINK_MODE = "mode" +SRV_LOAD_DB_RELOAD = "reload" +SRV_CONTROLLER = "controller" +SRV_RESPONDER = "responder" +SRV_HOUSECODE = "housecode" +SRV_SCENE_ON = "scene_on" +SRV_SCENE_OFF = "scene_off" + +SIGNAL_LOAD_ALDB = "load_aldb" +SIGNAL_PRINT_ALDB = "print_aldb" + +HOUSECODES = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", +] + +BUTTON_PRESSED_STATE_NAME = "onLevelButton" +EVENT_BUTTON_ON = "insteon.button_on" +EVENT_BUTTON_OFF = "insteon.button_off" +EVENT_CONF_BUTTON = "button" + + +STATE_NAME_LABEL_MAP = { + "keypadButtonA": "Button A", + "keypadButtonB": "Button B", + "keypadButtonC": "Button C", + "keypadButtonD": "Button D", + "keypadButtonE": "Button E", + "keypadButtonF": "Button F", + "keypadButtonG": "Button G", + "keypadButtonH": "Button H", + "keypadButtonMain": "Main", + "onOffButtonA": "Button A", + "onOffButtonB": "Button B", + "onOffButtonC": "Button C", + "onOffButtonD": "Button D", + "onOffButtonE": "Button E", + "onOffButtonF": "Button F", + "onOffButtonG": "Button G", + "onOffButtonH": "Button H", + "onOffButtonMain": "Main", + "fanOnLevel": "Fan", + "lightOnLevel": "Light", + "coolSetPoint": "Cool Set", + "heatSetPoint": "HeatSet", + "statusReport": "Status", + "generalSensor": "Sensor", + "motionSensor": "Motion", + "lightSensor": "Light", + "batterySensor": "Battery", + "dryLeakSensor": "Dry", + "wetLeakSensor": "Wet", + "heartbeatLeakSensor": "Heartbeat", + "openClosedRelay": "Relay", + "openClosedSensor": "Sensor", + "lightOnOff": "Light", + "outletTopOnOff": "Top", + "outletBottomOnOff": "Bottom", + "coverOpenLevel": "Cover", +} diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index f9399d7b13ff7a..575799cbf6725c 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -10,7 +10,7 @@ CoverDevice, ) -from . import InsteonEntity +from .insteon_entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index d88348b1a5ddee..6ad7436faf54c5 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -11,7 +11,7 @@ ) from homeassistant.const import STATE_OFF -from . import InsteonEntity +from .insteon_entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py new file mode 100644 index 00000000000000..c489dd8e382575 --- /dev/null +++ b/homeassistant/components/insteon/insteon_entity.py @@ -0,0 +1,123 @@ +"""Insteon base entity.""" +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import ( + DOMAIN, + INSTEON_ENTITIES, + SIGNAL_LOAD_ALDB, + SIGNAL_PRINT_ALDB, + STATE_NAME_LABEL_MAP, +) +from .utils import print_aldb_to_log + +_LOGGER = logging.getLogger(__name__) + + +class InsteonEntity(Entity): + """INSTEON abstract base entity.""" + + def __init__(self, device, state_key): + """Initialize the INSTEON binary sensor.""" + self._insteon_device_state = device.states[state_key] + self._insteon_device = device + self._insteon_device.aldb.add_loaded_callback(self._aldb_loaded) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def address(self): + """Return the address of the node.""" + return self._insteon_device.address.human + + @property + def group(self): + """Return the INSTEON group that the entity responds to.""" + return self._insteon_device_state.group + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + if self._insteon_device_state.group == 0x01: + uid = self._insteon_device.id + else: + uid = f"{self._insteon_device.id}_{self._insteon_device_state.group}" + return uid + + @property + def name(self): + """Return the name of the node (used for Entity_ID).""" + # Set a base description + description = self._insteon_device.description + if self._insteon_device.description is None: + description = "Unknown Device" + + # Get an extension label if there is one + extension = self._get_label() + if extension: + extension = f" {extension}" + name = f"{description} {self._insteon_device.address.human}{extension}" + return name + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + attributes = {"insteon_address": self.address, "insteon_group": self.group} + return attributes + + @callback + def async_entity_update(self, deviceid, group, val): + """Receive notification from transport that new data exists.""" + _LOGGER.debug( + "Received update for device %s group %d value %s", + deviceid.human, + group, + val, + ) + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register INSTEON update events.""" + _LOGGER.debug( + "Tracking updates for device %s group %d statename %s", + self.address, + self.group, + self._insteon_device_state.name, + ) + self._insteon_device_state.register_updates(self.async_entity_update) + self.hass.data[DOMAIN][INSTEON_ENTITIES].add(self.entity_id) + load_signal = f"{self.entity_id}_{SIGNAL_LOAD_ALDB}" + async_dispatcher_connect(self.hass, load_signal, self._load_aldb) + print_signal = f"{self.entity_id}_{SIGNAL_PRINT_ALDB}" + async_dispatcher_connect(self.hass, print_signal, self._print_aldb) + + def _load_aldb(self, reload=False): + """Load the device All-Link Database.""" + if reload: + self._insteon_device.aldb.clear() + self._insteon_device.read_aldb() + + def _print_aldb(self): + """Print the device ALDB to the log file.""" + print_aldb_to_log(self._insteon_device.aldb) + + @callback + def _aldb_loaded(self): + """All-Link Database loaded for the device.""" + self._print_aldb() + + def _get_label(self): + """Get the device label for grouped devices.""" + label = "" + if len(self._insteon_device.states) > 1: + if self._insteon_device_state.name in STATE_NAME_LABEL_MAP: + label = STATE_NAME_LABEL_MAP[self._insteon_device_state.name] + else: + label = f"Group {self.group:d}" + return label diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py new file mode 100644 index 00000000000000..1618518a0eb427 --- /dev/null +++ b/homeassistant/components/insteon/ipdb.py @@ -0,0 +1,82 @@ +"""Insteon product database.""" +import collections + +from insteonplm.states.cover import Cover +from insteonplm.states.dimmable import ( + DimmableKeypadA, + DimmableRemote, + DimmableSwitch, + DimmableSwitch_Fan, +) +from insteonplm.states.onOff import ( + OnOffKeypad, + OnOffKeypadA, + OnOffSwitch, + OnOffSwitch_OutletBottom, + OnOffSwitch_OutletTop, + OpenClosedRelay, +) +from insteonplm.states.sensor import ( + IoLincSensor, + LeakSensorDryWet, + OnOffSensor, + SmokeCO2Sensor, + VariableSensor, +) +from insteonplm.states.x10 import ( + X10AllLightsOffSensor, + X10AllLightsOnSensor, + X10AllUnitsOffSensor, + X10DimmableSwitch, + X10OnOffSensor, + X10OnOffSwitch, +) + +State = collections.namedtuple("Product", "stateType platform") + + +class IPDB: + """Embodies the INSTEON Product Database static data and access methods.""" + + def __init__(self): + """Create the INSTEON Product Database (IPDB).""" + self.states = [ + State(Cover, "cover"), + State(OnOffSwitch_OutletTop, "switch"), + State(OnOffSwitch_OutletBottom, "switch"), + State(OpenClosedRelay, "switch"), + State(OnOffSwitch, "switch"), + State(OnOffKeypadA, "switch"), + State(OnOffKeypad, "switch"), + State(LeakSensorDryWet, "binary_sensor"), + State(IoLincSensor, "binary_sensor"), + State(SmokeCO2Sensor, "sensor"), + State(OnOffSensor, "binary_sensor"), + State(VariableSensor, "sensor"), + State(DimmableSwitch_Fan, "fan"), + State(DimmableSwitch, "light"), + State(DimmableRemote, "on_off_events"), + State(DimmableKeypadA, "light"), + State(X10DimmableSwitch, "light"), + State(X10OnOffSwitch, "switch"), + State(X10OnOffSensor, "binary_sensor"), + State(X10AllUnitsOffSensor, "binary_sensor"), + State(X10AllLightsOnSensor, "binary_sensor"), + State(X10AllLightsOffSensor, "binary_sensor"), + ] + + def __len__(self): + """Return the number of INSTEON state types mapped to HA platforms.""" + return len(self.states) + + def __iter__(self): + """Itterate through the INSTEON state types to HA platforms.""" + for product in self.states: + yield product + + def __getitem__(self, key): + """Return a Home Assistant platform from an INSTEON state type.""" + for state in self.states: + if isinstance(key, state.stateType): + return state + return None diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 3a44d89add02c8..60a27b3acb8c26 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -3,7 +3,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light -from . import InsteonEntity +from .insteon_entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index de15fbee66eee6..69c35477b8dae2 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -2,7 +2,7 @@ "domain": "insteon", "name": "Insteon", "documentation": "https://www.home-assistant.io/integrations/insteon", - "requirements": ["insteonplm==0.16.5"], + "requirements": ["insteonplm==0.16.7"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py new file mode 100644 index 00000000000000..20399195365b0a --- /dev/null +++ b/homeassistant/components/insteon/schemas.py @@ -0,0 +1,156 @@ +"""Schemas used by insteon component.""" + +from typing import Dict + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ADDRESS, + CONF_ENTITY_ID, + CONF_HOST, + CONF_PLATFORM, + CONF_PORT, + ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, +) +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_CAT, + CONF_DIM_STEPS, + CONF_FIRMWARE, + CONF_HOUSECODE, + CONF_HUB_PASSWORD, + CONF_HUB_USERNAME, + CONF_HUB_VERSION, + CONF_IP_PORT, + CONF_OVERRIDE, + CONF_PLM_HUB_MSG, + CONF_PRODUCT_KEY, + CONF_SUBCAT, + CONF_UNITCODE, + CONF_X10, + CONF_X10_ALL_LIGHTS_OFF, + CONF_X10_ALL_LIGHTS_ON, + CONF_X10_ALL_UNITS_OFF, + DOMAIN, + HOUSECODES, + SRV_ALL_LINK_GROUP, + SRV_ALL_LINK_MODE, + SRV_CONTROLLER, + SRV_HOUSECODE, + SRV_LOAD_DB_RELOAD, + SRV_RESPONDER, +) + + +def set_default_port(schema: Dict) -> Dict: + """Set the default port based on the Hub version.""" + # If the ip_port is found do nothing + # If it is not found the set the default + ip_port = schema.get(CONF_IP_PORT) + if not ip_port: + hub_version = schema.get(CONF_HUB_VERSION) + # Found hub_version but not ip_port + if hub_version == 1: + schema[CONF_IP_PORT] = 9761 + else: + schema[CONF_IP_PORT] = 25105 + return schema + + +CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( + cv.deprecated(CONF_PLATFORM), + vol.Schema( + { + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_CAT): cv.byte, + vol.Optional(CONF_SUBCAT): cv.byte, + vol.Optional(CONF_FIRMWARE): cv.byte, + vol.Optional(CONF_PRODUCT_KEY): cv.byte, + vol.Optional(CONF_PLATFORM): cv.string, + } + ), +) + + +CONF_X10_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_HOUSECODE): cv.string, + vol.Required(CONF_UNITCODE): vol.Range(min=1, max=16), + vol.Required(CONF_PLATFORM): cv.string, + vol.Optional(CONF_DIM_STEPS): vol.Range(min=2, max=255), + } + ) +) + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + vol.Schema( + { + vol.Exclusive( + CONF_PORT, "plm_or_hub", msg=CONF_PLM_HUB_MSG + ): cv.string, + vol.Exclusive( + CONF_HOST, "plm_or_hub", msg=CONF_PLM_HUB_MSG + ): cv.string, + vol.Optional(CONF_IP_PORT): cv.port, + vol.Optional(CONF_HUB_USERNAME): cv.string, + vol.Optional(CONF_HUB_PASSWORD): cv.string, + vol.Optional(CONF_HUB_VERSION, default=2): vol.In([1, 2]), + vol.Optional(CONF_OVERRIDE): vol.All( + cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA] + ), + vol.Optional(CONF_X10_ALL_UNITS_OFF): vol.In(HOUSECODES), + vol.Optional(CONF_X10_ALL_LIGHTS_ON): vol.In(HOUSECODES), + vol.Optional(CONF_X10_ALL_LIGHTS_OFF): vol.In(HOUSECODES), + vol.Optional(CONF_X10): vol.All( + cv.ensure_list_csv, [CONF_X10_SCHEMA] + ), + }, + extra=vol.ALLOW_EXTRA, + required=True, + ), + cv.has_at_least_one_key(CONF_PORT, CONF_HOST), + set_default_port, + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +ADD_ALL_LINK_SCHEMA = vol.Schema( + { + vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255), + vol.Required(SRV_ALL_LINK_MODE): vol.In([SRV_CONTROLLER, SRV_RESPONDER]), + } +) + + +DEL_ALL_LINK_SCHEMA = vol.Schema( + {vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255)} +) + + +LOAD_ALDB_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): vol.Any( + cv.entity_id, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE + ), + vol.Optional(SRV_LOAD_DB_RELOAD, default=False): cv.boolean, + } +) + + +PRINT_ALDB_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) + + +X10_HOUSECODE_SCHEMA = vol.Schema({vol.Required(SRV_HOUSECODE): vol.In(HOUSECODES)}) + + +TRIGGER_SCENE_SCHEMA = vol.Schema( + {vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255)} +) diff --git a/homeassistant/components/insteon/sensor.py b/homeassistant/components/insteon/sensor.py index 0e8a592b92d651..475723b105d4dd 100644 --- a/homeassistant/components/insteon/sensor.py +++ b/homeassistant/components/insteon/sensor.py @@ -3,7 +3,7 @@ from homeassistant.helpers.entity import Entity -from . import InsteonEntity +from .insteon_entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index c36e60c2effd57..eec7874c7fbb18 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -3,7 +3,7 @@ from homeassistant.components.switch import SwitchDevice -from . import InsteonEntity +from .insteon_entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py new file mode 100644 index 00000000000000..f195a458477543 --- /dev/null +++ b/homeassistant/components/insteon/utils.py @@ -0,0 +1,239 @@ +"""Utilities used by insteon component.""" + +import logging + +from insteonplm.devices import ALDBStatus + +from homeassistant.const import CONF_ADDRESS, CONF_ENTITY_ID, ENTITY_MATCH_ALL +from homeassistant.core import callback +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + BUTTON_PRESSED_STATE_NAME, + DOMAIN, + EVENT_BUTTON_OFF, + EVENT_BUTTON_ON, + EVENT_CONF_BUTTON, + INSTEON_ENTITIES, + SIGNAL_LOAD_ALDB, + SIGNAL_PRINT_ALDB, + SRV_ADD_ALL_LINK, + SRV_ALL_LINK_GROUP, + SRV_ALL_LINK_MODE, + SRV_CONTROLLER, + SRV_DEL_ALL_LINK, + SRV_HOUSECODE, + SRV_LOAD_ALDB, + SRV_LOAD_DB_RELOAD, + SRV_PRINT_ALDB, + SRV_PRINT_IM_ALDB, + SRV_SCENE_OFF, + SRV_SCENE_ON, + SRV_X10_ALL_LIGHTS_OFF, + SRV_X10_ALL_LIGHTS_ON, + SRV_X10_ALL_UNITS_OFF, +) +from .ipdb import IPDB +from .schemas import ( + ADD_ALL_LINK_SCHEMA, + DEL_ALL_LINK_SCHEMA, + LOAD_ALDB_SCHEMA, + PRINT_ALDB_SCHEMA, + TRIGGER_SCENE_SCHEMA, + X10_HOUSECODE_SCHEMA, +) + +_LOGGER = logging.getLogger(__name__) + + +def register_new_device_callback(hass, config, insteon_modem): + """Register callback for new Insteon device.""" + + def _fire_button_on_off_event(address, group, val): + # Firing an event when a button is pressed. + device = insteon_modem.devices[address.hex] + state_name = device.states[group].name + button = ( + "" if state_name == BUTTON_PRESSED_STATE_NAME else state_name[-1].lower() + ) + schema = {CONF_ADDRESS: address.hex} + if button != "": + schema[EVENT_CONF_BUTTON] = button + if val: + event = EVENT_BUTTON_ON + else: + event = EVENT_BUTTON_OFF + _LOGGER.debug( + "Firing event %s with address %s and button %s", event, address.hex, button + ) + hass.bus.fire(event, schema) + + @callback + def async_new_insteon_device(device): + """Detect device from transport to be delegated to platform.""" + ipdb = IPDB() + for state_key in device.states: + platform_info = ipdb[device.states[state_key]] + if platform_info and platform_info.platform: + platform = platform_info.platform + + if platform == "on_off_events": + device.states[state_key].register_updates(_fire_button_on_off_event) + + else: + _LOGGER.info( + "New INSTEON device: %s (%s) %s", + device.address, + device.states[state_key].name, + platform, + ) + + hass.async_create_task( + discovery.async_load_platform( + hass, + platform, + DOMAIN, + discovered={ + "address": device.address.id, + "state_key": state_key, + }, + hass_config=config, + ) + ) + + insteon_modem.devices.add_device_callback(async_new_insteon_device) + + +@callback +def async_register_services(hass, config, insteon_modem): + """Register services used by insteon component.""" + + def add_all_link(service): + """Add an INSTEON All-Link between two devices.""" + group = service.data.get(SRV_ALL_LINK_GROUP) + mode = service.data.get(SRV_ALL_LINK_MODE) + link_mode = 1 if mode.lower() == SRV_CONTROLLER else 0 + insteon_modem.start_all_linking(link_mode, group) + + def del_all_link(service): + """Delete an INSTEON All-Link between two devices.""" + group = service.data.get(SRV_ALL_LINK_GROUP) + insteon_modem.start_all_linking(255, group) + + def load_aldb(service): + """Load the device All-Link database.""" + entity_id = service.data[CONF_ENTITY_ID] + reload = service.data[SRV_LOAD_DB_RELOAD] + if entity_id.lower() == ENTITY_MATCH_ALL: + for entity_id in hass.data[DOMAIN][INSTEON_ENTITIES]: + _send_load_aldb_signal(entity_id, reload) + else: + _send_load_aldb_signal(entity_id, reload) + + def _send_load_aldb_signal(entity_id, reload): + """Send the load All-Link database signal to INSTEON entity.""" + signal = f"{entity_id}_{SIGNAL_LOAD_ALDB}" + dispatcher_send(hass, signal, reload) + + def print_aldb(service): + """Print the All-Link Database for a device.""" + # For now this sends logs to the log file. + # Future direction is to create an INSTEON control panel. + entity_id = service.data[CONF_ENTITY_ID] + signal = f"{entity_id}_{SIGNAL_PRINT_ALDB}" + dispatcher_send(hass, signal) + + def print_im_aldb(service): + """Print the All-Link Database for a device.""" + # For now this sends logs to the log file. + # Future direction is to create an INSTEON control panel. + print_aldb_to_log(insteon_modem.aldb) + + def x10_all_units_off(service): + """Send the X10 All Units Off command.""" + housecode = service.data.get(SRV_HOUSECODE) + insteon_modem.x10_all_units_off(housecode) + + def x10_all_lights_off(service): + """Send the X10 All Lights Off command.""" + housecode = service.data.get(SRV_HOUSECODE) + insteon_modem.x10_all_lights_off(housecode) + + def x10_all_lights_on(service): + """Send the X10 All Lights On command.""" + housecode = service.data.get(SRV_HOUSECODE) + insteon_modem.x10_all_lights_on(housecode) + + def scene_on(service): + """Trigger an INSTEON scene ON.""" + group = service.data.get(SRV_ALL_LINK_GROUP) + insteon_modem.trigger_group_on(group) + + def scene_off(service): + """Trigger an INSTEON scene ON.""" + group = service.data.get(SRV_ALL_LINK_GROUP) + insteon_modem.trigger_group_off(group) + + hass.services.async_register( + DOMAIN, SRV_ADD_ALL_LINK, add_all_link, schema=ADD_ALL_LINK_SCHEMA + ) + hass.services.async_register( + DOMAIN, SRV_DEL_ALL_LINK, del_all_link, schema=DEL_ALL_LINK_SCHEMA + ) + hass.services.async_register( + DOMAIN, SRV_LOAD_ALDB, load_aldb, schema=LOAD_ALDB_SCHEMA + ) + hass.services.async_register( + DOMAIN, SRV_PRINT_ALDB, print_aldb, schema=PRINT_ALDB_SCHEMA + ) + hass.services.async_register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None) + hass.services.async_register( + DOMAIN, SRV_X10_ALL_UNITS_OFF, x10_all_units_off, schema=X10_HOUSECODE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SRV_X10_ALL_LIGHTS_OFF, x10_all_lights_off, schema=X10_HOUSECODE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SRV_X10_ALL_LIGHTS_ON, x10_all_lights_on, schema=X10_HOUSECODE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SRV_SCENE_ON, scene_on, schema=TRIGGER_SCENE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SRV_SCENE_OFF, scene_off, schema=TRIGGER_SCENE_SCHEMA + ) + _LOGGER.debug("Insteon Services registered") + + +def print_aldb_to_log(aldb): + """Print the All-Link Database to the log file.""" + _LOGGER.info("ALDB load status is %s", aldb.status.name) + if aldb.status not in [ALDBStatus.LOADED, ALDBStatus.PARTIAL]: + _LOGGER.warning("Device All-Link database not loaded") + _LOGGER.warning("Use service insteon.load_aldb first") + return + + _LOGGER.info("RecID In Use Mode HWM Group Address Data 1 Data 2 Data 3") + _LOGGER.info("----- ------ ---- --- ----- -------- ------ ------ ------") + for mem_addr in aldb: + rec = aldb[mem_addr] + # For now we write this to the log + # Roadmap is to create a configuration panel + in_use = "Y" if rec.control_flags.is_in_use else "N" + mode = "C" if rec.control_flags.is_controller else "R" + hwm = "Y" if rec.control_flags.is_high_water_mark else "N" + _LOGGER.info( + " {:04x} {:s} {:s} {:s} {:3d} {:s}" + " {:3d} {:3d} {:3d}".format( + rec.mem_addr, + in_use, + mode, + hwm, + rec.group, + rec.address.human, + rec.data1, + rec.data2, + rec.data3, + ) + ) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 560a7cbd33ceb8..dea7a5083dce75 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -10,6 +10,10 @@ CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, + TIME_DAYS, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -38,7 +42,12 @@ UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "G": 10 ** 6, "T": 10 ** 9} # SI Time prefixes -UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60} +UNIT_TIME = { + TIME_SECONDS: 1, + TIME_MINUTES: 60, + TIME_HOURS: 60 * 60, + TIME_DAYS: 24 * 60 * 60, +} ICON = "mdi:chart-histogram" @@ -50,7 +59,7 @@ vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), - vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( INTEGRATION_METHOD diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index bdf612b2e831ff..37761e88347fe2 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -5,7 +5,8 @@ from homeassistant.components import http from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.core import HomeAssistant +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv, integration_platform, intent from .const import DOMAIN @@ -22,6 +23,22 @@ async def async_setup(hass: HomeAssistant, config: dict): hass, DOMAIN, _async_process_intent ) + hass.helpers.intent.async_register( + intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, HA_DOMAIN, SERVICE_TURN_ON, "Turned {} on" + ) + ) + hass.helpers.intent.async_register( + intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, HA_DOMAIN, SERVICE_TURN_OFF, "Turned {} off" + ) + ) + hass.helpers.intent.async_register( + intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, HA_DOMAIN, SERVICE_TOGGLE, "Toggled {}" + ) + ) + return True diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 75622c29b1cf96..3f193993c2bc63 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -279,7 +279,7 @@ class iOSIdentifyDeviceView(HomeAssistantView): name = "api:ios:identify" def __init__(self, config_path): - """Initiliaze the view.""" + """Initialize the view.""" self._config_path = config_path async def post(self, request): diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index 9272a725bb7f03..bd5aeac099a4b1 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -13,6 +13,7 @@ CONF_PORT, CONF_PROTOCOL, CONF_SCAN_INTERVAL, + DATA_RATE_MEGABITS_PER_SECOND, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform @@ -39,11 +40,9 @@ ATTR_VERSION = "Version" ATTR_HOST = "host" -UNIT_OF_MEASUREMENT = "Mbit/s" - SENSOR_TYPES = { - ATTR_DOWNLOAD: [ATTR_DOWNLOAD.capitalize(), UNIT_OF_MEASUREMENT], - ATTR_UPLOAD: [ATTR_UPLOAD.capitalize(), UNIT_OF_MEASUREMENT], + ATTR_DOWNLOAD: [ATTR_DOWNLOAD.capitalize(), DATA_RATE_MEGABITS_PER_SECOND], + ATTR_UPLOAD: [ATTR_UPLOAD.capitalize(), DATA_RATE_MEGABITS_PER_SECOND], } PROTOCOLS = ["tcp", "udp"] diff --git a/homeassistant/components/ipma/.translations/ca.json b/homeassistant/components/ipma/.translations/ca.json index 29dbaa4f58dacc..ad2d37524c5166 100644 --- a/homeassistant/components/ipma/.translations/ca.json +++ b/homeassistant/components/ipma/.translations/ca.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitud", "longitude": "Longitud", + "mode": "Mode", "name": "Nom" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/da.json b/homeassistant/components/ipma/.translations/da.json index 017aff4d0ec34f..e2f72db7c4dac3 100644 --- a/homeassistant/components/ipma/.translations/da.json +++ b/homeassistant/components/ipma/.translations/da.json @@ -8,6 +8,7 @@ "data": { "latitude": "Breddegrad", "longitude": "L\u00e6ngdegrad", + "mode": "Tilstand", "name": "Navn" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/de.json b/homeassistant/components/ipma/.translations/de.json index 9e717b77843ae9..977b69576deef2 100644 --- a/homeassistant/components/ipma/.translations/de.json +++ b/homeassistant/components/ipma/.translations/de.json @@ -8,6 +8,7 @@ "data": { "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad", + "mode": "Modus", "name": "Name" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/en.json b/homeassistant/components/ipma/.translations/en.json index 15459b91f2a408..d47f0dfb50152a 100644 --- a/homeassistant/components/ipma/.translations/en.json +++ b/homeassistant/components/ipma/.translations/en.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitude", "longitude": "Longitude", + "mode": "Mode", "name": "Name" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/es.json b/homeassistant/components/ipma/.translations/es.json index acb8b51a44c81f..d6a43fc790bfe3 100644 --- a/homeassistant/components/ipma/.translations/es.json +++ b/homeassistant/components/ipma/.translations/es.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitud", "longitude": "Longitud", + "mode": "Modo", "name": "Nombre" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/fr.json b/homeassistant/components/ipma/.translations/fr.json index 64d03c6ae71d77..46b99e6651a272 100644 --- a/homeassistant/components/ipma/.translations/fr.json +++ b/homeassistant/components/ipma/.translations/fr.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitude", "longitude": "Longitude", + "mode": "Mode", "name": "Nom" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/hu.json b/homeassistant/components/ipma/.translations/hu.json index 62ddd85e6ef13b..5165fec9d902ee 100644 --- a/homeassistant/components/ipma/.translations/hu.json +++ b/homeassistant/components/ipma/.translations/hu.json @@ -8,6 +8,7 @@ "data": { "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g", + "mode": "M\u00f3d", "name": "N\u00e9v" }, "description": "Portug\u00e1l Atmoszf\u00e9ra Int\u00e9zet", diff --git a/homeassistant/components/ipma/.translations/it.json b/homeassistant/components/ipma/.translations/it.json index 954ff6e9ee1e9e..6e89d4259344f0 100644 --- a/homeassistant/components/ipma/.translations/it.json +++ b/homeassistant/components/ipma/.translations/it.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitudine", "longitude": "Longitudine", + "mode": "Modalit\u00e0", "name": "Nome" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/ko.json b/homeassistant/components/ipma/.translations/ko.json index 828733c9195ae5..c5614e1703414a 100644 --- a/homeassistant/components/ipma/.translations/ko.json +++ b/homeassistant/components/ipma/.translations/ko.json @@ -8,6 +8,7 @@ "data": { "latitude": "\uc704\ub3c4", "longitude": "\uacbd\ub3c4", + "mode": "\ubaa8\ub4dc", "name": "\uc774\ub984" }, "description": "\ud3ec\ub974\ud22c\uac08 \ud574\uc591 \ubc0f \ub300\uae30 \uc5f0\uad6c\uc18c (Instituto Portugu\u00eas do Mar e Atmosfera)", diff --git a/homeassistant/components/ipma/.translations/lb.json b/homeassistant/components/ipma/.translations/lb.json index c9eb3a01941dcc..7d8280998fe137 100644 --- a/homeassistant/components/ipma/.translations/lb.json +++ b/homeassistant/components/ipma/.translations/lb.json @@ -8,6 +8,7 @@ "data": { "latitude": "Breedegrad", "longitude": "L\u00e4ngegrad", + "mode": "Modus", "name": "Numm" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/nl.json b/homeassistant/components/ipma/.translations/nl.json index bc10eb3573ecd6..00b9881fd979fc 100644 --- a/homeassistant/components/ipma/.translations/nl.json +++ b/homeassistant/components/ipma/.translations/nl.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitude", "longitude": "Longitude", + "mode": "Mode", "name": "Naam" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/no.json b/homeassistant/components/ipma/.translations/no.json index 1d5aa9c40cf26c..d726173243167d 100644 --- a/homeassistant/components/ipma/.translations/no.json +++ b/homeassistant/components/ipma/.translations/no.json @@ -8,6 +8,7 @@ "data": { "latitude": "Breddegrad", "longitude": "Lengdegrad", + "mode": "Modus", "name": "Navn" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/pl.json b/homeassistant/components/ipma/.translations/pl.json index 735f5a4a12628b..267b4e79137bd3 100644 --- a/homeassistant/components/ipma/.translations/pl.json +++ b/homeassistant/components/ipma/.translations/pl.json @@ -1,13 +1,14 @@ { "config": { "error": { - "name_exists": "Nazwa ju\u017c istnieje" + "name_exists": "Nazwa ju\u017c istnieje." }, "step": { "user": { "data": { "latitude": "Szeroko\u015b\u0107 geograficzna", "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "mode": "Tryb", "name": "Nazwa" }, "description": "Portugalski Instytut Morza i Atmosfery", diff --git a/homeassistant/components/ipma/.translations/ru.json b/homeassistant/components/ipma/.translations/ru.json index 0db504c629cb5c..96d4ee904076b1 100644 --- a/homeassistant/components/ipma/.translations/ru.json +++ b/homeassistant/components/ipma/.translations/ru.json @@ -8,6 +8,7 @@ "data": { "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "mode": "\u0420\u0435\u0436\u0438\u043c", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, "description": "\u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u044c\u0441\u043a\u0438\u0439 \u0438\u043d\u0441\u0442\u0438\u0442\u0443\u0442 \u043c\u043e\u0440\u044f \u0438 \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u044b.", diff --git a/homeassistant/components/ipma/.translations/sl.json b/homeassistant/components/ipma/.translations/sl.json index da6a1dac859048..2dcfcde740475b 100644 --- a/homeassistant/components/ipma/.translations/sl.json +++ b/homeassistant/components/ipma/.translations/sl.json @@ -8,6 +8,7 @@ "data": { "latitude": "Zemljepisna \u0161irina", "longitude": "Zemljepisna dol\u017eina", + "mode": "Na\u010din", "name": "Ime" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/sv.json b/homeassistant/components/ipma/.translations/sv.json index 4bdba6f0d08658..e8cba56a0a03e1 100644 --- a/homeassistant/components/ipma/.translations/sv.json +++ b/homeassistant/components/ipma/.translations/sv.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitud", "longitude": "Longitud", + "mode": "L\u00e4ge", "name": "Namn" }, "description": "Portugisiska institutet f\u00f6r hav och atmosf\u00e4ren", diff --git a/homeassistant/components/ipma/.translations/zh-Hans.json b/homeassistant/components/ipma/.translations/zh-Hans.json index 6c5654b6388e9f..10d518329644ca 100644 --- a/homeassistant/components/ipma/.translations/zh-Hans.json +++ b/homeassistant/components/ipma/.translations/zh-Hans.json @@ -8,6 +8,7 @@ "data": { "latitude": "\u7eac\u5ea6", "longitude": "\u7ecf\u5ea6", + "mode": "\u6a21\u5f0f", "name": "\u540d\u79f0" }, "description": "\u8461\u8404\u7259\u56fd\u5bb6\u5927\u6c14\u7814\u7a76\u6240", diff --git a/homeassistant/components/ipma/.translations/zh-Hant.json b/homeassistant/components/ipma/.translations/zh-Hant.json index 25c832e51c6523..de36336f2c4452 100644 --- a/homeassistant/components/ipma/.translations/zh-Hant.json +++ b/homeassistant/components/ipma/.translations/zh-Hant.json @@ -8,6 +8,7 @@ "data": { "latitude": "\u7def\u5ea6", "longitude": "\u7d93\u5ea6", + "mode": "\u6a21\u5f0f", "name": "\u540d\u7a31" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index d1532066f68cd0..3811d30bfbe4fb 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -2,10 +2,11 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME import homeassistant.helpers.config_validation as cv from .const import DOMAIN, HOME_LOCATION_NAME +from .weather import FORECAST_MODE @config_entries.HANDLERS.register(DOMAIN) @@ -49,6 +50,7 @@ async def _show_config_form(self, name=None, latitude=None, longitude=None): vol.Required(CONF_NAME, default=name): str, vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, + vol.Required(CONF_MODE, default="daily"): vol.In(FORECAST_MODE), } ), errors=self._errors, diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index cd66ce7461b958..02d4e459f723cb 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -3,7 +3,7 @@ "name": "Instituto Português do Mar e Atmosfera (IPMA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", - "requirements": ["pyipma==2.0.2"], + "requirements": ["pyipma==2.0.3"], "dependencies": [], "codeowners": ["@dgomes", "@abmantis"] } diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index f22d1b62fe44cb..ea8b9edcc86380 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -8,7 +8,8 @@ "data": { "name": "Name", "latitude": "Latitude", - "longitude": "Longitude" + "longitude": "Longitude", + "mode": "Mode" } } }, diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 7b07406d007f75..1fce3922b58bd4 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -13,13 +13,22 @@ ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity, ) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, + TEMP_CELSIUS, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import Throttle +from homeassistant.util.dt import now, parse_datetime _LOGGER = logging.getLogger(__name__) @@ -44,11 +53,14 @@ "exceptional": [], } +FORECAST_MODE = ["hourly", "daily"] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_LATITUDE): cv.latitude, vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default="daily"): vol.In(FORECAST_MODE), } ) @@ -96,10 +108,12 @@ async def async_get_location(hass, api, latitude, longitude): location = await Location.get(api, float(latitude), float(longitude)) _LOGGER.debug( - "Initializing for coordinates %s, %s -> station %s", + "Initializing for coordinates %s, %s -> station %s (%d, %d)", latitude, longitude, location.station, + location.id_station, + location.global_id_local, ) return location @@ -112,6 +126,7 @@ def __init__(self, location: Location, api: IPMA_API, config): """Initialise the platform with a data instance and station name.""" self._api = api self._location_name = config.get(CONF_NAME, location.name) + self._mode = config.get(CONF_MODE) self._location = location self._observation = None self._forecast = None @@ -129,7 +144,7 @@ async def async_update(self): _LOGGER.warning("Could not update weather observation") if new_forecast: - self._forecast = [f for f in new_forecast if f.forecasted_hours == 24] + self._forecast = new_forecast else: _LOGGER.warning("Could not update weather forecast") @@ -220,22 +235,57 @@ def forecast(self): if not self._forecast: return [] - fcdata_out = [ - { - ATTR_FORECAST_TIME: data_in.forecast_date, - ATTR_FORECAST_CONDITION: next( - ( - k - for k, v in CONDITION_CLASSES.items() - if int(data_in.weather_type) in v + if self._mode == "hourly": + forecast_filtered = [ + x + for x in self._forecast + if x.forecasted_hours == 1 + and parse_datetime(x.forecast_date) + > (now().utcnow() - timedelta(hours=1)) + ] + + fcdata_out = [ + { + ATTR_FORECAST_TIME: data_in.forecast_date, + ATTR_FORECAST_CONDITION: next( + ( + k + for k, v in CONDITION_CLASSES.items() + if int(data_in.weather_type) in v + ), + None, + ), + ATTR_FORECAST_TEMP: float(data_in.feels_like_temperature), + ATTR_FORECAST_PRECIPITATION: ( + data_in.precipitation_probability + if float(data_in.precipitation_probability) >= 0 + else None + ), + ATTR_FORECAST_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, + } + for data_in in forecast_filtered + ] + else: + forecast_filtered = [f for f in self._forecast if f.forecasted_hours == 24] + fcdata_out = [ + { + ATTR_FORECAST_TIME: data_in.forecast_date, + ATTR_FORECAST_CONDITION: next( + ( + k + for k, v in CONDITION_CLASSES.items() + if int(data_in.weather_type) in v + ), + None, ), - None, - ), - ATTR_FORECAST_TEMP_LOW: data_in.min_temperature, - ATTR_FORECAST_TEMP: data_in.max_temperature, - ATTR_FORECAST_PRECIPITATION: data_in.precipitation_probability, - } - for data_in in self._forecast - ] + ATTR_FORECAST_TEMP_LOW: data_in.min_temperature, + ATTR_FORECAST_TEMP: data_in.max_temperature, + ATTR_FORECAST_PRECIPITATION: data_in.precipitation_probability, + ATTR_FORECAST_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, + } + for data_in in forecast_filtered + ] return fcdata_out diff --git a/homeassistant/components/iqvia/.translations/pl.json b/homeassistant/components/iqvia/.translations/pl.json index b528cdeb70f3b0..b8c014c3dc9acb 100644 --- a/homeassistant/components/iqvia/.translations/pl.json +++ b/homeassistant/components/iqvia/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Kod pocztowy ju\u017c zarejestrowany", + "identifier_exists": "Kod pocztowy jest ju\u017c zarejestrowany.", "invalid_zip_code": "Kod pocztowy jest nieprawid\u0142owy" }, "step": { diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py index 09548ee929a95c..52e657bc2c090f 100644 --- a/homeassistant/components/iqvia/const.py +++ b/homeassistant/components/iqvia/const.py @@ -6,7 +6,7 @@ DATA_CLIENT = "client" DATA_LISTENER = "listener" -TOPIC_DATA_UPDATE = "data_update" +TOPIC_DATA_UPDATE = f"{DOMAIN}_data_update" TYPE_ALLERGY_FORECAST = "allergy_average_forecasted" TYPE_ALLERGY_INDEX = "allergy_index" diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 7a5eb7e56df386..363269bc589c2a 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.17.4", "pyiqvia==0.2.1"], + "requirements": ["numpy==1.18.1", "pyiqvia==0.2.1"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 09edca52895c09..24ccfa9cdbf7f5 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -134,21 +134,34 @@ class IndexSensor(IQVIAEntity): async def async_update(self): """Update the sensor.""" if not self._iqvia.data: + _LOGGER.warning( + "IQVIA didn't return data for %s; trying again later", self.name + ) 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: + try: + 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") + except KeyError: + _LOGGER.warning( + "IQVIA didn't return data for %s; trying again later", self.name + ) return key = self._type.split("_")[-1].title() - [period] = [p for p in data["periods"] if p["Type"] == key] + + try: + [period] = [p for p in data["periods"] if p["Type"] == key] + except ValueError: + _LOGGER.warning( + "IQVIA didn't return data for %s; trying again later", self.name + ) + return + [rating] = [ i["label"] for i in RATING_MAPPING diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 883f4ed7b397f0..3bb7da52e22c4f 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -118,7 +118,7 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index a9746b004d0493..42590b0ea13054 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -3,7 +3,23 @@ from typing import Callable from homeassistant.components.sensor import DOMAIN -from homeassistant.const import POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_DAYS, + TIME_HOURS, + TIME_MILLISECONDS, + TIME_MINUTES, + TIME_MONTHS, + TIME_SECONDS, + TIME_YEARS, + UNIT_UV_INDEX, +) from homeassistant.helpers.typing import ConfigType from . import ISY994_NODES, ISY994_WEATHER, ISYDevice @@ -12,22 +28,22 @@ UOM_FRIENDLY_NAME = { "1": "amp", - "3": "btu/h", + "3": f"btu/{TIME_HOURS}", "4": TEMP_CELSIUS, "5": "cm", "6": "ft³", - "7": "ft³/min", + "7": f"ft³/{TIME_MINUTES}", "8": "m³", - "9": "day", - "10": "days", + "9": TIME_DAYS, + "10": TIME_DAYS, "12": "dB", "13": "dB A", "14": "°", "16": "macroseismic", "17": TEMP_FAHRENHEIT, "18": "ft", - "19": "hour", - "20": "hours", + "19": TIME_HOURS, + "20": TIME_HOURS, "21": "abs. humidity (%)", "22": "rel. humidity (%)", "23": "inHg", @@ -39,7 +55,7 @@ "29": "kV", "30": "kW", "31": "kPa", - "32": "KPH", + "32": SPEED_KILOMETERS_PER_HOUR, "33": "kWH", "34": "liedu", "35": "l", @@ -47,24 +63,24 @@ "37": "mercalli", "38": "m", "39": "m³/hr", - "40": "m/s", + "40": SPEED_METERS_PER_SECOND, "41": "mA", - "42": "ms", + "42": TIME_MILLISECONDS, "43": "mV", - "44": "min", - "45": "min", + "44": TIME_MINUTES, + "45": TIME_MINUTES, "46": "mm/hr", - "47": "month", - "48": "MPH", - "49": "m/s", + "47": TIME_MONTHS, + "48": SPEED_MILES_PER_HOUR, + "49": SPEED_METERS_PER_SECOND, "50": "ohm", "51": "%", "52": "lb", "53": "power factor", - "54": "ppm", + "54": CONCENTRATION_PARTS_PER_MILLION, "55": "pulse count", - "57": "s", - "58": "s", + "57": TIME_SECONDS, + "58": TIME_SECONDS, "59": "seimens/m", "60": "body wave magnitude scale", "61": "Ricter scale", @@ -79,7 +95,7 @@ "74": "W/m²", "75": "weekday", "76": "Wind Direction (°)", - "77": "year", + "77": TIME_YEARS, "82": "mm", "83": "km", "85": "ohm", diff --git a/homeassistant/components/izone/.translations/hu.json b/homeassistant/components/izone/.translations/hu.json new file mode 100644 index 00000000000000..79c621ce125a8e --- /dev/null +++ b/homeassistant/components/izone/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani az iZone-t?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/sv.json b/homeassistant/components/izone/.translations/sv.json new file mode 100644 index 00000000000000..c2c952d69fe8b6 --- /dev/null +++ b/homeassistant/components/izone/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga iZone-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av iZone \u00e4r n\u00f6dv\u00e4ndig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 0e5dcddbc488b4..1b4d2f19d1c93c 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -1,9 +1,4 @@ -""" -Platform for the iZone AC. - -For more details about this component, please refer to the documentation -https://home-assistant.io/integrations/izone/ -""" +"""Platform for the iZone AC.""" import logging import voluptuous as vol diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index c49144f1db9fc9..7690600786e59f 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -44,11 +44,11 @@ def controller_reconnected(self, ctrl: pizone.Controller) -> None: async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl) def controller_update(self, ctrl: pizone.Controller) -> None: - """System update message is recieved from the controller.""" + """System update message is received from the controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl) def zone_update(self, ctrl: pizone.Controller, zone: pizone.Zone) -> None: - """Zone update message is recieved from the controller.""" + """Zone update message is received from the controller.""" async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 2e4644d7ef5590..45f979874f74a0 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -21,6 +21,7 @@ "weekly_portion": ["Parshat Hashavua", "mdi:book-open-variant"], "holiday": ["Holiday", "mdi:calendar-star"], "omer_count": ["Day of the Omer", "mdi:counter"], + "daf_yomi": ["Daf Yomi", "mdi:book-open-variant"], }, "time": { "first_light": ["Alot Hashachar", "mdi:weather-sunset-up"], diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 8e1781f310b7f0..c4ebb382a44ce2 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -2,7 +2,7 @@ "domain": "jewish_calendar", "name": "Jewish Calendar", "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", - "requirements": ["hdate==0.9.3"], + "requirements": ["hdate==0.9.5"], "dependencies": [], "codeowners": ["@tsvi"] } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d0376694a4404a..7da9d7e31d0716 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -73,7 +73,7 @@ async def async_update(self): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - date = hdate.HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) + daytime_date = hdate.HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) # The Jewish day starts after darkness (called "tzais") and finishes at # sunset ("shkia"). The time in between is a gray area (aka "Bein @@ -82,16 +82,16 @@ async def async_update(self): # For some sensors, it is more interesting to consider the date to be # tomorrow based on sunset ("shkia"), for others based on "tzais". # Hence the following variables. - after_tzais_date = after_shkia_date = date + after_tzais_date = after_shkia_date = daytime_date today_times = self.make_zmanim(today) if now > sunset: - after_shkia_date = date.next_day + after_shkia_date = daytime_date.next_day if today_times.havdalah and now > today_times.havdalah: - after_tzais_date = date.next_day + after_tzais_date = daytime_date.next_day - self._state = self.get_state(after_shkia_date, after_tzais_date) + self._state = self.get_state(daytime_date, after_shkia_date, after_tzais_date) _LOGGER.debug("New value for %s: %s", self._type, self._state) def make_zmanim(self, date): @@ -112,7 +112,7 @@ def device_state_attributes(self): return {} - def get_state(self, after_shkia_date, after_tzais_date): + def get_state(self, daytime_date, after_shkia_date, after_tzais_date): """For a given type of sensor, return the state.""" # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. @@ -128,6 +128,8 @@ def get_state(self, after_shkia_date, after_tzais_date): return after_shkia_date.holiday_description if self._type == "omer_count": return after_shkia_date.omer_day + if self._type == "daf_yomi": + return daytime_date.daf_yomi return None @@ -157,7 +159,7 @@ def device_state_attributes(self): return attrs - def get_state(self, after_shkia_date, after_tzais_date): + def get_state(self, daytime_date, after_shkia_date, after_tzais_date): """For a given type of sensor, return the state.""" if self._type == "upcoming_shabbat_candle_lighting": times = self.make_zmanim( diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 9a0431ef2d86b5..67a04d3955672e 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,7 +1,7 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" import logging -from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS +from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS from homeassistant.helpers.entity import Entity from . import DOMAIN, JuicenetDevice @@ -14,7 +14,7 @@ "voltage": ["Voltage", "V"], "amps": ["Amps", "A"], "watts": ["Watts", POWER_WATT], - "charge_time": ["Charge time", "s"], + "charge_time": ["Charge time", TIME_SECONDS], "energy_added": ["Energy added", ENERGY_WATT_HOUR], } diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py index 7e23edb1259fa9..6c3ea4d6f01678 100644 --- a/homeassistant/components/kaiterra/const.py +++ b/homeassistant/components/kaiterra/const.py @@ -2,6 +2,13 @@ from datetime import timedelta +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, +) + DOMAIN = "kaiterra" DISPATCHER_KAITERRA = "kaiterra_update" @@ -44,7 +51,16 @@ ATTR_AQI_POLLUTANT = "air_quality_index_pollutant" AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"] -AVAILABLE_UNITS = ["x", "%", "C", "F", "mg/m³", "µg/m³", "ppm", "ppb"] +AVAILABLE_UNITS = [ + "x", + "%", + "C", + "F", + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONCENTRATION_PARTS_PER_BILLION, +] AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"] CONF_AQI_STANDARD = "aqi_standard" diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index a2769cd8eb60e8..135f8e1cf54502 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/kef", "dependencies": [], "codeowners": ["@basnijholt"], - "requirements": ["aiokef==0.2.6", "getmac==0.8.1"] + "requirements": ["aiokef==0.2.7", "getmac==0.8.1"] } diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index dc91b94f5ef96c..d4a1d7a4df3fe5 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -11,6 +11,10 @@ from homeassistant.components.media_player import ( PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, @@ -214,6 +218,10 @@ def supported_features(self): | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | SUPPORT_TURN_OFF + | SUPPORT_NEXT_TRACK # only in Bluetooth and Wifi + | SUPPORT_PAUSE # only in Bluetooth and Wifi + | SUPPORT_PLAY # only in Bluetooth and Wifi + | SUPPORT_PREVIOUS_TRACK # only in Bluetooth and Wifi ) if self._supports_on: support_kef |= SUPPORT_TURN_ON @@ -280,3 +288,19 @@ async def async_select_source(self, source: str): await self._speaker.set_source(source) else: raise ValueError(f"Unknown input source: {source}.") + + async def async_media_play(self): + """Send play command.""" + await self._speaker.play_pause() + + async def async_media_pause(self): + """Send pause command.""" + await self._speaker.play_pause() + + async def async_media_previous_track(self): + """Send previous track command.""" + await self._speaker.prev_track() + + async def async_media_next_track(self): + """Send next track command.""" + await self._speaker.next_track() diff --git a/homeassistant/components/kira/remote.py b/homeassistant/components/kira/remote.py index 8914641dd74ae2..330813a7bffa09 100644 --- a/homeassistant/components/kira/remote.py +++ b/homeassistant/components/kira/remote.py @@ -48,9 +48,8 @@ def send_command(self, command, **kwargs): _LOGGER.info("Sending Command: %s to %s", *code_tuple) self._kira.sendCode(code_tuple) - def async_send_command(self, command, **kwargs): - """Send a command to a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.send_command, command, **kwargs)) + async def async_send_command(self, command, **kwargs): + """Send a command to a device.""" + return await self.hass.async_add_job( + ft.partial(self.send_command, command, **kwargs) + ) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 819fb1794c3896..554ae59f3972c4 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -330,10 +330,7 @@ def preset_modes(self) -> Optional[List[str]]: return list(filter(None, _presets)) async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode. - - This method must be run in the event loop and returns a coroutine. - """ + """Set new preset mode.""" if self.device.mode.supports_operation_mode: knx_operation_mode = HVACOperationMode(PRESET_MODES_INV.get(preset_mode)) await self.device.mode.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index c7292309461440..6eb539c19ce41b 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -269,8 +269,16 @@ async def async_turn_on(self, **kwargs): update_white_value = ATTR_WHITE_VALUE in kwargs update_color_temp = ATTR_COLOR_TEMP in kwargs - # always only go one path for turning on (avoid conflicting changes - # and weird effects) + # avoid conflicting changes and weird effects + if not ( + self.is_on + or update_brightness + or update_color + or update_white_value + or update_color_temp + ): + await self.device.set_on() + if self.device.supports_brightness and (update_brightness and not update_color): # if we don't need to update the color, try updating brightness # directly if supported; don't do it if color also has to be @@ -279,7 +287,7 @@ async def async_turn_on(self, **kwargs): elif (self.device.supports_rgbw or self.device.supports_color) and ( update_brightness or update_color or update_white_value ): - # change RGB color, white value )if supported), and brightness + # change RGB color, white value (if supported), and brightness # if brightness or hs_color was not yet set use the default value # to calculate RGB from as a fallback if brightness is None: @@ -290,29 +298,20 @@ async def async_turn_on(self, **kwargs): white_value = DEFAULT_WHITE_VALUE rgb = color_util.color_hsv_to_RGB(*hs_color, brightness * 100 / 255) await self.device.set_color(rgb, white_value) - elif self.device.supports_color_temperature and update_color_temp: - # change color temperature without ON telegram + + if update_color_temp: kelvin = int(color_util.color_temperature_mired_to_kelvin(mireds)) - if kelvin > self._max_kelvin: - kelvin = self._max_kelvin - elif kelvin < self._min_kelvin: - kelvin = self._min_kelvin - await self.device.set_color_temperature(kelvin) - elif self.device.supports_tunable_white and update_color_temp: - # calculate relative_ct from Kelvin to fit typical KNX devices - kelvin = min( - self._max_kelvin, - int(color_util.color_temperature_mired_to_kelvin(mireds)), - ) - relative_ct = int( - 255 - * (kelvin - self._min_kelvin) - / (self._max_kelvin - self._min_kelvin) - ) - await self.device.set_tunable_white(relative_ct) - else: - # no color/brightness change requested, so just turn it on - await self.device.set_on() + kelvin = min(self._max_kelvin, max(self._min_kelvin, kelvin)) + + if self.device.supports_color_temperature: + await self.device.set_color_temperature(kelvin) + elif self.device.supports_tunable_white: + relative_ct = int( + 255 + * (kelvin - self._min_kelvin) + / (self._max_kelvin - self._min_kelvin) + ) + await self.device.set_tunable_white(relative_ct) async def async_turn_off(self, **kwargs): """Turn the light off.""" diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 78355937d15fbb..f326ba6037552f 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -668,20 +668,14 @@ async def async_volume_down(self): assert (await self.server.Input.ExecuteAction("volumedown")) == "OK" @cmd - def async_set_volume_level(self, volume): - """Set volume level, range 0..1. - - This method must be run in the event loop and returns a coroutine. - """ - return self.server.Application.SetVolume(int(volume * 100)) + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self.server.Application.SetVolume(int(volume * 100)) @cmd - def async_mute_volume(self, mute): - """Mute (true) or unmute (false) media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.server.Application.SetMute(mute) + async def async_mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + await self.server.Application.SetMute(mute) async def async_set_play_state(self, state): """Handle play/pause/toggle.""" @@ -691,28 +685,19 @@ async def async_set_play_state(self, state): await self.server.Player.PlayPause(players[0]["playerid"], state) @cmd - def async_media_play_pause(self): - """Pause media on media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_set_play_state("toggle") + async def async_media_play_pause(self): + """Pause media on media player.""" + await self.async_set_play_state("toggle") @cmd - def async_media_play(self): - """Play media. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_set_play_state(True) + async def async_media_play(self): + """Play media.""" + await self.async_set_play_state(True) @cmd - def async_media_pause(self): - """Pause the media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_set_play_state(False) + async def async_media_pause(self): + """Pause the media player.""" + await self.async_set_play_state(False) @cmd async def async_media_stop(self): @@ -735,20 +720,14 @@ async def _goto(self, direction): await self.server.Player.GoTo(players[0]["playerid"], direction) @cmd - def async_media_next_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self._goto("next") + async def async_media_next_track(self): + """Send next track command.""" + await self._goto("next") @cmd - def async_media_previous_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self._goto("previous") + async def async_media_previous_track(self): + """Send next track command.""" + await self._goto("previous") @cmd async def async_media_seek(self, position): @@ -772,21 +751,18 @@ async def async_media_seek(self, position): await self.server.Player.Seek(players[0]["playerid"], time) @cmd - def async_play_media(self, media_type, media_id, **kwargs): - """Send the play_media command to the media player. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the media player.""" if media_type == "CHANNEL": - return self.server.Player.Open({"item": {"channelid": int(media_id)}}) - if media_type == "PLAYLIST": - return self.server.Player.Open({"item": {"playlistid": int(media_id)}}) - if media_type == "DIRECTORY": - return self.server.Player.Open({"item": {"directory": str(media_id)}}) - if media_type == "PLUGIN": - return self.server.Player.Open({"item": {"file": str(media_id)}}) - - return self.server.Player.Open({"item": {"file": str(media_id)}}) + await self.server.Player.Open({"item": {"channelid": int(media_id)}}) + elif media_type == "PLAYLIST": + await self.server.Player.Open({"item": {"playlistid": int(media_id)}}) + elif media_type == "DIRECTORY": + await self.server.Player.Open({"item": {"directory": str(media_id)}}) + elif media_type == "PLUGIN": + await self.server.Player.Open({"item": {"file": str(media_id)}}) + else: + await self.server.Player.Open({"item": {"file": str(media_id)}}) async def async_set_shuffle(self, shuffle): """Set shuffle mode, for the first player.""" diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 6f370ffad98241..aa3fe0610a72c9 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -58,7 +58,7 @@ async def async_get_service(hass, config, discovery_info=None): _LOGGER.warning( "Kodi host name should no longer contain http:// See updated " "definitions here: " - "https://home-assistant.io/components/media_player.kodi/" + "https://www.home-assistant.io/integrations/media_player.kodi/" ) http_protocol = "https" if encryption else "http" diff --git a/homeassistant/components/konnected/.translations/ca.json b/homeassistant/components/konnected/.translations/ca.json new file mode 100644 index 00000000000000..ccb03ef7add709 --- /dev/null +++ b/homeassistant/components/konnected/.translations/ca.json @@ -0,0 +1,100 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de dades de configuraci\u00f3 pel dispositiu ja est\u00e0 en curs.", + "not_konn_panel": "No s'ha reconegut com a un dispositiu Konnected.io", + "unknown": "S'ha produ\u00eft un error desconegut" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar amb el panell Konnected a {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model} \nAmfitri\u00f3: {host} \nPort: {port} \n\nPots configurar el comportament de les E/S (I/O) i del panell a la configuraci\u00f3 del panell d\u2019alarma Konnected.", + "title": "Dispositiu Konnected llest" + }, + "user": { + "data": { + "host": "Adre\u00e7a IP del dispositiu Konnected", + "port": "Port del dispositiu Konnected" + }, + "description": "Introdueix la informaci\u00f3 d'amfitri\u00f3 del panell Konnected.", + "title": "Descoberta de dispositiu Konnected" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "No s'ha reconegut com a un dispositiu Konnected.io" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Inverteix l'estat obert/tancat", + "name": "Nom (opcional)", + "type": "Tipus de sensor binari" + }, + "description": "Selecciona les opcions pel sensor binari de {zone}", + "title": "Configuraci\u00f3 de sensor binari" + }, + "options_digital": { + "data": { + "name": "Nom (opcional)", + "poll_interval": "Interval de sondeig (minuts) (opcional)", + "type": "Tipus de sensor" + }, + "description": "Selecciona les opcions pel sensor digital de {zone}", + "title": "Configuraci\u00f3 de sensor digital" + }, + "options_io": { + "data": { + "1": "Zona 1", + "2": "Zona 2", + "3": "Zona 3", + "4": "Zona 4", + "5": "Zona 5", + "6": "Zona 6", + "7": "Zona 7", + "out": "OUT (sortida)" + }, + "description": "S'ha descobert {model} a {host}. Selecciona la configuraci\u00f3 b\u00e0sica de cada Entrada/Sortida (I/O), en funci\u00f3 del tipus que sigui pot ser que et permeti sensors binaris (contactes oberts/tancats), sensors digitals (DHT i ds18b20) o sortides commutables. M\u00e9s endavant les podr\u00e0s configurar de manera m\u00e9s detallada.", + "title": "Configuraci\u00f3 E/S (I/O)" + }, + "options_io_ext": { + "data": { + "10": "Zona 10", + "11": "Zona 11", + "12": "Zona 12", + "8": "Zona 8", + "9": "Zona 9", + "alarm1": "ALARMA1", + "alarm2_out2": "OUT2/ALARMA2", + "out1": "OUT1" + }, + "description": "Selecciona la configuraci\u00f3 de les E/S restants. Podr\u00e0s configurar opcions m\u00e9s detallades en els passos seg\u00fcents.", + "title": "Configuraci\u00f3 E/S (I/O) ampliades" + }, + "options_misc": { + "data": { + "blink": "Parpelleja el LED del panell quan s'envien canvis d'estat" + }, + "description": "Selecciona el comportament desitjat del panell", + "title": "Configuraci\u00f3 diversos" + }, + "options_switch": { + "data": { + "activation": "Sortida quan estigui ON", + "momentary": "Durada del pols (ms) (opcional)", + "name": "Nom (opcional)", + "pause": "Pausa entre polsos (ms) (opcional)", + "repeat": "Repeticions (-1 = infinit) (opcional)" + }, + "description": "Selecciona les opcions de sortida per a {zone}", + "title": "Configuraci\u00f3 de sortida commutable" + } + }, + "title": "Opcions del panell d'alarma Konnected" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/da.json b/homeassistant/components/konnected/.translations/da.json new file mode 100644 index 00000000000000..a1545bd657574f --- /dev/null +++ b/homeassistant/components/konnected/.translations/da.json @@ -0,0 +1,108 @@ +{ + "config": { + "abort": { + "already_configured": "Enheden er allerede konfigureret", + "already_in_progress": "Enhedskonfiguration er allerede i gang.", + "not_konn_panel": "Ikke en genkendt Konnected.io-enhed", + "unknown": "Ukendt fejl opstod" + }, + "error": { + "cannot_connect": "Der kan ikke oprettes forbindelse til et Konnected-panel p\u00e5 {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model}\nID: {id}\nV\u00e6rt: {host}\nPort: {port}\n\nDu kan konfigurere IO og panelfunktionsm\u00e5den i indstillingerne for Konnected-alarmpanel.", + "title": "Konnected-enhed klar" + }, + "import_confirm": { + "description": "Et Konnected-alarmpanel med id {id} er blevet fundet i configuration.yaml. Dette flow giver dig mulighed for at importere det til en konfigurationspost.", + "title": "Importer Konnected-enhed" + }, + "user": { + "data": { + "host": "Konnected-enhedens IP-adresse", + "port": "Konnected-enhedsport" + }, + "description": "Indtast v\u00e6rtsinformationen for dit Konnected-panel.", + "title": "Find Konnected-enhed" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Ikke en genkendt Konnected.io-enhed" + }, + "error": { + "one": "en", + "other": "anden" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Inverter tilstanden \u00e5ben/lukket", + "name": "Navn (valgfrit)", + "type": "Bin\u00e6r sensortype" + }, + "description": "V\u00e6lg indstillingerne for den bin\u00e6re sensor, der er knyttet til {zone}", + "title": "Konfigurer bin\u00e6r sensor" + }, + "options_digital": { + "data": { + "name": "Navn (valgfrit)", + "poll_interval": "Foresp\u00f8rgselsinterval (minutter) (valgfrit)", + "type": "Sensortype" + }, + "description": "V\u00e6lg indstillingerne for den digitale sensor, der er knyttet til {zone}", + "title": "Konfigurer digital sensor" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + }, + "description": "Der blev fundet en {model} p\u00e5 {host}. V\u00e6lg basiskonfigurationen af hver I/O nedenfor - afh\u00e6ngigt af I/O kan det give mulighed for bin\u00e6re sensorer (\u00e5ben-/lukket-kontakter), digitale sensorer (dht og ds18b20) eller omskiftelige outputs. Du kan konfigurere detaljerede indstillinger i de n\u00e6ste trin.", + "title": "Konfigurer I/O" + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "V\u00e6lg konfigurationen af det resterende I/O nedenfor. Du kan konfigurere detaljerede indstillinger i de n\u00e6ste trin.", + "title": "Konfigurer udvidet I/O" + }, + "options_misc": { + "data": { + "blink": "Blink panel-LED ved sending af tilstands\u00e6ndring" + }, + "description": "V\u00e6lg den \u00f8nskede funktionsm\u00e5de for panelet", + "title": "Konfigurer diverse" + }, + "options_switch": { + "data": { + "activation": "Output n\u00e5r der er t\u00e6ndt", + "momentary": "Impulsvarighed (ms) (valgfrit)", + "name": "Navn (valgfrit)", + "pause": "Pause mellem impulser (ms) (valgfrit)", + "repeat": "Gange til gentagelse (-1=uendelig) (valgfrit)" + }, + "description": "V\u00e6lg outputindstillingerne for {zone}", + "title": "Konfigurer skifteligt output" + } + }, + "title": "Indstillinger for Konnected-alarmpanel" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/de.json b/homeassistant/components/konnected/.translations/de.json new file mode 100644 index 00000000000000..fa5b1f53dfb89f --- /dev/null +++ b/homeassistant/components/konnected/.translations/de.json @@ -0,0 +1,95 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsfluss f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.", + "not_konn_panel": "Kein anerkanntes Konnected.io-Ger\u00e4t", + "unknown": "Unbekannter Fehler ist aufgetreten" + }, + "error": { + "cannot_connect": "Es konnte keine Verbindung zu einem Konnected-Panel unter {host}:{port} hergestellt werden." + }, + "step": { + "confirm": { + "description": "Modell: {model} \nHost: {host} \nPort: {port} \n\nSie k\u00f6nnen das I / O - und Bedienfeldverhalten in den Einstellungen der verbundenen Alarmzentrale konfigurieren.", + "title": "Konnected Device Bereit" + }, + "import_confirm": { + "title": "Importieren von Konnected Ger\u00e4t" + }, + "user": { + "data": { + "host": "Konnected Ger\u00e4t IP-Adresse", + "port": "Konnected Device Port" + }, + "description": "Bitte geben Sie die Hostinformationen f\u00fcr Ihr Konnected Panel ein.", + "title": "Entdecken Sie Konnected Ger\u00e4t" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Kein anerkanntes Konnected.io-Ger\u00e4t" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Invertieren Sie den \u00d6ffnungs- / Schlie\u00dfzustand", + "name": "Name (optional)", + "type": "Bin\u00e4rer Sensortyp" + }, + "description": "Bitte w\u00e4hlen Sie die Optionen f\u00fcr den an {zone} angeschlossenen Bin\u00e4rsensor", + "title": "Konfigurieren Sie den Bin\u00e4rsensor" + }, + "options_digital": { + "data": { + "name": "Name (optional)", + "poll_interval": "Abfrageintervall (Minuten) (optional)", + "type": "Sensortyp" + }, + "description": "Bitte w\u00e4hlen Sie die Optionen f\u00fcr den an {zone} angeschlossenen digitalen Sensor aus", + "title": "Konfigurieren Sie den digitalen Sensor" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + }, + "title": "Konfigurieren von I/O" + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + } + }, + "options_misc": { + "description": "Bitte w\u00e4hlen Sie das gew\u00fcnschte Verhalten f\u00fcr Ihr Panel" + }, + "options_switch": { + "data": { + "activation": "Ausgabe, wenn eingeschaltet", + "momentary": "Impulsdauer (ms) (optional)", + "name": "Name (optional)", + "pause": "Pause zwischen Impulsen (ms) (optional)", + "repeat": "Zeit zum Wiederholen (-1 = unendlich) (optional)" + }, + "description": "Bitte w\u00e4hlen Sie die Ausgabeoptionen f\u00fcr {zone}" + } + }, + "title": "Konnected Alarm Panel-Optionen" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/en.json b/homeassistant/components/konnected/.translations/en.json new file mode 100644 index 00000000000000..fd0a8e84e37eaa --- /dev/null +++ b/homeassistant/components/konnected/.translations/en.json @@ -0,0 +1,104 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "not_konn_panel": "Not a recognized Konnected.io device", + "unknown": "Unknown error occurred" + }, + "error": { + "cannot_connect": "Unable to connect to a Konnected Panel at {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings.", + "title": "Konnected Device Ready" + }, + "import_confirm": { + "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry.", + "title": "Import Konnected Device" + }, + "user": { + "data": { + "host": "Konnected device IP address", + "port": "Konnected device port" + }, + "description": "Please enter the host information for your Konnected Panel.", + "title": "Discover Konnected Device" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Not a recognized Konnected.io device" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Invert the open/close state", + "name": "Name (optional)", + "type": "Binary Sensor Type" + }, + "description": "Please select the options for the binary sensor attached to {zone}", + "title": "Configure Binary Sensor" + }, + "options_digital": { + "data": { + "name": "Name (optional)", + "poll_interval": "Poll Interval (minutes) (optional)", + "type": "Sensor Type" + }, + "description": "Please select the options for the digital sensor attached to {zone}", + "title": "Configure Digital Sensor" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + }, + "description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.", + "title": "Configure I/O" + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "title": "Configure Extended I/O" + }, + "options_misc": { + "data": { + "blink": "Blink panel LED on when sending state change" + }, + "description": "Please select the desired behavior for your panel", + "title": "Configure Misc" + }, + "options_switch": { + "data": { + "activation": "Output when on", + "momentary": "Pulse duration (ms) (optional)", + "name": "Name (optional)", + "pause": "Pause between pulses (ms) (optional)", + "repeat": "Times to repeat (-1=infinite) (optional)" + }, + "description": "Please select the output options for {zone}", + "title": "Configure Switchable Output" + } + }, + "title": "Konnected Alarm Panel Options" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/es.json b/homeassistant/components/konnected/.translations/es.json new file mode 100644 index 00000000000000..f72a58cf6495e8 --- /dev/null +++ b/homeassistant/components/konnected/.translations/es.json @@ -0,0 +1,104 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el dispositivo ya est\u00e1 en curso.", + "not_konn_panel": "No es un dispositivo Konnected.io reconocido", + "unknown": "Se produjo un error desconocido" + }, + "error": { + "cannot_connect": "No se puede conectar a un Panel conectado en {host}:{port}" + }, + "step": { + "confirm": { + "description": "Modelo: {model}\nHost: {host}\nPuerto: {port}\n\nPuede configurar las E/S y el comportamiento del panel en los ajustes del panel de alarmas Konnected.", + "title": "Dispositivo Konnected Listo" + }, + "user": { + "data": { + "host": "Direcci\u00f3n IP del dispositivo Konnected", + "port": "Puerto del dispositivo Konnected" + }, + "description": "Introduzca la informaci\u00f3n del host de su panel Konnected.", + "title": "Descubrir el dispositivo Konnected" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "No es un dispositivo Konnected.io reconocido" + }, + "error": { + "one": "", + "other": "otros" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Invertir el estado de apertura/cierre", + "name": "Nombre (opcional)", + "type": "Tipo de sensor binario" + }, + "description": "Seleccione las opciones para el sensor binario conectado a {zone}", + "title": "Configurar sensor binario" + }, + "options_digital": { + "data": { + "name": "Nombre (opcional)", + "poll_interval": "Intervalo de sondeo (minutos) (opcional)", + "type": "Tipo de sensor" + }, + "description": "Seleccione las opciones para el sensor digital conectado a {zone}", + "title": "Configurar el sensor digital" + }, + "options_io": { + "data": { + "1": "Zona 1", + "2": "Zona 2", + "3": "Zona 3", + "4": "Zona 4", + "5": "Zona 5", + "6": "Zona 6", + "7": "Zona 7", + "out": "OUT" + }, + "description": "Descubierto un {model} en {host} . Seleccione la configuraci\u00f3n base de cada E / S a continuaci\u00f3n: seg\u00fan la E / S, puede permitir sensores binarios (contactos de apertura / cierre), sensores digitales (dht y ds18b20) o salidas conmutables. Podr\u00e1 configurar opciones detalladas en los pr\u00f3ximos pasos.", + "title": "Configurar E/S" + }, + "options_io_ext": { + "data": { + "10": "Zona 10", + "11": "Zona 11", + "12": "Zona 12", + "8": "Zona 8", + "9": "Zona 9", + "alarm1": "ALARMA1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Seleccione la configuraci\u00f3n de las E/S restantes a continuaci\u00f3n. Podr\u00e1s configurar opciones detalladas en los pr\u00f3ximos pasos.", + "title": "Configurar E/S extendidas" + }, + "options_misc": { + "data": { + "blink": "Parpadea el LED del panel cuando se env\u00eda un cambio de estado" + }, + "description": "Seleccione el comportamiento deseado para su panel", + "title": "Configurar miscel\u00e1neos" + }, + "options_switch": { + "data": { + "activation": "Salida cuando est\u00e1 activada", + "momentary": "Duraci\u00f3n del pulso (ms) (opcional)", + "name": "Nombre (opcional)", + "pause": "Pausa entre pulsos (ms) (opcional)", + "repeat": "Tiempos de repetici\u00f3n (-1 = infinito) (opcional)" + }, + "description": "Por favor, seleccione las opciones de salida para {zone}", + "title": "Configurar la salida conmutable" + } + }, + "title": "Opciones del panel de alarma Konnected" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/fr.json b/homeassistant/components/konnected/.translations/fr.json new file mode 100644 index 00000000000000..e6c0cded9fca7d --- /dev/null +++ b/homeassistant/components/konnected/.translations/fr.json @@ -0,0 +1,72 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "not_konn_panel": "Non reconnu comme appareil Konnected.io", + "unknown": "Une erreur inconnue s'est produite" + }, + "step": { + "confirm": { + "title": "Appareil Konnected pr\u00eat" + }, + "user": { + "data": { + "host": "Adresse IP de l\u2019appareil Konnected" + } + } + }, + "title": "Konnected.io" + }, + "options": { + "error": { + "one": "Vide", + "other": "Vide" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Inverser l'\u00e9tat ouvert / ferm\u00e9", + "name": "Nom (facultatif)", + "type": "Type de capteur binaire" + }, + "description": "Veuillez s\u00e9lectionner les options du capteur binaire attach\u00e9 \u00e0 {zone}", + "title": "Configurer le capteur binaire" + }, + "options_digital": { + "data": { + "name": "Nom (facultatif)", + "poll_interval": "Intervalle d'interrogation (minutes) (facultatif)", + "type": "Type de capteur" + }, + "description": "Veuillez s\u00e9lectionner les options du capteur digital attach\u00e9 \u00e0 {zone}", + "title": "Configurer le capteur digital" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7" + } + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9", + "alarm1": "ALARME1" + } + }, + "options_switch": { + "data": { + "name": "Nom (facultatif)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/hu.json b/homeassistant/components/konnected/.translations/hu.json new file mode 100644 index 00000000000000..35a4adfebe3a1c --- /dev/null +++ b/homeassistant/components/konnected/.translations/hu.json @@ -0,0 +1,17 @@ +{ + "options": { + "step": { + "options_digital": { + "data": { + "name": "N\u00e9v (nem k\u00f6telez\u0151)", + "type": "\u00c9rz\u00e9kel\u0151 t\u00edpusa" + } + }, + "options_switch": { + "data": { + "name": "N\u00e9v (nem k\u00f6telez\u0151)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/it.json b/homeassistant/components/konnected/.translations/it.json new file mode 100644 index 00000000000000..fb18ece10f89c8 --- /dev/null +++ b/homeassistant/components/konnected/.translations/it.json @@ -0,0 +1,104 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", + "not_konn_panel": "Non \u00e8 un dispositivo Konnected.io riconosciuto", + "unknown": "Si \u00e8 verificato un errore sconosciuto" + }, + "error": { + "cannot_connect": "Impossibile connettersi ad un Pannello Konnected su {host}:{port}." + }, + "step": { + "confirm": { + "description": "Modello: {model}\nHost: {host}\nPorta: {port}\n\n\u00c8 possibile configurare il comportamento di I/O e del pannello nelle impostazioni del Pannello Allarmi di Konnected.", + "title": "Dispositivo Konnected pronto" + }, + "user": { + "data": { + "host": "Indirizzo IP del dispositivo Konnected", + "port": "Porta del dispositivo Konnected" + }, + "description": "Si prega di inserire le informazioni dell'host per il tuo Pannello Konnected.", + "title": "Rileva il dispositivo Konnected" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Non \u00e8 un dispositivo Konnected.io riconosciuto" + }, + "error": { + "one": "uno", + "other": "altro" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Invertire lo stato di apertura/chiusura", + "name": "Nome (opzionale)", + "type": "Tipo di sensore binario" + }, + "description": "Si prega di selezionare le opzioni per il sensore binario collegato alla {zone}", + "title": "Configurare il Sensore Binario" + }, + "options_digital": { + "data": { + "name": "Nome (opzionale)", + "poll_interval": "Intervallo di sondaggio (minuti) (opzionale)", + "type": "Tipo di sensore" + }, + "description": "Si prega di selezionare le opzioni per il sensore digitale collegato alla {zone}", + "title": "Configurare il Sensore Digitale" + }, + "options_io": { + "data": { + "1": "Zona 1", + "2": "Zona 2", + "3": "Zona 3", + "4": "Zona 4", + "5": "Zona 5", + "6": "Zona 6", + "7": "Zona 7", + "out": "OUT" + }, + "description": "Rilevato un {model} su {host}. Selezionare la configurazione di base di ciascun I/O di seguito: a seconda dell'I/O, essa pu\u00f2 consentire sensori binari (contatti aperti/chiusi), sensori digitali (DHT e DS18B20) o uscite commutabili. Sarai in grado di configurare le opzioni dettagliate nei prossimi passi.", + "title": "Configura I/O" + }, + "options_io_ext": { + "data": { + "10": "Zona 10", + "11": "Zona 11", + "12": "Zona 12", + "8": "Zona 8", + "9": "Zona 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2 / ALARM2", + "out1": "OUT1" + }, + "description": "Selezionare di seguito la configurazione degli I/O rimanenti. Potrete configurare opzioni dettagliate nei prossimi passi.", + "title": "Configurazione I/O Esteso" + }, + "options_misc": { + "data": { + "blink": "Attiva il lampeggio del LED del pannello durante l'invio del cambiamento di stato " + }, + "description": "Seleziona il comportamento desiderato per il tuo pannello", + "title": "Configura Altro" + }, + "options_switch": { + "data": { + "activation": "Uscita quando acceso", + "momentary": "Durata impulso (ms) (opzionale)", + "name": "Nome (opzionale)", + "pause": "Pausa tra gli impulsi (ms) (opzionale)", + "repeat": "Numero di volte da ripetere (-1 = infinito) (opzionale)" + }, + "description": "Selezionare le opzioni di uscita per {zona}", + "title": "Configurare l'uscita commutabile" + } + }, + "title": "Opzioni del pannello di allarme Konnected" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/ko.json b/homeassistant/components/konnected/.translations/ko.json new file mode 100644 index 00000000000000..fe19605076668b --- /dev/null +++ b/homeassistant/components/konnected/.translations/ko.json @@ -0,0 +1,104 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", + "not_konn_panel": "\uc778\uc2dd\ub41c Konnected.io \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4", + "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "{host}:{port} \uc758 Konnected \ud328\ub110\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "\ubaa8\ub378: {model}\n\ud638\uc2a4\ud2b8: {host}\n\ud3ec\ud2b8: {port}\n\nKonnected \uc54c\ub78c \ud328\ub110 \uc124\uc815\uc5d0\uc11c IO \uc640 \ud328\ub110 \ub3d9\uc791\uc744 \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "Konnected \uae30\uae30 \uc900\ube44" + }, + "import_confirm": { + "description": "Konnected \uc54c\ub78c \ud328\ub110 ID {id} \uac00 configuration.yaml \uc5d0\uc11c \ubc1c\uacac\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc774 \uacfc\uc815\uc744 \ud1b5\ud574 \uad6c\uc131 \ud56d\ubaa9\uc73c\ub85c \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "Konnected \uae30\uae30 \uac00\uc838\uc624\uae30" + }, + "user": { + "data": { + "host": "Konnected \uae30\uae30 IP \uc8fc\uc18c", + "port": "Konnected \uae30\uae30 \ud3ec\ud2b8" + }, + "description": "Konnected \ud328\ub110\uc758 \ud638\uc2a4\ud2b8 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "Konnected \uae30\uae30 \ucc3e\uae30" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "\uc778\uc2dd\ub41c Konnected.io \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4" + }, + "step": { + "options_binary": { + "data": { + "inverse": "\uc5f4\ub9bc / \ub2eb\ud798 \uc0c1\ud0dc \ubc18\uc804", + "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", + "type": "\uc774\uc9c4 \uc13c\uc11c \uc720\ud615" + }, + "description": "{zone} \uc5d0 \uc5f0\uacb0\ub41c \uc774\uc9c4 \uc13c\uc11c\uc5d0 \ub300\ud55c \uc635\uc158\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "\uc774\uc9c4 \uc13c\uc11c \uad6c\uc131" + }, + "options_digital": { + "data": { + "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", + "poll_interval": "\ud3f4\ub9c1 \uac04\uaca9 (\ubd84) (\uc120\ud0dd \uc0ac\ud56d)", + "type": "\uc13c\uc11c \uc720\ud615" + }, + "description": "{zone} \uc5d0 \uc5f0\uacb0\ub41c \ub514\uc9c0\ud138 \uc13c\uc11c\uc5d0 \ub300\ud55c \uc635\uc158\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "\ub514\uc9c0\ud138 \uc13c\uc11c \uad6c\uc131" + }, + "options_io": { + "data": { + "1": "\uad6c\uc5ed 1", + "2": "\uad6c\uc5ed 2", + "3": "\uad6c\uc5ed 3", + "4": "\uad6c\uc5ed 4", + "5": "\uad6c\uc5ed 5", + "6": "\uad6c\uc5ed 6", + "7": "\uad6c\uc5ed 7", + "out": "\uc678\ubd80" + }, + "description": "{host} \uc5d0\uc11c {model} \uc744(\ub97c) \ubc1c\uacac\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc9c4 \uc13c\uc11c(\uac1c\ud3d0 \uc811\uc810), \ub514\uc9c0\ud138 \uc13c\uc11c(dht \ubc0f ds18b20) \ub610\ub294 \uc2a4\uc704\uce58\uac00 \uac00\ub2a5\ud55c \uc13c\uc11c\uc758 I/O \uc5d0 \ub530\ub77c \uc544\ub798\uc5d0\uc11c \uac01 I/O \uc758 \uae30\ubcf8 \uad6c\uc131\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \ub2e4\uc74c \ub2e8\uacc4\uc5d0\uc11c \uc138\ubd80 \uc635\uc158\uc744 \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "I/O \uad6c\uc131" + }, + "options_io_ext": { + "data": { + "10": "\uad6c\uc5ed 10", + "11": "\uad6c\uc5ed 11", + "12": "\uad6c\uc5ed 12", + "8": "\uad6c\uc5ed 8", + "9": "\uad6c\uc5ed 9", + "alarm1": "\uc54c\ub78c 1", + "alarm2_out2": "\ucd9c\ub825 2 / \uc54c\ub78c 2", + "out1": "\ucd9c\ub825 1" + }, + "description": "\uc544\ub798\uc758 \ub098\uba38\uc9c0 I/O \uad6c\uc131\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \ub2e4\uc74c \ub2e8\uacc4\uc5d0\uc11c \uc138\ubd80 \uc635\uc158\uc744 \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "\ud655\uc7a5 I/O \uad6c\uc131" + }, + "options_misc": { + "data": { + "blink": "\uc0c1\ud0dc \ubcc0\uacbd\uc744 \ubcf4\ub0bc \ub54c \uae5c\ubc15\uc784 \ud328\ub110 LED \ub97c \ucf2d\ub2c8\ub2e4" + }, + "description": "\ud328\ub110\uc5d0 \uc6d0\ud558\ub294 \ub3d9\uc791\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "\uae30\ud0c0 \uad6c\uc131" + }, + "options_switch": { + "data": { + "activation": "\uc2a4\uc704\uce58\uac00 \ucf1c\uc9c8 \ub54c \ucd9c\ub825", + "momentary": "\ud384\uc2a4 \uc9c0\uc18d\uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)", + "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", + "pause": "\ud384\uc2a4 \uac04 \uc77c\uc2dc\uc815\uc9c0 \uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)", + "repeat": "\ubc18\ubcf5 \uc2dc\uac04 (-1 = \ubb34\ud55c) (\uc120\ud0dd \uc0ac\ud56d)" + }, + "description": "{zone} \ub300\ud55c \ucd9c\ub825 \uc635\uc158\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "\uc2a4\uc704\uce58 \ucd9c\ub825 \uad6c\uc131" + } + }, + "title": "Konnected \uc54c\ub78c \ud328\ub110 \uc635\uc158" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/lb.json b/homeassistant/components/konnected/.translations/lb.json new file mode 100644 index 00000000000000..2e37ecb8e92381 --- /dev/null +++ b/homeassistant/components/konnected/.translations/lb.json @@ -0,0 +1,104 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun's Oflaf fir den Apparat ass schonn am gaangen.", + "not_konn_panel": "Keen erkannten Konnected.io Apparat", + "unknown": "Onbekannten Fehler opgetrueden" + }, + "error": { + "cannot_connect": "Kann sech net mam Konnected Panel um {host}:{port} verbannen" + }, + "step": { + "confirm": { + "description": "Modell: {model}\nHost: {host}\nPort: {port}\n\nDir k\u00ebnnt den I/O a Panel Verhaalen an de Konnected Alarm Panel Astellunge konfigur\u00e9ieren.", + "title": "Konnected Apparat parat" + }, + "user": { + "data": { + "host": "Konnected Apparat IP Adress", + "port": "Konnected Apparat Port" + }, + "description": "Informatioune vum Konnected Panel aginn.", + "title": "Konnected Apparat entdecken" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Keen erkannten Konnected.io Apparat" + }, + "error": { + "one": "Ee", + "other": "M\u00e9i" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Op/Zou Zoustand vertauschen", + "name": "Numm (optional)", + "type": "Typ vun Bin\u00e4re Sensor" + }, + "description": "Wiel d'Optioune fir den bin\u00e4ren Sensor dee mat {zone} verbonnen ass", + "title": "Bin\u00e4re Sensor konfigur\u00e9ieren" + }, + "options_digital": { + "data": { + "name": "Numm (optional)", + "poll_interval": "Intervall vun den Offroen (Minutten) (optional)", + "type": "Typ vum Sensor" + }, + "description": "Wiel d'Optioune fir den digitale Sensor dee mat {zone} verbonnen ass", + "title": "Digitale Sensor konfigur\u00e9ieren" + }, + "options_io": { + "data": { + "1": "Zon 2", + "2": "Zon 1", + "3": "Zon 3", + "4": "Zon 4", + "5": "Zon 5", + "6": "Zon 6", + "7": "Zon 7", + "out": "OUT" + }, + "description": "{model} um {host} entdeckt.\u00a0Wiel Basis Konfiguratioun vun den I/O hei dr\u00ebnner aus - ofh\u00e4ngeg vum I/O erlaabt et bin\u00e4r Sensoren (op / zou Kontakter), digital Sensoren (dht an ds18b20) oder schaltbar Ausgab. D\u00e9i detaill\u00e9iert Optioune k\u00ebnnen en an den n\u00e4chste Schr\u00ebtt konfigur\u00e9iert ginn.", + "title": "I/O konfigur\u00e9ieren" + }, + "options_io_ext": { + "data": { + "10": "Zon 10", + "11": "Zon 11", + "12": "Zon 12", + "8": "Zon 8", + "9": "Zon 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Wiel d'Konfiguratioun vun de verbleiwenden I/O hei dr\u00ebnner. D\u00e9i detaill\u00e9iert Optioune k\u00ebnnen en an den n\u00e4chste Schr\u00ebtt konfigur\u00e9iert ginn.", + "title": "Erweiderten I/O konfigur\u00e9ieren" + }, + "options_misc": { + "data": { + "blink": "Blink panel LED un wann Status \u00c4nnerung gesch\u00e9ckt g\u00ebtt" + }, + "description": "Wielt w.e.g. dat gew\u00ebnschte Verhalen fir \u00c4re Panel aus", + "title": "Divers Optioune astellen" + }, + "options_switch": { + "data": { + "activation": "Ausgang wann un", + "momentary": "Pulsatiounsdauer (ms) (optional)", + "name": "Numm (optional)", + "pause": "Pausen zw\u00ebscht den Impulser (ms) (optional)", + "repeat": "Unzuel vu Widderhuelungen (-1= onendlech) (optional)" + }, + "description": "Wielt w.e.g. d'Ausgaboptiounen fir {zone}", + "title": "\u00cbmschltbaren Ausgang konfigur\u00e9ieren" + } + }, + "title": "Konnected Alarm Panneau Optiounen" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/nl.json b/homeassistant/components/konnected/.translations/nl.json new file mode 100644 index 00000000000000..1b6242b37f4999 --- /dev/null +++ b/homeassistant/components/konnected/.translations/nl.json @@ -0,0 +1,74 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "unknown": "Onbekende fout opgetreden" + }, + "step": { + "confirm": { + "title": "Konnected Apparaat Klaar" + }, + "user": { + "data": { + "host": "IP-adres van Konnected apparaat", + "port": "Konnected apparaat poort" + }, + "description": "Voer de host-informatie in voor uw Konnected-paneel.", + "title": "Ontdek Konnected Device" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Geen herkend Konnected.io apparaat" + }, + "error": { + "one": "Leeg", + "other": "Leeg" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Keer de open / dicht status om", + "name": "Naam (optioneel)", + "type": "Type binaire sensor" + }, + "title": "Binaire sensor configureren" + }, + "options_digital": { + "data": { + "name": "Naam (optioneel)", + "type": "Type sensor" + } + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7" + } + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9" + } + }, + "options_switch": { + "data": { + "name": "Naam (optioneel)" + }, + "title": "Schakelbare uitgang configureren" + } + }, + "title": "Konnected Alarm Paneel Opties" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/no.json b/homeassistant/components/konnected/.translations/no.json new file mode 100644 index 00000000000000..569dac5756fe67 --- /dev/null +++ b/homeassistant/components/konnected/.translations/no.json @@ -0,0 +1,100 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", + "not_konn_panel": "Ikke en anerkjent Konnected.io-enhet", + "unknown": "Ukjent feil oppstod" + }, + "error": { + "cannot_connect": "Kan ikke koble til et Konnected Panel p\u00e5 {host} : {port}" + }, + "step": { + "confirm": { + "description": "Modell: {model}\nVert: {host}\nPort: {port}\n\nDu kan konfigurere IO og panel atferd i Konnected Alarm Panel innstillinger.", + "title": "Konnected Enhet klar" + }, + "user": { + "data": { + "host": "Konnected enhet IP-adresse", + "port": "Koblet enhetsport" + }, + "description": "Vennligst skriv inn verten informasjon for din Konnected Panel.", + "title": "Oppdag Konnected Enheten" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Ikke en anerkjent Konnected.io-enhet" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Inverter \u00e5pen / lukk tilstand", + "name": "Navn (valgfritt)", + "type": "Bin\u00e6r sensortype" + }, + "description": "Vennligst velg alternativer for bin\u00e6re sensor koblet til {sone}", + "title": "Konfigurer bin\u00e6r sensor" + }, + "options_digital": { + "data": { + "name": "Navn (valgfritt)", + "poll_interval": "Avstemningsintervall (minutter) (valgfritt)", + "type": "Sensortype" + }, + "description": "Vennligst velg alternativene for den digitale sensor som er koblet til {sone}", + "title": "Konfigurere Digital Sensor" + }, + "options_io": { + "data": { + "1": "Sone 1", + "2": "Sone 2", + "3": "Sone 3", + "4": "Sone 4", + "5": "Sone 5", + "6": "Sone 6", + "7": "Sone 7", + "out": "OUT" + }, + "description": "Oppdaget en {model} hos {host} . Velg basiskonfigurasjon for hver I / O nedenfor - avhengig av I / O kan det gi rom for bin\u00e6re sensorer (\u00e5pne / lukke kontakter), digitale sensorer (dht og ds18b20), eller switchbare utganger. Du vil kunne konfigurere detaljerte alternativer i de neste trinnene.", + "title": "Konfigurere I/O" + }, + "options_io_ext": { + "data": { + "10": "Sone 10", + "11": "Sone 11", + "12": "Sone 12", + "8": "Sone 8", + "9": "Sone 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Velg konfigurasjonen av de gjenv\u00e6rende I/O nedenfor. Du vil v\u00e6re i stand til \u00e5 konfigurere detaljerte alternativer i de neste trinnene.", + "title": "Konfigurer utvidet I / O" + }, + "options_misc": { + "data": { + "blink": "Blink p\u00e5 LED-lampen n\u00e5r du sender statusendring" + }, + "description": "Vennligst velg \u00f8nsket atferd for din panel", + "title": "Konfigurere Diverse" + }, + "options_switch": { + "data": { + "activation": "Utgang n\u00e5r den er p\u00e5", + "momentary": "Pulsvarighet (ms) (valgfritt)", + "name": "Navn (valgfritt)", + "pause": "Pause mellom pulser (ms) (valgfritt)", + "repeat": "Tider \u00e5 gjenta (-1 = uendelig) (valgfritt)" + }, + "description": "Velg outputalternativer for {zone}", + "title": "Konfigurere Valgbare Utgang" + } + }, + "title": "Alternativer for Konnected Alarm Panel" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/pl.json b/homeassistant/components/konnected/.translations/pl.json new file mode 100644 index 00000000000000..b0d721891c0fa4 --- /dev/null +++ b/homeassistant/components/konnected/.translations/pl.json @@ -0,0 +1,106 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "already_in_progress": "Konfiguracja urz\u0105dzenia jest ju\u017c w toku.", + "not_konn_panel": "Nie rozpoznano urz\u0105dzenia Konnected.io", + "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z panelem Konnected na {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model} \nHost: {host} \nPort: {port} \n\nMo\u017cesz skonfigurowa\u0107 IO i zachowanie panelu w ustawieniach Konnected Alarm Panel.", + "title": "Urz\u0105dzenie Konnected gotowe" + }, + "user": { + "data": { + "host": "Adres IP urz\u0105dzenia Konnected", + "port": "Port urz\u0105dzenia Konnected urz\u0105dzenia" + }, + "description": "Wprowad\u017a informacje o ho\u015bcie panelu Konnected.", + "title": "Wykryj urz\u0105dzenie Konnected" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Nie rozpoznano urz\u0105dzenia Konnected.io" + }, + "error": { + "few": "kilka", + "many": "wiele", + "one": "jeden", + "other": "inny" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Odwr\u00f3\u0107 stan otwarty/zamkni\u0119ty", + "name": "Nazwa (opcjonalnie)", + "type": "Typ sensora binarnego" + }, + "description": "Wybierz opcje dla sensora binarnego powi\u0105zanego ze {zone}", + "title": "Konfiguracja sensora binarnego" + }, + "options_digital": { + "data": { + "name": "Nazwa (opcjonalnie)", + "poll_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji (minuty) (opcjonalnie)", + "type": "Typ sensora" + }, + "description": "Wybierz opcje dla cyfrowego sensora powi\u0105zanego ze {zone}", + "title": "Konfiguracja sensora cyfrowego" + }, + "options_io": { + "data": { + "1": "Strefa 1", + "2": "Strefa 2", + "3": "Strefa 3", + "4": "Strefa 4", + "5": "Strefa 5", + "6": "Strefa 6", + "7": "Strefa 7", + "out": "OUT" + }, + "description": "Wykryto {model} na ho\u015bcie {host}. Wybierz podstawow\u0105 konfiguracj\u0119 ka\u017cdego wej\u015bcia/wyj\u015bcia poni\u017cej \u2014 w zale\u017cno\u015bci od typu wej\u015b\u0107/wyj\u015b\u0107 mo\u017ce zastosowa\u0107 sensory binarne (otwarte/ amkni\u0119te), sensory cyfrowe (dht i ds18b20) lub prze\u0142\u0105czane wyj\u015bcia. B\u0119dziesz m\u00f3g\u0142 skonfigurowa\u0107 szczeg\u00f3\u0142owe opcje w kolejnych krokach.", + "title": "Konfiguracja wej\u015bcia/wyj\u015bcia" + }, + "options_io_ext": { + "data": { + "10": "Strefa 10", + "11": "Strefa 11", + "12": "Strefa 12", + "8": "Strefa 8", + "9": "Strefa 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Wybierz konfiguracj\u0119 pozosta\u0142ych wej\u015b\u0107/wyj\u015b\u0107 poni\u017cej. B\u0119dziesz m\u00f3g\u0142 skonfigurowa\u0107 szczeg\u00f3\u0142owe opcje w kolejnych krokach.", + "title": "Konfiguracja rozszerzonego wej\u015bcia/wyj\u015bcia" + }, + "options_misc": { + "data": { + "blink": "Miganie diody LED panelu podczas wysy\u0142ania zmiany stanu" + }, + "description": "Wybierz po\u017c\u0105dane zachowanie dla swojego panelu", + "title": "R\u00f3\u017cne opcje" + }, + "options_switch": { + "data": { + "activation": "Wyj\u015bcie, gdy w\u0142\u0105czone", + "momentary": "Czas trwania impulsu (ms) (opcjonalnie)", + "name": "Nazwa (opcjonalnie)", + "pause": "Przerwa mi\u0119dzy impulsami (ms) (opcjonalnie)", + "repeat": "Ilo\u015b\u0107 powt\u00f3rze\u0144 (-1=niesko\u0144czenie) (opcjonalnie)" + }, + "description": "Wybierz opcje wyj\u015bcia dla {zone}", + "title": "Konfiguracja prze\u0142\u0105czalne wyj\u015bcie" + } + }, + "title": "Opcje panelu alarmu Konnected" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/ru.json b/homeassistant/components/konnected/.translations/ru.json new file mode 100644 index 00000000000000..25cb03b1578887 --- /dev/null +++ b/homeassistant/components/konnected/.translations/ru.json @@ -0,0 +1,100 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "not_konn_panel": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected.io \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u043e.", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u0430\u043d\u0435\u043b\u0438 Konnected {host}:{port}." + }, + "step": { + "confirm": { + "description": "\u041c\u043e\u0434\u0435\u043b\u044c: {model}\n\u0425\u043e\u0441\u0442: {host}\n\u041f\u043e\u0440\u0442: {port}\n\n\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043b\u043e\u0433\u0438\u043a\u0438 \u0440\u0430\u0431\u043e\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u0438, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u0445\u043e\u0434\u043e\u0432 \u0438 \u0432\u044b\u0445\u043e\u0434\u043e\u0432 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u043f\u0430\u043d\u0435\u043b\u0438 \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected.", + "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected \u0433\u043e\u0442\u043e\u0432\u043e \u043a \u0440\u0430\u0431\u043e\u0442\u0435." + }, + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u043f\u0430\u043d\u0435\u043b\u0438 Konnected.", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected.io \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u043e." + }, + "step": { + "options_binary": { + "data": { + "inverse": "\u0418\u043d\u0432\u0435\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u0435/\u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "type": "\u0422\u0438\u043f \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043d\u0441\u043e\u0440\u0430" + }, + "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043d\u0441\u043e\u0440\u0430, \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043a {zone}.", + "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043d\u0441\u043e\u0440\u0430" + }, + "options_digital": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "poll_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0440\u043e\u0441\u0430 \u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "type": "\u0422\u0438\u043f \u0441\u0435\u043d\u0441\u043e\u0440\u0430" + }, + "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0441\u0435\u043d\u0441\u043e\u0440\u0430, \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043a {zone}.", + "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0441\u0435\u043d\u0441\u043e\u0440\u0430" + }, + "options_io": { + "data": { + "1": "\u0417\u043e\u043d\u0430 1", + "2": "\u0417\u043e\u043d\u0430 2", + "3": "\u0417\u043e\u043d\u0430 3", + "4": "\u0417\u043e\u043d\u0430 4", + "5": "\u0417\u043e\u043d\u0430 5", + "6": "\u0417\u043e\u043d\u0430 6", + "7": "\u0417\u043e\u043d\u0430 7", + "out": "\u0412\u042b\u0425\u041e\u0414" + }, + "description": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e {model} \u0441 \u0430\u0434\u0440\u0435\u0441\u043e\u043c {host}. \u0412 \u0437\u0430\u0432\u0438\u0441\u0438\u043c\u043e\u0441\u0442\u0438 \u043e\u0442 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432, \u043a \u043f\u0430\u043d\u0435\u043b\u0438 \u043c\u043e\u0433\u0443\u0442 \u0431\u044b\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b (\u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f/\u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f), \u0446\u0438\u0444\u0440\u043e\u0432\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b (dht \u0438 ds18b20) \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0435\u043c\u044b\u0435 \u0432\u044b\u0445\u043e\u0434\u044b. \u0411\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u043d\u0430 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0445 \u0448\u0430\u0433\u0430\u0445.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432" + }, + "options_io_ext": { + "data": { + "10": "\u0417\u043e\u043d\u0430 10", + "11": "\u0417\u043e\u043d\u0430 11", + "12": "\u0417\u043e\u043d\u0430 12", + "8": "\u0417\u043e\u043d\u0430 8", + "9": "\u0417\u043e\u043d\u0430 9", + "alarm1": "\u0422\u0420\u0415\u0412\u041e\u0413\u04101", + "alarm2_out2": "\u0412\u042b\u0425\u041e\u04142/\u0422\u0420\u0415\u0412\u041e\u0413\u04102", + "out1": "\u0412\u042b\u0425\u041e\u04141" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u043e\u0441\u0442\u0430\u0432\u0448\u0438\u0445\u0441\u044f \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432. \u0411\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u043d\u0430 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0445 \u0448\u0430\u0433\u0430\u0445.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0445 \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432" + }, + "options_misc": { + "data": { + "blink": "LED-\u0438\u043d\u0434\u0438\u043a\u0430\u0446\u0438\u044f \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 \u043f\u0440\u0438 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0436\u0435\u043b\u0430\u0435\u043c\u043e\u0435 \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u0434\u043b\u044f \u0412\u0430\u0448\u0435\u0439 \u043f\u0430\u043d\u0435\u043b\u0438.", + "title": "\u041f\u0440\u043e\u0447\u0438\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438" + }, + "options_switch": { + "data": { + "activation": "\u0412\u044b\u0445\u043e\u0434 \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", + "momentary": "\u0414\u043b\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u0438\u043c\u043f\u0443\u043b\u044c\u0441\u0430 (\u043c\u0441) (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "pause": "\u041f\u0430\u0443\u0437\u0430 \u043c\u0435\u0436\u0434\u0443 \u0438\u043c\u043f\u0443\u043b\u044c\u0441\u0430\u043c\u0438 (\u043c\u0441) (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "repeat": "\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u0435\u043d\u0438\u0439 (-1 = \u0431\u0435\u0441\u043a\u043e\u043d\u0435\u0447\u043d\u043e) (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0432\u044b\u0445\u043e\u0434\u0430 \u0434\u043b\u044f {zone}.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0435\u043c\u043e\u0433\u043e \u0432\u044b\u0445\u043e\u0434\u0430" + } + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0430\u043d\u0435\u043b\u0438 \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/sl.json b/homeassistant/components/konnected/.translations/sl.json new file mode 100644 index 00000000000000..38396d0832db8a --- /dev/null +++ b/homeassistant/components/konnected/.translations/sl.json @@ -0,0 +1,106 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "already_in_progress": "Konfiguracijski tok za napravo je \u017ee v teku.", + "not_konn_panel": "Ni prepoznana kot Konnected.io naprava", + "unknown": "Pri\u0161lo je do neznane napake" + }, + "error": { + "cannot_connect": "Ni mogo\u010de vzpostaviti povezave s Konnected plo\u0161\u010do v {Host}: {Port}" + }, + "step": { + "confirm": { + "description": "Model: {model}\nGostitelj: {host}\nVrata: {port}\n\nV nastavitvah lahko nastavite vedenje I / O in plo\u0161\u010de Konnected alarma. ", + "title": "Konnected naprava pripravljena" + }, + "user": { + "data": { + "host": "IP-naslov Konnected naprave", + "port": "Vrata Konnected naprave" + }, + "description": "Vnesite podatke o gostitelju v svoj Konnected Panel.", + "title": "Odkrijte Konnected napravo" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Ni prepoznana kot Konnected.io naprava" + }, + "error": { + "few": "nekaj", + "one": "ena", + "other": "drugo", + "two": "dva" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Zamenjajte odprto / zaprto stanje", + "name": "Ime (neobvezno)", + "type": "Vrsta binarnega senzorja" + }, + "description": "Izberite mo\u017enosti za binarni senzor, priklju\u010den na {zone}", + "title": "Konfigurirajte binarni senzor" + }, + "options_digital": { + "data": { + "name": "Ime (neobvezno)", + "poll_interval": "Interval osve\u017eevanja (minute) (neobvezno)", + "type": "Vrsta tipala" + }, + "description": "Izberite mo\u017enosti za digitalni senzor, priklju\u010den na {zone}", + "title": "Konfigurirajte digitalni senzor" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + }, + "description": "Odkrili {model} na {host} . Spodaj izberite osnovno konfiguracijo vsakega I / O - odvisno od I / O lahko omogo\u010da binarne senzorje (odpiranje / zapiranje kontaktov), digitalne senzorje (dht in ds18b20) ali preklopne izhode. Podrobne mo\u017enosti boste lahko konfigurirali v naslednjih korakih.", + "title": "Konfigurirajte I / O" + }, + "options_io_ext": { + "data": { + "10": "Obmo\u010dje 10", + "11": "Obmo\u010dje 11", + "12": "Obmo\u010dje 12", + "8": "Obmo\u010dje 8", + "9": "Obmo\u010dje 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2 / ALARM2", + "out1": "OUT1" + }, + "description": "Izberite konfiguracijo preostalega I/O spodaj. Podrobne mo\u017enosti boste lahko konfigurirali v naslednjih korakih.", + "title": "Konfigurirajte raz\u0161irjeni I/O" + }, + "options_misc": { + "data": { + "blink": "Lu\u010dka LED na zaslonu utripa, ko po\u0161iljate spremembo stanja" + }, + "description": "Izberite \u017eeleno vedenje za va\u0161o plo\u0161\u010do", + "title": "Konfigurirajte Razno" + }, + "options_switch": { + "data": { + "activation": "Izhod, ko je vklopljen", + "momentary": "Trajanje impulza (ms) (neobvezno)", + "name": "Ime (neobvezno)", + "pause": "Premor med impulzi (ms) (neobvezno)", + "repeat": "\u010casi ponovitve (-1 = neskon\u010dno) (neobvezno)" + }, + "description": "Izberite izhodne mo\u017enosti za {zone}", + "title": "Konfigurirajte preklopni izhod" + } + }, + "title": "Mo\u017enosti Konnected alarm-a" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/sv.json b/homeassistant/components/konnected/.translations/sv.json new file mode 100644 index 00000000000000..7e035264215709 --- /dev/null +++ b/homeassistant/components/konnected/.translations/sv.json @@ -0,0 +1,104 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurationsfl\u00f6de f\u00f6r enhet p\u00e5g\u00e5r redan.", + "not_konn_panel": "Inte en erk\u00e4nd Konnected.io-enhet", + "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat" + }, + "error": { + "cannot_connect": "Det g\u00e5r inte att ansluta till en ansluten panel p\u00e5 {host}:{port}" + }, + "step": { + "confirm": { + "description": "Modell: {modell}\nV\u00e4rd: {host}\nPort: {port}\n\nDu kan konfigurera IO- och panelbeteendet i inst\u00e4llningarna f\u00f6r Konnected Alarm Panel.", + "title": "Konnected-enheten redo" + }, + "user": { + "data": { + "host": "Konnected-enhetens IP-adress", + "port": "Konnected-enhetens port" + }, + "description": "Ange v\u00e4rdinformationen f\u00f6r din Konnected Panel.", + "title": "Uppt\u00e4ck Konnected-enhet" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Inte en erk\u00e4nd Konnected.io-enhet" + }, + "error": { + "one": "Tom", + "other": "Tomma" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Invertera \u00f6ppet/st\u00e4ngt tillst\u00e5nd", + "name": "Namn (valfritt)", + "type": "Bin\u00e4r sensortyp" + }, + "description": "V\u00e4lj alternativ f\u00f6r den bin\u00e4ra sensorn som \u00e4r ansluten till {zone}", + "title": "Konfigurera Bin\u00e4r Sensor" + }, + "options_digital": { + "data": { + "name": "Namn (valfritt)", + "poll_interval": "H\u00e4mtningsintervall (minuter) (valfritt)", + "type": "Sensortyp" + }, + "description": "V\u00e4lj alternativ f\u00f6r den digitala sensorn som \u00e4r ansluten till {zone}", + "title": "Konfigurera Digital Sensor" + }, + "options_io": { + "data": { + "1": "Zon 1", + "2": "Zon 2", + "3": "Zon 3", + "4": "Zon 4", + "5": "Zon 5", + "6": "Zon 6", + "7": "Zon 7", + "out": "UT" + }, + "description": "Uppt\u00e4ckte en {model} p\u00e5 {host}. V\u00e4lj baskonfigurationen f\u00f6r varje I/O nedan - beroende p\u00e5 I/O kan det m\u00f6jligg\u00f6ra bin\u00e4ra sensorer (\u00f6ppen/st\u00e4ngd kontakter), digitala sensorer (dht och ds18b20) eller omkopplingsbara utg\u00e5ngar. Du kan konfigurera detaljerade alternativ i n\u00e4sta steg.", + "title": "Konfigurera I/O" + }, + "options_io_ext": { + "data": { + "10": "Zon 10", + "11": "Zon 11", + "12": "Zon 12", + "8": "Zon 8", + "9": "Zon 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "V\u00e4lj den konfiguration av resterande I/O nedan. Du kommer att kunna konfigurera detaljerade alternativ i n\u00e4sta steg.", + "title": "Konfigurera ut\u00f6kat I/O" + }, + "options_misc": { + "data": { + "blink": "Blinka p\u00e5 panel-LED n\u00e4r du skickar tillst\u00e5nds\u00e4ndring" + }, + "description": "V\u00e4lj \u00f6nskat beteende f\u00f6r din panel", + "title": "Konfigurera \u00d6vrigt" + }, + "options_switch": { + "data": { + "activation": "Utdata n\u00e4r den \u00e4r p\u00e5", + "momentary": "Pulsvarighet (ms) (valfritt)", + "name": "Namn (valfritt)", + "pause": "Paus mellan pulser (ms) (valfritt)", + "repeat": "G\u00e5nger att upprepa (-1=o\u00e4ndligt) (tillval)" + }, + "description": "V\u00e4lj utdataalternativ f\u00f6r {zone}", + "title": "Konfigurera v\u00e4xelbar utdata" + } + }, + "title": "Alternativ f\u00f6r Konnected alarmpanel" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/zh-Hans.json b/homeassistant/components/konnected/.translations/zh-Hans.json new file mode 100644 index 00000000000000..2bba1260764ed4 --- /dev/null +++ b/homeassistant/components/konnected/.translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "options_switch": { + "description": "\u8bf7\u9009\u62e9 {zone}\u8f93\u51fa\u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/zh-Hant.json b/homeassistant/components/konnected/.translations/zh-Hant.json new file mode 100644 index 00000000000000..0ecd6c9fc25289 --- /dev/null +++ b/homeassistant/components/konnected/.translations/zh-Hant.json @@ -0,0 +1,100 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5099\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "not_konn_panel": "\u4e26\u975e\u53ef\u8b58\u5225 Konnected.io \u8a2d\u5099", + "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Konnected \u9762\u677f\uff1a{host}:{port}" + }, + "step": { + "confirm": { + "description": "\u578b\u865f\uff1a{model}\n\u4e3b\u6a5f\u7aef\uff1a{host}\n\u901a\u8a0a\u57e0\uff1a{port}\n\n\u53ef\u4ee5\u65bc Konncected \u8b66\u5831\u9762\u677f\u8a2d\u5b9a\u4e2d\u8a2d\u5b9a IO \u8207\u9762\u677f\u884c\u70ba\u3002", + "title": "Konnected \u8a2d\u5099\u5df2\u5099\u59a5" + }, + "user": { + "data": { + "host": "Konnected \u8a2d\u5099 IP \u4f4d\u5740", + "port": "Konnected \u8a2d\u5099\u901a\u8a0a\u57e0" + }, + "description": "\u8acb\u8f38\u5165 Konnected \u9762\u677f\u4e3b\u6a5f\u7aef\u8cc7\u8a0a\u3002", + "title": "\u641c\u7d22 Konnected \u8a2d\u5099" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "\u4e26\u975e\u53ef\u8b58\u5225 Konnected.io \u8a2d\u5099" + }, + "step": { + "options_binary": { + "data": { + "inverse": "\u53cd\u8f49\u958b\u555f/\u95dc\u9589\u72c0\u614b", + "name": "\u540d\u7a31\uff08\u9078\u9805\uff09", + "type": "\u4e8c\u9032\u4f4d\u611f\u61c9\u5668\u985e\u578b" + }, + "description": "\u8acb\u9078\u64c7\u6b78\u7d0d\u70ba {zone}\u7684\u4e8c\u9032\u4f4d\u611f\u61c9\u5668\u8f38\u51fa\u9078\u9805", + "title": "\u8a2d\u5b9a\u4e8c\u9032\u4f4d\u611f\u61c9\u5668" + }, + "options_digital": { + "data": { + "name": "\u540d\u7a31\uff08\u9078\u9805\uff09", + "poll_interval": "\u66f4\u65b0\u9593\u8ddd\uff08\u5206\u9418\uff09\uff08\u9078\u9805\uff09", + "type": "\u611f\u61c9\u5668\u985e\u578b" + }, + "description": "\u8acb\u9078\u64c7\u6b78\u7d0d\u70ba {zone}\u7684\u6578\u4f4d\u611f\u61c9\u5668\u8f38\u51fa\u9078\u9805", + "title": "\u8a2d\u5b9a\u6578\u4f4d\u611f\u61c9\u5668" + }, + "options_io": { + "data": { + "1": "\u5340\u57df 1", + "2": "\u5340\u57df 2", + "3": "\u5340\u57df 3", + "4": "\u5340\u57df 4", + "5": "\u5340\u57df 5", + "6": "\u5340\u57df 6", + "7": "\u5340\u57df 7", + "out": "OUT" + }, + "description": "\u65bc {host} \u767c\u73fe {model}\u3002\u8acb\u65bc\u4e0b\u65b9\u6bcf\u4e00\u500b I/O \u9078\u64c7\u57fa\u672c\u8a2d\u5b9a - \u96a8\u8457 I/O \u4e0d\u540c\uff0c\u53ef\u5141\u8a31\u4e8c\u9032\u4f4d\u611f\u61c9\u5668\uff08\u958b\u555f/\u95dc\u9589\u72c0\u614b\uff09\u3001\u6578\u4f4d\u611f\u61c9\u5668\uff08DHT \u53ca ds18b20\uff09\uff0c\u6216\u8005\u53ef\u5207\u63db\u8f38\u51fa\u3002\u53ef\u4ee5\u65bc\u4e0b\u4e00\u6b65\u8a2d\u5b9a\u8a73\u7d30\u9078\u9805\u3002", + "title": "\u8a2d\u5b9a I/O" + }, + "options_io_ext": { + "data": { + "10": "\u5340\u57df 10", + "11": "\u5340\u57df 11", + "12": "\u5340\u57df 12", + "8": "\u5340\u57df 8", + "9": "\u5340\u57df 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "\u9078\u64c7\u4e0b\u65b9\u5269\u9918 I/O \u8a2d\u5b9a\u3002\u53ef\u4ee5\u65bc\u4e0b\u4e00\u6b65\u8a2d\u5b9a\u8a73\u7d30\u9078\u9805\u3002", + "title": "\u8a2d\u5b9a\u5ef6\u4f38 I/O" + }, + "options_misc": { + "data": { + "blink": "\u7576\u50b3\u9001\u72c0\u614b\u8b8a\u66f4\u6642\u3001\u9583\u720d\u9762\u677f LED" + }, + "description": "\u8acb\u9078\u64c7\u9762\u677f\u671f\u671b\u884c\u70ba", + "title": "\u5176\u4ed6\u8a2d\u5b9a" + }, + "options_switch": { + "data": { + "activation": "\u958b\u555f\u6642\u8f38\u51fa", + "momentary": "\u6301\u7e8c\u6642\u9593\uff08ms\uff09\uff08\u9078\u9805\uff09", + "name": "\u540d\u7a31\uff08\u9078\u9805\uff09", + "pause": "\u66ab\u505c\u9593\u8ddd\uff08ms\uff09\uff08\u9078\u9805\uff09", + "repeat": "\u91cd\u8907\u6642\u9593\uff08-1=\u7121\u9650\uff09\uff08\u9078\u9805\uff09" + }, + "description": "\u8acb\u9078\u64c7 {zone}\u8f38\u51fa\u9078\u9805", + "title": "\u8a2d\u5b9a Switchable \u8f38\u51fa" + } + }, + "title": "Konnected \u8b66\u5831\u9762\u677f\u9078\u9805" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 28e62c322ad824..94508b0148378c 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -1,20 +1,20 @@ """Support for Konnected devices.""" import asyncio +import copy import hmac import json import logging from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response -import konnected import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA -from homeassistant.components.discovery import SERVICE_KONNECTED from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_STATE, CONF_ACCESS_TOKEN, CONF_BINARY_SENSORS, CONF_DEVICES, @@ -27,45 +27,106 @@ CONF_SWITCHES, CONF_TYPE, CONF_ZONE, - EVENT_HOMEASSISTANT_START, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, + STATE_OFF, STATE_ON, ) -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import dispatcher_send - +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv + +from .config_flow import ( # Loading the config flow file will register the flow + CONF_DEFAULT_OPTIONS, + CONF_IO, + CONF_IO_BIN, + CONF_IO_DIG, + CONF_IO_SWI, + OPTIONS_SCHEMA, +) from .const import ( CONF_ACTIVATION, CONF_API_HOST, CONF_BLINK, - CONF_DHT_SENSORS, CONF_DISCOVERY, - CONF_DS18B20_SENSORS, CONF_INVERSE, CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, CONF_REPEAT, DOMAIN, - ENDPOINT_ROOT, PIN_TO_ZONE, - SIGNAL_SENSOR_UPDATE, STATE_HIGH, STATE_LOW, UPDATE_ENDPOINT, ZONE_TO_PIN, + ZONES, ) +from .errors import CannotConnect from .handlers import HANDLERS +from .panel import AlarmPanel _LOGGER = logging.getLogger(__name__) -_BINARY_SENSOR_SCHEMA = vol.All( + +def ensure_pin(value): + """Check if valid pin and coerce to string.""" + if value is None: + raise vol.Invalid("pin value is None") + + if PIN_TO_ZONE.get(str(value)) is None: + raise vol.Invalid("pin not valid") + + return str(value) + + +def ensure_zone(value): + """Check if valid zone and coerce to string.""" + if value is None: + raise vol.Invalid("zone value is None") + + if str(value) not in ZONES is None: + raise vol.Invalid("zone not valid") + + return str(value) + + +def import_validator(config): + """Validate zones and reformat for import.""" + config = copy.deepcopy(config) + io_cfgs = {} + # Replace pins with zones + for conf_platform, conf_io in ( + (CONF_BINARY_SENSORS, CONF_IO_BIN), + (CONF_SENSORS, CONF_IO_DIG), + (CONF_SWITCHES, CONF_IO_SWI), + ): + for zone in config.get(conf_platform, []): + if zone.get(CONF_PIN): + zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]] + del zone[CONF_PIN] + io_cfgs[zone[CONF_ZONE]] = conf_io + + # Migrate config_entry data into default_options structure + config[CONF_IO] = io_cfgs + config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config) + + # clean up fields migrated to options + config.pop(CONF_BINARY_SENSORS, None) + config.pop(CONF_SENSORS, None) + config.pop(CONF_SWITCHES, None) + config.pop(CONF_BLINK, None) + config.pop(CONF_DISCOVERY, None) + config.pop(CONF_IO, None) + return config + + +# configuration.yaml schemas (legacy) +BINARY_SENSOR_SCHEMA_YAML = vol.All( vol.Schema( { - vol.Exclusive(CONF_PIN, "s_pin"): vol.Any(*PIN_TO_ZONE), - vol.Exclusive(CONF_ZONE, "s_pin"): vol.Any(*ZONE_TO_PIN), + vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, + vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_INVERSE, default=False): cv.boolean, @@ -74,14 +135,14 @@ cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), ) -_SENSOR_SCHEMA = vol.All( +SENSOR_SCHEMA_YAML = vol.All( vol.Schema( { - vol.Exclusive(CONF_PIN, "s_pin"): vol.Any(*PIN_TO_ZONE), - vol.Exclusive(CONF_ZONE, "s_pin"): vol.Any(*ZONE_TO_PIN), + vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, + vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_POLL_INTERVAL): vol.All( + vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All( vol.Coerce(int), vol.Range(min=1) ), } @@ -89,11 +150,11 @@ cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), ) -_SWITCH_SCHEMA = vol.All( +SWITCH_SCHEMA_YAML = vol.All( vol.Schema( { - vol.Exclusive(CONF_PIN, "a_pin"): vol.Any(*PIN_TO_ZONE), - vol.Exclusive(CONF_ZONE, "a_pin"): vol.Any(*ZONE_TO_PIN), + vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, + vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( vol.Lower, vol.Any(STATE_HIGH, STATE_LOW) @@ -106,6 +167,24 @@ cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), ) +DEVICE_SCHEMA_YAML = vol.All( + vol.Schema( + { + vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML] + ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]), + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]), + vol.Inclusive(CONF_HOST, "host_info"): cv.string, + vol.Inclusive(CONF_PORT, "host_info"): cv.port, + vol.Optional(CONF_BLINK, default=True): cv.boolean, + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + } + ), + import_validator, +) + # pylint: disable=no-value-for-parameter CONFIG_SCHEMA = vol.Schema( { @@ -113,352 +192,88 @@ { vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_API_HOST): vol.Url(), - vol.Required(CONF_DEVICES): [ - { - vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [_BINARY_SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [_SENSOR_SCHEMA] - ), - vol.Optional(CONF_SWITCHES): vol.All( - cv.ensure_list, [_SWITCH_SCHEMA] - ), - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_BLINK, default=True): cv.boolean, - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - } - ], + vol.Optional(CONF_DEVICES): vol.All( + cv.ensure_list, [DEVICE_SCHEMA_YAML] + ), } ) }, extra=vol.ALLOW_EXTRA, ) +YAML_CONFIGS = "yaml_configs" +PLATFORMS = ["binary_sensor", "sensor", "switch"] + -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict): """Set up the Konnected platform.""" cfg = config.get(DOMAIN) if cfg is None: cfg = {} - access_token = cfg.get(CONF_ACCESS_TOKEN) if DOMAIN not in hass.data: hass.data[DOMAIN] = { - CONF_ACCESS_TOKEN: access_token, + CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN), CONF_API_HOST: cfg.get(CONF_API_HOST), + CONF_DEVICES: {}, } - def setup_device(host, port): - """Set up a Konnected device at `host` listening on `port`.""" - discovered = DiscoveredDevice(hass, host, port) - if discovered.is_configured: - discovered.setup() - else: - _LOGGER.warning( - "Konnected device %s was discovered on the network" - " but not specified in configuration.yaml", - discovered.device_id, - ) - - def device_discovered(service, info): - """Call when a Konnected device has been discovered.""" - host = info.get(CONF_HOST) - port = info.get(CONF_PORT) - setup_device(host, port) - - async def manual_discovery(event): - """Init devices on the network with manually assigned addresses.""" - specified = [ - dev - for dev in cfg.get(CONF_DEVICES) - if dev.get(CONF_HOST) and dev.get(CONF_PORT) - ] - - while specified: - for dev in specified: - _LOGGER.debug( - "Discovering Konnected device %s at %s:%s", - dev.get(CONF_ID), - dev.get(CONF_HOST), - dev.get(CONF_PORT), - ) - try: - await hass.async_add_executor_job( - setup_device, dev.get(CONF_HOST), dev.get(CONF_PORT) - ) - specified.remove(dev) - except konnected.Client.ClientError as err: - _LOGGER.error(err) - await asyncio.sleep(10) # try again in 10 seconds + hass.http.register_view(KonnectedView) - # Initialize devices specified in the configuration on boot - for device in cfg.get(CONF_DEVICES): - ConfiguredDevice(hass, device, config).save_data() + # Check if they have yaml configured devices + if CONF_DEVICES not in cfg: + return True - discovery.async_listen(hass, SERVICE_KONNECTED, device_discovered) - - hass.http.register_view(KonnectedView(access_token)) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, manual_discovery) - - return True - - -class ConfiguredDevice: - """A representation of a configured Konnected device.""" - - def __init__(self, hass, config, hass_config): - """Initialize the Konnected device.""" - self.hass = hass - self.config = config - self.hass_config = hass_config - - @property - def device_id(self): - """Device id is the MAC address as string with punctuation removed.""" - return self.config.get(CONF_ID) - - def save_data(self): - """Save the device configuration to `hass.data`.""" - binary_sensors = {} - for entity in self.config.get(CONF_BINARY_SENSORS) or []: - if CONF_ZONE in entity: - pin = ZONE_TO_PIN[entity[CONF_ZONE]] - else: - pin = entity[CONF_PIN] - - binary_sensors[pin] = { - CONF_TYPE: entity[CONF_TYPE], - CONF_NAME: entity.get( - CONF_NAME, - "Konnected {} Zone {}".format(self.device_id[6:], PIN_TO_ZONE[pin]), - ), - CONF_INVERSE: entity.get(CONF_INVERSE), - ATTR_STATE: None, - } - _LOGGER.debug( - "Set up binary_sensor %s (initial state: %s)", - binary_sensors[pin].get("name"), - binary_sensors[pin].get(ATTR_STATE), + for device in cfg.get(CONF_DEVICES, []): + # Attempt to importing the cfg. Use + # hass.async_add_job to avoid a deadlock. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device, ) - - actuators = [] - for entity in self.config.get(CONF_SWITCHES) or []: - if CONF_ZONE in entity: - pin = ZONE_TO_PIN[entity[CONF_ZONE]] - else: - pin = entity[CONF_PIN] - - act = { - CONF_PIN: pin, - CONF_NAME: entity.get( - CONF_NAME, - "Konnected {} Actuator {}".format( - self.device_id[6:], PIN_TO_ZONE[pin] - ), - ), - ATTR_STATE: None, - CONF_ACTIVATION: entity[CONF_ACTIVATION], - CONF_MOMENTARY: entity.get(CONF_MOMENTARY), - CONF_PAUSE: entity.get(CONF_PAUSE), - CONF_REPEAT: entity.get(CONF_REPEAT), - } - actuators.append(act) - _LOGGER.debug("Set up switch %s", act) - - sensors = [] - for entity in self.config.get(CONF_SENSORS) or []: - if CONF_ZONE in entity: - pin = ZONE_TO_PIN[entity[CONF_ZONE]] - else: - pin = entity[CONF_PIN] - - sensor = { - CONF_PIN: pin, - CONF_NAME: entity.get( - CONF_NAME, - "Konnected {} Sensor {}".format( - self.device_id[6:], PIN_TO_ZONE[pin] - ), - ), - CONF_TYPE: entity[CONF_TYPE], - CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL), - } - sensors.append(sensor) - _LOGGER.debug( - "Set up %s sensor %s (initial state: %s)", - sensor.get(CONF_TYPE), - sensor.get(CONF_NAME), - sensor.get(ATTR_STATE), - ) - - device_data = { - CONF_BINARY_SENSORS: binary_sensors, - CONF_SENSORS: sensors, - CONF_SWITCHES: actuators, - CONF_BLINK: self.config.get(CONF_BLINK), - CONF_DISCOVERY: self.config.get(CONF_DISCOVERY), - } - - if CONF_DEVICES not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][CONF_DEVICES] = {} - - _LOGGER.debug( - "Storing data in hass.data[%s][%s][%s]: %s", - DOMAIN, - CONF_DEVICES, - self.device_id, - device_data, ) - self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data - - for platform in ["binary_sensor", "sensor", "switch"]: - discovery.load_platform( - self.hass, - platform, - DOMAIN, - {"device_id": self.device_id}, - self.hass_config, - ) - + return True -class DiscoveredDevice: - """A representation of a discovered Konnected device.""" - def __init__(self, hass, host, port): - """Initialize the Konnected device.""" - self.hass = hass - self.host = host - self.port = port +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up panel from a config entry.""" + client = AlarmPanel(hass, entry) + # create a data store in hass.data[DOMAIN][CONF_DEVICES] + await client.async_save_data() - self.client = konnected.Client(host, str(port)) - self.status = self.client.get_status() + try: + await client.async_connect() + except CannotConnect: + # this will trigger a retry in the future + raise config_entries.ConfigEntryNotReady - def setup(self): - """Set up a newly discovered Konnected device.""" - _LOGGER.info( - "Discovered Konnected device %s. Open http://%s:%s in a " - "web browser to view device status.", - self.device_id, - self.host, - self.port, + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) ) - self.save_data() - self.update_initial_states() - self.sync_device_config() - - def save_data(self): - """Save the discovery information to `hass.data`.""" - self.stored_configuration["client"] = self.client - self.stored_configuration["host"] = self.host - self.stored_configuration["port"] = self.port - - @property - def device_id(self): - """Device id is the MAC address as string with punctuation removed.""" - return self.status["mac"].replace(":", "") - - @property - def is_configured(self): - """Return true if device_id is specified in the configuration.""" - return bool(self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)) - - @property - def stored_configuration(self): - """Return the configuration stored in `hass.data` for this device.""" - return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) - - def binary_sensor_configuration(self): - """Return the configuration map for syncing binary sensors.""" - return [{"pin": p} for p in self.stored_configuration[CONF_BINARY_SENSORS]] - - def actuator_configuration(self): - """Return the configuration map for syncing actuators.""" - return [ - { - "pin": data.get(CONF_PIN), - "trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1), - } - for data in self.stored_configuration[CONF_SWITCHES] - ] - - def dht_sensor_configuration(self): - """Return the configuration map for syncing DHT sensors.""" - return [ - {CONF_PIN: sensor[CONF_PIN], CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} - for sensor in self.stored_configuration[CONF_SENSORS] - if sensor[CONF_TYPE] == "dht" - ] - - def ds18b20_sensor_configuration(self): - """Return the configuration map for syncing DS18B20 sensors.""" - return [ - {"pin": sensor[CONF_PIN]} - for sensor in self.stored_configuration[CONF_SENSORS] - if sensor[CONF_TYPE] == "ds18b20" - ] - - def update_initial_states(self): - """Update the initial state of each sensor from status poll.""" - for sensor_data in self.status.get("sensors"): - sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get( - sensor_data.get(CONF_PIN), {} - ) - entity_id = sensor_config.get(ATTR_ENTITY_ID) - - state = bool(sensor_data.get(ATTR_STATE)) - if sensor_config.get(CONF_INVERSE): - state = not state + entry.add_update_listener(async_entry_updated) + return True - dispatcher_send(self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) - def desired_settings_payload(self): - """Return a dict representing the desired device configuration.""" - desired_api_host = ( - self.hass.data[DOMAIN].get(CONF_API_HOST) or self.hass.config.api.base_url +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] ) - desired_api_endpoint = desired_api_host + ENDPOINT_ROOT - - return { - "sensors": self.binary_sensor_configuration(), - "actuators": self.actuator_configuration(), - "dht_sensors": self.dht_sensor_configuration(), - "ds18b20_sensors": self.ds18b20_sensor_configuration(), - "auth_token": self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), - "endpoint": desired_api_endpoint, - "blink": self.stored_configuration.get(CONF_BLINK), - "discovery": self.stored_configuration.get(CONF_DISCOVERY), - } + ) + if unload_ok: + hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID]) - def current_settings_payload(self): - """Return a dict of configuration currently stored on the device.""" - settings = self.status["settings"] - if not settings: - settings = {} - - return { - "sensors": [{"pin": s[CONF_PIN]} for s in self.status.get("sensors")], - "actuators": self.status.get("actuators"), - "dht_sensors": self.status.get(CONF_DHT_SENSORS), - "ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS), - "auth_token": settings.get("token"), - "endpoint": settings.get("apiUrl"), - "blink": settings.get(CONF_BLINK), - "discovery": settings.get(CONF_DISCOVERY), - } + return unload_ok - def sync_device_config(self): - """Sync the new pin configuration to the Konnected device if needed.""" - _LOGGER.debug( - "Device %s settings payload: %s", - self.device_id, - self.desired_settings_payload(), - ) - if self.desired_settings_payload() != self.current_settings_payload(): - _LOGGER.info("pushing settings to device %s", self.device_id) - self.client.put_settings(**self.desired_settings_payload()) + +async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry): + """Reload the config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) class KonnectedView(HomeAssistantView): @@ -468,9 +283,8 @@ class KonnectedView(HomeAssistantView): name = "api:konnected" requires_auth = False # Uses access token from configuration - def __init__(self, auth_token): + def __init__(self): """Initialize the view.""" - self.auth_token = auth_token @staticmethod def binary_value(state, activation): @@ -479,50 +293,29 @@ def binary_value(state, activation): return 1 if state == STATE_ON else 0 return 0 if state == STATE_ON else 1 - async def get(self, request: Request, device_id) -> Response: - """Return the current binary state of a switch.""" + async def update_sensor(self, request: Request, device_id) -> Response: + """Process a put or post.""" hass = request.app["hass"] - pin_num = int(request.query.get("pin")) data = hass.data[DOMAIN] - device = data[CONF_DEVICES][device_id] - if not device: - return self.json_message( - f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND - ) - - try: - pin = next( - filter( - lambda switch: switch[CONF_PIN] == pin_num, device[CONF_SWITCHES] - ) - ) - except StopIteration: - pin = None - - if not pin: - return self.json_message( - format("Switch on pin {} not configured", pin_num), - status_code=HTTP_NOT_FOUND, - ) - - return self.json( - { - "pin": pin_num, - "state": self.binary_value( - hass.states.get(pin[ATTR_ENTITY_ID]).state, pin[CONF_ACTIVATION] - ), - } + auth = request.headers.get(AUTHORIZATION, None) + tokens = [] + if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN): + tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]]) + tokens.extend( + [ + entry.data[CONF_ACCESS_TOKEN] + for entry in hass.config_entries.async_entries(DOMAIN) + ] ) - - async def put(self, request: Request, device_id) -> Response: - """Receive a sensor update via PUT request and async set state.""" - hass = request.app["hass"] - data = hass.data[DOMAIN] + if auth is None or not next( + (True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)), + False, + ): + return self.json_message("unauthorized", status_code=HTTP_UNAUTHORIZED) try: # Konnected 2.2.0 and above supports JSON payloads payload = await request.json() - pin_num = payload["pin"] except json.decoder.JSONDecodeError: _LOGGER.error( ( @@ -532,30 +325,97 @@ async def put(self, request: Request, device_id) -> Response: ) ) - auth = request.headers.get(AUTHORIZATION, None) - if not hmac.compare_digest(f"Bearer {self.auth_token}", auth): - return self.json_message("unauthorized", status_code=HTTP_UNAUTHORIZED) - pin_num = int(pin_num) device = data[CONF_DEVICES].get(device_id) if device is None: return self.json_message( "unregistered device", status_code=HTTP_BAD_REQUEST ) - pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or next( - (s for s in device[CONF_SENSORS] if s[CONF_PIN] == pin_num), None - ) - if pin_data is None: + try: + zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]]) + zone_data = device[CONF_BINARY_SENSORS].get(zone_num) or next( + (s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None + ) + except KeyError: + zone_data = None + + if zone_data is None: return self.json_message( "unregistered sensor/actuator", status_code=HTTP_BAD_REQUEST ) - pin_data["device_id"] = device_id + zone_data["device_id"] = device_id for attr in ["state", "temp", "humi", "addr"]: value = payload.get(attr) handler = HANDLERS.get(attr) if value is not None and handler: - hass.async_create_task(handler(hass, pin_data, payload)) + hass.async_create_task(handler(hass, zone_data, payload)) return self.json_message("ok") + + async def get(self, request: Request, device_id) -> Response: + """Return the current binary state of a switch.""" + hass = request.app["hass"] + data = hass.data[DOMAIN] + + device = data[CONF_DEVICES].get(device_id) + if not device: + return self.json_message( + f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND + ) + + # Our data model is based on zone ids but we convert from/to pin ids + # based on whether they are specified in the request + try: + zone_num = str( + request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]] + ) + zone = next( + ( + switch + for switch in device[CONF_SWITCHES] + if switch[CONF_ZONE] == zone_num + ) + ) + + except StopIteration: + zone = None + except KeyError: + zone = None + zone_num = None + + if not zone: + target = request.query.get( + CONF_ZONE, request.query.get(CONF_PIN, "unknown") + ) + return self.json_message( + f"Switch on zone or pin {target} not configured", + status_code=HTTP_NOT_FOUND, + ) + + resp = {} + if request.query.get(CONF_ZONE): + resp[CONF_ZONE] = zone_num + else: + resp[CONF_PIN] = ZONE_TO_PIN[zone_num] + + # Make sure entity is setup + zone_entity_id = zone.get(ATTR_ENTITY_ID) + if zone_entity_id: + resp["state"] = self.binary_value( + hass.states.get(zone_entity_id).state, zone[CONF_ACTIVATION], + ) + return self.json(resp) + + _LOGGER.warning("Konnected entity not yet setup, returning default") + resp["state"] = self.binary_value(STATE_OFF, zone[CONF_ACTIVATION]) + return self.json(resp) + + async def put(self, request: Request, device_id) -> Response: + """Receive a sensor update via PUT request and async set state.""" + return await self.update_sensor(request, device_id) + + async def post(self, request: Request, device_id) -> Response: + """Receive a sensor update via POST request and async set state.""" + return await self.update_sensor(request, device_id) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 486c228d6fb2f7..dc4dae7787f1cb 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -13,18 +13,15 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, SIGNAL_SENSOR_UPDATE +from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_SENSOR_UPDATE _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up binary sensors attached to a Konnected device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up binary sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info["device_id"] + device_id = config_entry.data["id"] sensors = [ KonnectedBinarySensor(device_id, pin_num, pin_data) for pin_num, pin_data in data[CONF_DEVICES][device_id][ @@ -37,14 +34,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class KonnectedBinarySensor(BinarySensorDevice): """Representation of a Konnected binary sensor.""" - def __init__(self, device_id, pin_num, data): + def __init__(self, device_id, zone_num, data): """Initialize the Konnected binary sensor.""" self._data = data self._device_id = device_id - self._pin_num = pin_num + self._zone_num = zone_num self._state = self._data.get(ATTR_STATE) self._device_class = self._data.get(CONF_TYPE) - self._unique_id = "{}-{}".format(device_id, PIN_TO_ZONE[pin_num]) + self._unique_id = f"{device_id}-{zone_num}" self._name = self._data.get(CONF_NAME) @property @@ -72,6 +69,13 @@ def device_class(self): """Return the device class.""" return self._device_class + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, + } + async def async_added_to_hass(self): """Store entity_id and register state change callback.""" self._data[ATTR_ENTITY_ID] = self.entity_id diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py new file mode 100644 index 00000000000000..cb9004c9efed6a --- /dev/null +++ b/homeassistant/components/konnected/config_flow.py @@ -0,0 +1,766 @@ +"""Config flow for konnected.io integration.""" +import asyncio +import copy +import logging +import random +import string +from urllib.parse import urlparse + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_DOOR, + DEVICE_CLASSES_SCHEMA, +) +from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_BINARY_SENSORS, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_SWITCHES, + CONF_TYPE, + CONF_ZONE, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_ACTIVATION, + CONF_BLINK, + CONF_DEFAULT_OPTIONS, + CONF_DISCOVERY, + CONF_INVERSE, + CONF_MODEL, + CONF_MOMENTARY, + CONF_PAUSE, + CONF_POLL_INTERVAL, + CONF_REPEAT, + DOMAIN, + STATE_HIGH, + STATE_LOW, + ZONES, +) +from .errors import CannotConnect +from .panel import KONN_MODEL, KONN_MODEL_PRO, get_status + +_LOGGER = logging.getLogger(__name__) + +ATTR_KONN_UPNP_MODEL_NAME = "model_name" # standard upnp is modelName +CONF_IO = "io" +CONF_IO_DIS = "Disabled" +CONF_IO_BIN = "Binary Sensor" +CONF_IO_DIG = "Digital Sensor" +CONF_IO_SWI = "Switchable Output" + +KONN_MANUFACTURER = "konnected.io" +KONN_PANEL_MODEL_NAMES = { + KONN_MODEL: "Konnected Alarm Panel", + KONN_MODEL_PRO: "Konnected Alarm Panel Pro", +} + +OPTIONS_IO_ANY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG, CONF_IO_SWI]) +OPTIONS_IO_INPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG]) +OPTIONS_IO_OUTPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_SWI]) + + +# Config entry schemas +IO_SCHEMA = vol.Schema( + { + vol.Optional("1", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("2", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("3", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("4", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("5", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("6", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("7", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("8", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("9", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("10", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("11", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("12", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("out", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + vol.Optional("alarm1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + vol.Optional("out1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + vol.Optional("alarm2_out2", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + } +) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(ZONES), + vol.Required(CONF_TYPE, default=DEVICE_CLASS_DOOR): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INVERSE, default=False): cv.boolean, + } +) + +SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(ZONES), + vol.Required(CONF_TYPE, default="dht"): vol.All( + vol.Lower, vol.In(["dht", "ds18b20"]) + ), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } +) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(ZONES), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( + vol.Lower, vol.Any(STATE_HIGH, STATE_LOW) + ), + vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)), + } +) + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_IO): IO_SCHEMA, + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [BINARY_SENSOR_SCHEMA] + ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]), + vol.Optional(CONF_BLINK, default=True): cv.boolean, + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + }, + extra=vol.REMOVE_EXTRA, +) + +CONFIG_ENTRY_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_MODEL): vol.Any(*KONN_PANEL_MODEL_NAMES), + vol.Required(CONF_ACCESS_TOKEN): cv.matches_regex("[a-zA-Z0-9]+"), + vol.Required(CONF_DEFAULT_OPTIONS): OPTIONS_SCHEMA, + }, + extra=vol.REMOVE_EXTRA, +) + + +class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NEW_NAME.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + # class variable to store/share discovered host information + discovered_hosts = {} + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + + def __init__(self): + """Initialize the Konnected flow.""" + self.data = {} + self.options = OPTIONS_SCHEMA({CONF_IO: {}}) + + async def async_gen_config(self, host, port): + """Populate self.data based on panel status. + + This will raise CannotConnect if an error occurs + """ + self.data[CONF_HOST] = host + self.data[CONF_PORT] = port + try: + status = await get_status(self.hass, host, port) + self.data[CONF_ID] = status["mac"].replace(":", "") + except (CannotConnect, KeyError): + raise CannotConnect + else: + self.data[CONF_MODEL] = status.get("model", KONN_MODEL) + self.data[CONF_ACCESS_TOKEN] = "".join( + random.choices(f"{string.ascii_uppercase}{string.digits}", k=20) + ) + + async def async_step_import(self, device_config): + """Import a configuration.yaml config. + + This flow is triggered by `async_setup` for configured panels. + """ + _LOGGER.debug(device_config) + + # save the data and confirm connection via user step + await self.async_set_unique_id(device_config["id"]) + self.options = device_config[CONF_DEFAULT_OPTIONS] + + # config schema ensures we have port if we have host + if device_config.get(CONF_HOST): + # automatically connect if we have host info + return await self.async_step_user( + user_input={ + CONF_HOST: device_config[CONF_HOST], + CONF_PORT: device_config[CONF_PORT], + } + ) + + # if we have no host info wait for it or abort if previously configured + self._abort_if_unique_id_configured() + return await self.async_step_import_confirm() + + async def async_step_import_confirm(self, user_input=None): + """Confirm the user wants to import the config entry.""" + if user_input is None: + return self.async_show_form( + step_id="import_confirm", + description_placeholders={"id": self.unique_id}, + ) + + # if we have ssdp discovered applicable host info use it + if KonnectedFlowHandler.discovered_hosts.get(self.unique_id): + return await self.async_step_user( + user_input={ + CONF_HOST: KonnectedFlowHandler.discovered_hosts[self.unique_id][ + CONF_HOST + ], + CONF_PORT: KonnectedFlowHandler.discovered_hosts[self.unique_id][ + CONF_PORT + ], + } + ) + return await self.async_step_user() + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered konnected panel. + + This flow is triggered by the SSDP component. It will check if the + device is already configured and attempt to finish the config if not. + """ + _LOGGER.debug(discovery_info) + + try: + if discovery_info[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: + return self.async_abort(reason="not_konn_panel") + + if not any( + name in discovery_info[ATTR_UPNP_MODEL_NAME] + for name in KONN_PANEL_MODEL_NAMES + ): + _LOGGER.warning( + "Discovered unrecognized Konnected device %s", + discovery_info.get(ATTR_UPNP_MODEL_NAME, "Unknown"), + ) + return self.async_abort(reason="not_konn_panel") + + # If MAC is missing it is a bug in the device fw but we'll guard + # against it since the field is so vital + except KeyError: + _LOGGER.error("Malformed Konnected SSDP info") + else: + # extract host/port from ssdp_location + netloc = urlparse(discovery_info["ssdp_location"]).netloc.split(":") + return await self.async_step_user( + user_input={CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])} + ) + + return self.async_abort(reason="unknown") + + async def async_step_user(self, user_input=None): + """Connect to panel and get config.""" + errors = {} + if user_input: + # build config info and wait for user confirmation + self.data[CONF_HOST] = user_input[CONF_HOST] + self.data[CONF_PORT] = user_input[CONF_PORT] + self.data[CONF_ACCESS_TOKEN] = self.hass.data.get(DOMAIN, {}).get( + CONF_ACCESS_TOKEN + ) or "".join( + random.choices(f"{string.ascii_uppercase}{string.digits}", k=20) + ) + + # brief delay to allow processing of recent status req + await asyncio.sleep(0.1) + try: + status = await get_status( + self.hass, self.data[CONF_HOST], self.data[CONF_PORT] + ) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + self.data[CONF_ID] = status["mac"].replace(":", "") + self.data[CONF_MODEL] = status.get("model", KONN_MODEL) + + # save off our discovered host info + KonnectedFlowHandler.discovered_hosts[self.data[CONF_ID]] = { + CONF_HOST: self.data[CONF_HOST], + CONF_PORT: self.data[CONF_PORT], + } + return await self.async_step_confirm() + + return self.async_show_form( + step_id="user", + description_placeholders={ + "host": self.data.get(CONF_HOST, "Unknown"), + "port": self.data.get(CONF_PORT, "Unknown"), + }, + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str, + vol.Required(CONF_PORT, default=self.data.get(CONF_PORT)): int, + } + ), + errors=errors, + ) + + async def async_step_confirm(self, user_input=None): + """Attempt to link with the Konnected panel. + + Given a configured host, will ask the user to confirm and finalize + the connection. + """ + if user_input is None: + # abort and update an existing config entry if host info changes + await self.async_set_unique_id(self.data[CONF_ID]) + self._abort_if_unique_id_configured(updates=self.data) + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], + "id": self.unique_id, + "host": self.data[CONF_HOST], + "port": self.data[CONF_PORT], + }, + ) + + # Attach default options and create entry + self.data[CONF_DEFAULT_OPTIONS] = self.options + return self.async_create_entry( + title=KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], data=self.data, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Return the Options Flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for a Konnected Panel.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.entry = config_entry + self.model = self.entry.data[CONF_MODEL] + self.current_opt = self.entry.options or self.entry.data[CONF_DEFAULT_OPTIONS] + + # as config proceeds we'll build up new options and then replace what's in the config entry + self.new_opt = {CONF_IO: {}} + self.active_cfg = None + self.io_cfg = {} + + @callback + def get_current_cfg(self, io_type, zone): + """Get the current zone config.""" + return next( + ( + cfg + for cfg in self.current_opt.get(io_type, []) + if cfg[CONF_ZONE] == zone + ), + {}, + ) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + return await self.async_step_options_io() + + async def async_step_options_io(self, user_input=None): + """Configure legacy panel IO or first half of pro IO.""" + errors = {} + current_io = self.current_opt.get(CONF_IO, {}) + + if user_input is not None: + # strip out disabled io and save for options cfg + for key, value in user_input.items(): + if value != CONF_IO_DIS: + self.new_opt[CONF_IO][key] = value + return await self.async_step_options_io_ext() + + if self.model == KONN_MODEL: + return self.async_show_form( + step_id="options_io", + data_schema=vol.Schema( + { + vol.Required( + "1", default=current_io.get("1", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "2", default=current_io.get("2", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "3", default=current_io.get("3", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "4", default=current_io.get("4", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "5", default=current_io.get("5", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "6", default=current_io.get("6", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "out", default=current_io.get("out", CONF_IO_DIS) + ): OPTIONS_IO_OUTPUT_ONLY, + } + ), + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.model], + "host": self.entry.data[CONF_HOST], + }, + errors=errors, + ) + + # configure the first half of the pro board io + if self.model == KONN_MODEL_PRO: + return self.async_show_form( + step_id="options_io", + data_schema=vol.Schema( + { + vol.Required( + "1", default=current_io.get("1", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "2", default=current_io.get("2", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "3", default=current_io.get("3", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "4", default=current_io.get("4", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "5", default=current_io.get("5", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "6", default=current_io.get("6", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "7", default=current_io.get("7", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + } + ), + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.model], + "host": self.entry.data[CONF_HOST], + }, + errors=errors, + ) + + return self.async_abort(reason="not_konn_panel") + + async def async_step_options_io_ext(self, user_input=None): + """Allow the user to configure the extended IO for pro.""" + errors = {} + current_io = self.current_opt.get(CONF_IO, {}) + + if user_input is not None: + # strip out disabled io and save for options cfg + for key, value in user_input.items(): + if value != CONF_IO_DIS: + self.new_opt[CONF_IO].update({key: value}) + self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO]) + return await self.async_step_options_binary() + + if self.model == KONN_MODEL: + self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO]) + return await self.async_step_options_binary() + + if self.model == KONN_MODEL_PRO: + return self.async_show_form( + step_id="options_io_ext", + data_schema=vol.Schema( + { + vol.Required( + "8", default=current_io.get("8", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "9", default=current_io.get("9", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "10", default=current_io.get("10", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "11", default=current_io.get("11", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "12", default=current_io.get("12", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "alarm1", default=current_io.get("alarm1", CONF_IO_DIS) + ): OPTIONS_IO_OUTPUT_ONLY, + vol.Required( + "out1", default=current_io.get("out1", CONF_IO_DIS) + ): OPTIONS_IO_OUTPUT_ONLY, + vol.Required( + "alarm2_out2", + default=current_io.get("alarm2_out2", CONF_IO_DIS), + ): OPTIONS_IO_OUTPUT_ONLY, + } + ), + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.model], + "host": self.entry.data[CONF_HOST], + }, + errors=errors, + ) + + return self.async_abort(reason="not_konn_panel") + + async def async_step_options_binary(self, user_input=None): + """Allow the user to configure the IO options for binary sensors.""" + errors = {} + if user_input is not None: + zone = {"zone": self.active_cfg} + zone.update(user_input) + self.new_opt[CONF_BINARY_SENSORS] = self.new_opt.get( + CONF_BINARY_SENSORS, [] + ) + [zone] + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None + + if self.active_cfg: + current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_binary", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, + default=current_cfg.get(CONF_TYPE, DEVICE_CLASS_DOOR), + ): DEVICE_CLASSES_SCHEMA, + vol.Optional( + CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_INVERSE, default=current_cfg.get(CONF_INVERSE, False) + ): bool, + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper + }, + errors=errors, + ) + + # find the next unconfigured binary sensor + for key, value in self.io_cfg.items(): + if value == CONF_IO_BIN: + self.active_cfg = key + current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_binary", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, + default=current_cfg.get(CONF_TYPE, DEVICE_CLASS_DOOR), + ): DEVICE_CLASSES_SCHEMA, + vol.Optional( + CONF_NAME, + default=current_cfg.get(CONF_NAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_INVERSE, + default=current_cfg.get(CONF_INVERSE, False), + ): bool, + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper + }, + errors=errors, + ) + + return await self.async_step_options_digital() + + async def async_step_options_digital(self, user_input=None): + """Allow the user to configure the IO options for digital sensors.""" + errors = {} + if user_input is not None: + zone = {"zone": self.active_cfg} + zone.update(user_input) + self.new_opt[CONF_SENSORS] = self.new_opt.get(CONF_SENSORS, []) + [zone] + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None + + if self.active_cfg: + current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_digital", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht") + ): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), + vol.Optional( + CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_POLL_INTERVAL, + default=current_cfg.get(CONF_POLL_INTERVAL, 3), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + # find the next unconfigured digital sensor + for key, value in self.io_cfg.items(): + if value == CONF_IO_DIG: + self.active_cfg = key + current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_digital", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht") + ): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), + vol.Optional( + CONF_NAME, + default=current_cfg.get(CONF_NAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_POLL_INTERVAL, + default=current_cfg.get(CONF_POLL_INTERVAL, 3), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + return await self.async_step_options_switch() + + async def async_step_options_switch(self, user_input=None): + """Allow the user to configure the IO options for switches.""" + errors = {} + if user_input is not None: + zone = {"zone": self.active_cfg} + zone.update(user_input) + self.new_opt[CONF_SWITCHES] = self.new_opt.get(CONF_SWITCHES, []) + [zone] + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None + + if self.active_cfg: + current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg) + return self.async_show_form( + step_id="options_switch", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_ACTIVATION, + default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH), + ): vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)), + vol.Optional( + CONF_MOMENTARY, + default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_PAUSE, + default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_REPEAT, + default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=-1)), + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + # find the next unconfigured switch + for key, value in self.io_cfg.items(): + if value == CONF_IO_SWI: + self.active_cfg = key + current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg) + return self.async_show_form( + step_id="options_switch", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, + default=current_cfg.get(CONF_NAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_ACTIVATION, + default=current_cfg.get(CONF_ACTIVATION, "high"), + ): vol.In(["low", "high"]), + vol.Optional( + CONF_MOMENTARY, + default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_PAUSE, + default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_REPEAT, + default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=-1)), + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + return await self.async_step_options_misc() + + async def async_step_options_misc(self, user_input=None): + """Allow the user to configure the LED behavior.""" + errors = {} + if user_input is not None: + self.new_opt[CONF_BLINK] = user_input[CONF_BLINK] + return self.async_create_entry(title="", data=self.new_opt) + + return self.async_show_form( + step_id="options_misc", + data_schema=vol.Schema( + { + vol.Required( + CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True) + ): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index 0107b341532b5a..d6819dcf71f201 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -4,6 +4,7 @@ CONF_ACTIVATION = "activation" CONF_API_HOST = "api_host" +CONF_DEFAULT_OPTIONS = "default_options" CONF_MOMENTARY = "momentary" CONF_PAUSE = "pause" CONF_POLL_INTERVAL = "poll_interval" @@ -14,11 +15,33 @@ CONF_DISCOVERY = "discovery" CONF_DHT_SENSORS = "dht_sensors" CONF_DS18B20_SENSORS = "ds18b20_sensors" +CONF_MODEL = "model" STATE_LOW = "low" STATE_HIGH = "high" -PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: "out", 9: 6} +ZONES = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "alarm1", + "out1", + "alarm2_out2", + "out", +] + +# alarm panel pro only handles zones, +# alarm panel allows specifying pins via configuration.yaml +PIN_TO_ZONE = {"1": "1", "2": "2", "5": "3", "6": "4", "7": "5", "8": "out", "9": "6"} ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} ENDPOINT_ROOT = "/api/konnected" diff --git a/homeassistant/components/konnected/errors.py b/homeassistant/components/konnected/errors.py new file mode 100644 index 00000000000000..5a0207f3f8d865 --- /dev/null +++ b/homeassistant/components/konnected/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Konnected component.""" +from homeassistant.exceptions import HomeAssistantError + + +class KonnectedException(HomeAssistantError): + """Base class for Konnected exceptions.""" + + +class CannotConnect(KonnectedException): + """Unable to connect to the panel.""" diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json index feb6a4589cbb57..3a74e2165dfb3e 100644 --- a/homeassistant/components/konnected/manifest.json +++ b/homeassistant/components/konnected/manifest.json @@ -1,8 +1,21 @@ { "domain": "konnected", "name": "Konnected", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/konnected", - "requirements": ["konnected==0.1.5"], - "dependencies": ["http"], - "codeowners": ["@heythisisnate"] + "requirements": [ + "konnected==1.1.0" + ], + "ssdp": [ + { + "manufacturer": "konnected.io" + } + ], + "dependencies": [ + "http" + ], + "codeowners": [ + "@heythisisnate", + "@kit-klein" + ] } diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py new file mode 100644 index 00000000000000..2668a382ccc21d --- /dev/null +++ b/homeassistant/components/konnected/panel.py @@ -0,0 +1,362 @@ +"""Support for Konnected devices.""" +import asyncio +import logging + +import konnected + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_STATE, + CONF_ACCESS_TOKEN, + CONF_BINARY_SENSORS, + CONF_DEVICES, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PIN, + CONF_PORT, + CONF_SENSORS, + CONF_SWITCHES, + CONF_TYPE, + CONF_ZONE, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_ACTIVATION, + CONF_API_HOST, + CONF_BLINK, + CONF_DEFAULT_OPTIONS, + CONF_DHT_SENSORS, + CONF_DISCOVERY, + CONF_DS18B20_SENSORS, + CONF_INVERSE, + CONF_MOMENTARY, + CONF_PAUSE, + CONF_POLL_INTERVAL, + CONF_REPEAT, + DOMAIN, + ENDPOINT_ROOT, + SIGNAL_SENSOR_UPDATE, + STATE_LOW, + ZONE_TO_PIN, +) +from .errors import CannotConnect + +_LOGGER = logging.getLogger(__name__) + +KONN_MODEL = "Konnected" +KONN_MODEL_PRO = "Konnected Pro" + +# Indicate how each unit is controlled (pin or zone) +KONN_API_VERSIONS = { + KONN_MODEL: CONF_PIN, + KONN_MODEL_PRO: CONF_ZONE, +} + + +class AlarmPanel: + """A representation of a Konnected alarm panel.""" + + def __init__(self, hass, config_entry): + """Initialize the Konnected device.""" + self.hass = hass + self.config_entry = config_entry + self.config = config_entry.data + self.options = config_entry.options or config_entry.data.get( + CONF_DEFAULT_OPTIONS, {} + ) + self.host = self.config.get(CONF_HOST) + self.port = self.config.get(CONF_PORT) + self.client = None + self.status = None + self.api_version = KONN_API_VERSIONS[KONN_MODEL] + + @property + def device_id(self): + """Device id is the MAC address as string with punctuation removed.""" + return self.config.get(CONF_ID) + + @property + def stored_configuration(self): + """Return the configuration stored in `hass.data` for this device.""" + return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) + + def format_zone(self, zone, other_items=None): + """Get zone or pin based dict based on the client type.""" + payload = { + self.api_version: zone + if self.api_version == CONF_ZONE + else ZONE_TO_PIN[zone] + } + payload.update(other_items or {}) + return payload + + async def async_connect(self): + """Connect to and setup a Konnected device.""" + try: + self.client = konnected.Client( + host=self.host, + port=str(self.port), + websession=aiohttp_client.async_get_clientsession(self.hass), + ) + self.status = await self.client.get_status() + self.api_version = KONN_API_VERSIONS.get( + self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL] + ) + _LOGGER.info( + "Connected to new %s device", self.status.get("model", "Konnected") + ) + _LOGGER.debug(self.status) + + await self.async_update_initial_states() + # brief delay to allow processing of recent status req + await asyncio.sleep(0.1) + await self.async_sync_device_config() + + except self.client.ClientError as err: + _LOGGER.warning("Exception trying to connect to panel: %s", err) + raise CannotConnect + + _LOGGER.info( + "Set up Konnected device %s. Open http://%s:%s in a " + "web browser to view device status", + self.device_id, + self.host, + self.port, + ) + + device_registry = await dr.async_get_registry(self.hass) + + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))}, + identifiers={(DOMAIN, self.device_id)}, + manufacturer="Konnected.io", + name=self.config_entry.title, + model=self.config_entry.title, + sw_version=self.status.get("swVersion"), + ) + + async def update_switch(self, zone, state, momentary=None, times=None, pause=None): + """Update the state of a switchable output.""" + try: + if self.client: + if self.api_version == CONF_ZONE: + return await self.client.put_zone( + zone, state, momentary, times, pause, + ) + + # device endpoint uses pin number instead of zone + return await self.client.put_device( + ZONE_TO_PIN[zone], state, momentary, times, pause, + ) + + except self.client.ClientError as err: + _LOGGER.warning("Exception trying to update panel: %s", err) + + raise CannotConnect + + async def async_save_data(self): + """Save the device configuration to `hass.data`.""" + binary_sensors = {} + for entity in self.options.get(CONF_BINARY_SENSORS) or []: + zone = entity[CONF_ZONE] + + binary_sensors[zone] = { + CONF_TYPE: entity[CONF_TYPE], + CONF_NAME: entity.get( + CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}" + ), + CONF_INVERSE: entity.get(CONF_INVERSE), + ATTR_STATE: None, + } + _LOGGER.debug( + "Set up binary_sensor %s (initial state: %s)", + binary_sensors[zone].get("name"), + binary_sensors[zone].get(ATTR_STATE), + ) + + actuators = [] + for entity in self.options.get(CONF_SWITCHES) or []: + zone = entity[CONF_ZONE] + + act = { + CONF_ZONE: zone, + CONF_NAME: entity.get( + CONF_NAME, f"Konnected {self.device_id[6:]} Actuator {zone}", + ), + ATTR_STATE: None, + CONF_ACTIVATION: entity[CONF_ACTIVATION], + CONF_MOMENTARY: entity.get(CONF_MOMENTARY), + CONF_PAUSE: entity.get(CONF_PAUSE), + CONF_REPEAT: entity.get(CONF_REPEAT), + } + actuators.append(act) + _LOGGER.debug("Set up switch %s", act) + + sensors = [] + for entity in self.options.get(CONF_SENSORS) or []: + zone = entity[CONF_ZONE] + + sensor = { + CONF_ZONE: zone, + CONF_NAME: entity.get( + CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}" + ), + CONF_TYPE: entity[CONF_TYPE], + CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL), + } + sensors.append(sensor) + _LOGGER.debug( + "Set up %s sensor %s (initial state: %s)", + sensor.get(CONF_TYPE), + sensor.get(CONF_NAME), + sensor.get(ATTR_STATE), + ) + + device_data = { + CONF_BINARY_SENSORS: binary_sensors, + CONF_SENSORS: sensors, + CONF_SWITCHES: actuators, + CONF_BLINK: self.options.get(CONF_BLINK), + CONF_DISCOVERY: self.options.get(CONF_DISCOVERY), + CONF_HOST: self.host, + CONF_PORT: self.port, + "panel": self, + } + + if CONF_DEVICES not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][CONF_DEVICES] = {} + + _LOGGER.debug( + "Storing data in hass.data[%s][%s][%s]: %s", + DOMAIN, + CONF_DEVICES, + self.device_id, + device_data, + ) + self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data + + @callback + def async_binary_sensor_configuration(self): + """Return the configuration map for syncing binary sensors.""" + return [ + self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS] + ] + + @callback + def async_actuator_configuration(self): + """Return the configuration map for syncing actuators.""" + return [ + self.format_zone( + data[CONF_ZONE], + {"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)}, + ) + for data in self.stored_configuration[CONF_SWITCHES] + ] + + @callback + def async_dht_sensor_configuration(self): + """Return the configuration map for syncing DHT sensors.""" + return [ + self.format_zone( + sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} + ) + for sensor in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == "dht" + ] + + @callback + def async_ds18b20_sensor_configuration(self): + """Return the configuration map for syncing DS18B20 sensors.""" + return [ + self.format_zone(sensor[CONF_ZONE]) + for sensor in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == "ds18b20" + ] + + async def async_update_initial_states(self): + """Update the initial state of each sensor from status poll.""" + for sensor_data in self.status.get("sensors"): + sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get( + sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {} + ) + entity_id = sensor_config.get(ATTR_ENTITY_ID) + + state = bool(sensor_data.get(ATTR_STATE)) + if sensor_config.get(CONF_INVERSE): + state = not state + + async_dispatcher_send( + self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state + ) + + @callback + def async_desired_settings_payload(self): + """Return a dict representing the desired device configuration.""" + desired_api_host = ( + self.hass.data[DOMAIN].get(CONF_API_HOST) or self.hass.config.api.base_url + ) + desired_api_endpoint = desired_api_host + ENDPOINT_ROOT + + return { + "sensors": self.async_binary_sensor_configuration(), + "actuators": self.async_actuator_configuration(), + "dht_sensors": self.async_dht_sensor_configuration(), + "ds18b20_sensors": self.async_ds18b20_sensor_configuration(), + "auth_token": self.config.get(CONF_ACCESS_TOKEN), + "endpoint": desired_api_endpoint, + "blink": self.options.get(CONF_BLINK, True), + "discovery": self.options.get(CONF_DISCOVERY, True), + } + + @callback + def async_current_settings_payload(self): + """Return a dict of configuration currently stored on the device.""" + settings = self.status["settings"] + if not settings: + settings = {} + + return { + "sensors": [ + {self.api_version: s[self.api_version]} + for s in self.status.get("sensors") + ], + "actuators": self.status.get("actuators"), + "dht_sensors": self.status.get(CONF_DHT_SENSORS), + "ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS), + "auth_token": settings.get("token"), + "endpoint": settings.get("endpoint"), + "blink": settings.get(CONF_BLINK), + "discovery": settings.get(CONF_DISCOVERY), + } + + async def async_sync_device_config(self): + """Sync the new zone configuration to the Konnected device if needed.""" + _LOGGER.debug( + "Device %s settings payload: %s", + self.device_id, + self.async_desired_settings_payload(), + ) + if ( + self.async_desired_settings_payload() + != self.async_current_settings_payload() + ): + _LOGGER.info("pushing settings to device %s", self.device_id) + await self.client.put_settings(**self.async_desired_settings_payload()) + + +async def get_status(hass, host, port): + """Get the status of a Konnected Panel.""" + client = konnected.Client( + host, str(port), aiohttp_client.async_get_clientsession(hass) + ) + try: + return await client.get_status() + + except client.ClientError as err: + _LOGGER.error("Exception trying to get panel status: %s", err) + raise CannotConnect diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 7498f2bde1d601..d189ac8809afd2 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -4,9 +4,9 @@ from homeassistant.const import ( CONF_DEVICES, CONF_NAME, - CONF_PIN, CONF_SENSORS, CONF_TYPE, + CONF_ZONE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, @@ -25,13 +25,10 @@ } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up sensors attached to a Konnected device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info["device_id"] + device_id = config_entry.data["id"] sensors = [] # Initialize all DHT sensors. @@ -53,7 +50,7 @@ def async_add_ds18b20(attrs): ( s for s in data[CONF_DEVICES][device_id][CONF_SENSORS] - if s[CONF_TYPE] == "ds18b20" and s[CONF_PIN] == attrs.get(CONF_PIN) + if s[CONF_TYPE] == "ds18b20" and s[CONF_ZONE] == attrs.get(CONF_ZONE) ), None, ) @@ -85,10 +82,10 @@ def __init__(self, device_id, data, sensor_type, addr=None, initial_state=None): self._data = data self._device_id = device_id self._type = sensor_type - self._pin_num = self._data.get(CONF_PIN) + self._zone_num = self._data.get(CONF_ZONE) self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._unique_id = addr or "{}-{}-{}".format( - device_id, self._pin_num, sensor_type + device_id, self._zone_num, sensor_type ) # set initial state if known at initialization @@ -99,7 +96,7 @@ def __init__(self, device_id, data, sensor_type, addr=None, initial_state=None): # set entity name if given self._name = self._data.get(CONF_NAME) if self._name: - self._name += " " + SENSOR_TYPES[sensor_type][0] + self._name += f" {SENSOR_TYPES[sensor_type][0]}" @property def unique_id(self) -> str: @@ -121,6 +118,13 @@ def unit_of_measurement(self): """Return the unit of measurement.""" return self._unit_of_measurement + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, + } + async def async_added_to_hass(self): """Store entity_id and register state change callback.""" entity_id_key = self._addr or self._type diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json new file mode 100644 index 00000000000000..4d923238df4614 --- /dev/null +++ b/homeassistant/components/konnected/strings.json @@ -0,0 +1,105 @@ +{ + "config": { + "title": "Konnected.io", + "step": { + "import_confirm": { + "title": "Import Konnected Device", + "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." + }, + "user": { + "title": "Discover Konnected Device", + "description": "Please enter the host information for your Konnected Panel.", + "data": { + "host": "Konnected device IP address", + "port": "Konnected device port" + } + }, + "confirm": { + "title": "Konnected Device Ready", + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." + } + }, + "error": { + "cannot_connect": "Unable to connect to a Konnected Panel at {host}:{port}" + }, + "abort": { + "unknown": "Unknown error occurred", + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "not_konn_panel": "Not a recognized Konnected.io device" + } + }, + "options": { + "title": "Konnected Alarm Panel Options", + "step": { + "options_io": { + "title": "Configure I/O", + "description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.", + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + } + }, + "options_io_ext": { + "title": "Configure Extended I/O", + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "data": { + "8": "Zone 8", + "9": "Zone 9", + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "out1": "OUT1", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2" + } + }, + "options_binary": { + "title": "Configure Binary Sensor", + "description": "Please select the options for the binary sensor attached to {zone}", + "data": { + "type": "Binary Sensor Type", + "name": "Name (optional)", + "inverse": "Invert the open/close state" + } + }, + "options_digital": { + "title": "Configure Digital Sensor", + "description": "Please select the options for the digital sensor attached to {zone}", + "data": { + "type": "Sensor Type", + "name": "Name (optional)", + "poll_interval": "Poll Interval (minutes) (optional)" + } + }, + "options_switch": { + "title": "Configure Switchable Output", + "description": "Please select the output options for {zone}", + "data": { + "name": "Name (optional)", + "activation": "Output when on", + "momentary": "Pulse duration (ms) (optional)", + "pause": "Pause between pulses (ms) (optional)", + "repeat": "Times to repeat (-1=infinite) (optional)" + } + }, + "options_misc": { + "title": "Configure Misc", + "description": "Please select the desired behavior for your panel", + "data": { + "blink": "Blink panel LED on when sending state change" + } + } + }, + "error": {}, + "abort": { + "not_konn_panel": "Not a recognized Konnected.io device" + } + } +} diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index a88281826c0520..d16051eb8da9e0 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -5,12 +5,12 @@ ATTR_STATE, CONF_DEVICES, CONF_NAME, - CONF_PIN, CONF_SWITCHES, + CONF_ZONE, ) from homeassistant.helpers.entity import ToggleEntity -from . import ( +from .const import ( CONF_ACTIVATION, CONF_MOMENTARY, CONF_PAUSE, @@ -23,16 +23,13 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set switches attached to a Konnected device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info["device_id"] + device_id = config_entry.data["id"] switches = [ - KonnectedSwitch(device_id, pin_data.get(CONF_PIN), pin_data) - for pin_data in data[CONF_DEVICES][device_id][CONF_SWITCHES] + KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data) + for zone_data in data[CONF_DEVICES][device_id][CONF_SWITCHES] ] async_add_entities(switches) @@ -40,11 +37,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class KonnectedSwitch(ToggleEntity): """Representation of a Konnected switch.""" - def __init__(self, device_id, pin_num, data): + def __init__(self, device_id, zone_num, data): """Initialize the Konnected switch.""" self._data = data self._device_id = device_id - self._pin_num = pin_num + self._zone_num = zone_num self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH) self._momentary = self._data.get(CONF_MOMENTARY) self._pause = self._data.get(CONF_PAUSE) @@ -52,7 +49,7 @@ def __init__(self, device_id, pin_num, data): self._state = self._boolean_state(self._data.get(ATTR_STATE)) self._name = self._data.get(CONF_NAME) self._unique_id = "{}-{}-{}-{}-{}".format( - device_id, self._pin_num, self._momentary, self._pause, self._repeat + device_id, self._zone_num, self._momentary, self._pause, self._repeat ) @property @@ -71,16 +68,22 @@ def is_on(self): return self._state @property - def client(self): + def panel(self): """Return the Konnected HTTP client.""" - return self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id].get( - "client" - ) + device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] + return device_data.get("panel") - def turn_on(self, **kwargs): + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, + } + + async def async_turn_on(self, **kwargs): """Send a command to turn on the switch.""" - resp = self.client.put_device( - self._pin_num, + resp = await self.panel.update_switch( + self._zone_num, int(self._activation == STATE_HIGH), self._momentary, self._repeat, @@ -94,9 +97,11 @@ def turn_on(self, **kwargs): # Immediately set the state back off for momentary switches self._set_state(False) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Send a command to turn off the switch.""" - resp = self.client.put_device(self._pin_num, int(self._activation == STATE_LOW)) + resp = await self.panel.update_switch( + self._zone_num, int(self._activation == STATE_LOW) + ) if resp.get(ATTR_STATE) is not None: self._set_state(self._boolean_state(resp.get(ATTR_STATE))) @@ -111,9 +116,9 @@ def _boolean_state(self, int_state): def _set_state(self, state): self._state = state - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() _LOGGER.debug( - "Setting status of %s actuator pin %s to %s", + "Setting status of %s actuator zone %s to %s", self._device_id, self.name, state, diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 68d727626cfc9c..1a5b7a56e8e801 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -1,4 +1,5 @@ """Sensor for Last.fm account status.""" +import hashlib import logging import re @@ -54,6 +55,7 @@ class LastfmSensor(Entity): def __init__(self, user, lastfm_api): """Initialize the sensor.""" + self._unique_id = hashlib.sha256(user.encode("utf-8")).hexdigest() self._user = lastfm_api.get_user(user) self._name = user self._lastfm = lastfm_api @@ -63,16 +65,16 @@ def __init__(self, user, lastfm_api): self._topplayed = None self._cover = None + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._unique_id + @property def name(self): """Return the name of the sensor.""" return self._name - @property - def entity_id(self): - """Return the entity ID.""" - return f"sensor.lastfm_{self._name}" - @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index c35a0cc00bf95d..1a5d4475b0e962 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -7,6 +7,7 @@ CONF_BRIGHTNESS, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv @@ -178,7 +179,7 @@ class VarAbs(LcnServiceCall): """Set absolute value of a variable or setpoint. Variable has to be set as counter! - Reguator setpoints can also be set using R1VARSETPOINT, R2VARSETPOINT. + Regulator setpoints can also be set using R1VARSETPOINT, R2VARSETPOINT. """ schema = LcnServiceCall.schema.extend( @@ -281,7 +282,7 @@ class SendKeys(LcnServiceCall): vol.Upper, vol.In(SENDKEYCOMMANDS) ), vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), - vol.Optional(CONF_TIME_UNIT, default="s"): vol.All( + vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.All( vol.Upper, vol.In(TIME_UNITS) ), } @@ -324,7 +325,7 @@ class LockKeys(LcnServiceCall): ), vol.Required(CONF_STATE): is_key_lock_states_string, vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), - vol.Optional(CONF_TIME_UNIT, default="s"): vol.All( + vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.All( vol.Upper, vol.In(TIME_UNITS) ), } diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 0be51c337e865a..cb91257f83d6a6 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -1,5 +1,5 @@ """Support for LG TV running on NetCast 3 or 4.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging from pylgnetcast import LgNetCastClient, LgNetCastError @@ -16,6 +16,7 @@ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) @@ -28,11 +29,14 @@ STATE_PLAYING, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "LG TV Remote" +CONF_ON_ACTION = "turn_on_action" + MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) @@ -49,6 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -62,20 +67,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): host = config.get(CONF_HOST) access_token = config.get(CONF_ACCESS_TOKEN) name = config.get(CONF_NAME) + on_action = config.get(CONF_ON_ACTION) client = LgNetCastClient(host, access_token) + on_action_script = Script(hass, on_action) if on_action else None - add_entities([LgTVDevice(client, name)], True) + add_entities([LgTVDevice(client, name, on_action_script)], True) class LgTVDevice(MediaPlayerDevice): """Representation of a LG TV.""" - def __init__(self, client, name): + def __init__(self, client, name, on_action_script): """Initialize the LG TV device.""" self._client = client self._name = name self._muted = False + self._on_action_script = on_action_script # Assume that the TV is in Play mode self._playing = True self._volume = 0 @@ -112,6 +120,10 @@ def update(self): channel_info = channel_info[0] self._channel_name = channel_info.find("chname").text self._program_name = channel_info.find("progName").text + if self._channel_name is None: + self._channel_name = channel_info.find("inputSourceName").text + if self._program_name is None: + self._program_name = channel_info.find("labelName").text channel_list = client.query_data("channel_list") if channel_list: @@ -180,17 +192,26 @@ def media_title(self): @property def supported_features(self): """Flag media player features that are supported.""" + if self._on_action_script: + return SUPPORT_LGTV | SUPPORT_TURN_ON return SUPPORT_LGTV @property def media_image_url(self): """URL for obtaining a screen capture.""" - return self._client.url + "data?target=screen_image" + return ( + f"{self._client.url}data?target=screen_image&_={datetime.now().timestamp()}" + ) def turn_off(self): """Turn off media player.""" self.send_command(1) + def turn_on(self): + """Turn on the media player.""" + if self._on_action_script: + self._on_action_script.run() + def volume_up(self): """Volume up the media player.""" self.send_command(24) diff --git a/homeassistant/components/life360/.translations/hu.json b/homeassistant/components/life360/.translations/hu.json index 227e784b0653eb..7f158a24622f4b 100644 --- a/homeassistant/components/life360/.translations/hu.json +++ b/homeassistant/components/life360/.translations/hu.json @@ -1,7 +1,16 @@ { "config": { "error": { + "invalid_username": "\u00c9rv\u00e9nytelen felhaszn\u00e1l\u00f3n\u00e9v", "unexpected": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt a kommunik\u00e1ci\u00f3ban a Life360 szerverrel" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/life360/.translations/pl.json b/homeassistant/components/life360/.translations/pl.json index e9cd992030442b..f82c832582811d 100644 --- a/homeassistant/components/life360/.translations/pl.json +++ b/homeassistant/components/life360/.translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce", - "user_already_configured": "Konto jest ju\u017c skonfigurowane" + "user_already_configured": "Konto jest ju\u017c skonfigurowane." }, "create_entry": { "default": "Aby skonfigurowa\u0107 zaawansowane ustawienia, zapoznaj si\u0119 z [dokumentacj\u0105 Life360]({docs_url})." @@ -11,7 +11,7 @@ "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce", "invalid_username": "Nieprawid\u0142owa nazwa u\u017cytkownika", "unexpected": "Nieoczekiwany b\u0142\u0105d komunikacji z serwerem Life360", - "user_already_configured": "Konto jest ju\u017c skonfigurowane" + "user_already_configured": "Konto jest ju\u017c skonfigurowane." }, "step": { "user": { diff --git a/homeassistant/components/life360/.translations/sv.json b/homeassistant/components/life360/.translations/sv.json index 836680aad6a688..ba28d973ec3899 100644 --- a/homeassistant/components/life360/.translations/sv.json +++ b/homeassistant/components/life360/.translations/sv.json @@ -10,6 +10,7 @@ "error": { "invalid_credentials": "Ogiltiga autentiseringsuppgifter", "invalid_username": "Ogiltigt anv\u00e4ndarnmn", + "unexpected": "Ov\u00e4ntat fel vid kommunikation med Life360-servern", "user_already_configured": "Konto har redan konfigurerats" }, "step": { diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index b7b0415a1b36c1..6f4255735e0354 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -5,9 +5,9 @@ from life360 import Life360Error import voluptuous as vol -from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL -from homeassistant.components.device_tracker.const import ( - ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT, +from homeassistant.components.device_tracker import ( + CONF_SCAN_INTERVAL, + DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.components.zone import async_active_zone from homeassistant.const import ( @@ -180,14 +180,14 @@ def _prev_seen(self, dev_id, last_seen): if overdue and not reported and now - self._started > EVENT_DELAY: self._hass.bus.fire( EVENT_UPDATE_OVERDUE, - {ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id)}, + {ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}"}, ) reported = True elif not overdue and reported: self._hass.bus.fire( EVENT_UPDATE_RESTORED, { - ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id), + ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}", ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split( "." )[0], diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 4e845a07854208..5bc0c1bc53b1ac 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -39,6 +39,7 @@ ATTR_ENTITY_ID, ATTR_MODE, ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback @@ -374,6 +375,9 @@ async def start_effect(self, entities, service, **kwargs): async def async_service_to_entities(self, service): """Return the known entities that a service call mentions.""" + if service.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: + return [] + if service.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: return self.entities.values() diff --git a/homeassistant/components/lifx_legacy/light.py b/homeassistant/components/lifx_legacy/light.py index 8f767a2f5594a2..7fb0e686b31606 100644 --- a/homeassistant/components/lifx_legacy/light.py +++ b/homeassistant/components/lifx_legacy/light.py @@ -3,9 +3,6 @@ This is a legacy platform, included because the current lifx platform does not yet support Windows. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.lifx/ """ import logging diff --git a/homeassistant/components/light/.translations/sv.json b/homeassistant/components/light/.translations/sv.json new file mode 100644 index 00000000000000..8df3f3d382bfb8 --- /dev/null +++ b/homeassistant/components/light/.translations/sv.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "V\u00e4xla {entity_name}", + "turn_off": "St\u00e4ng av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e4r avst\u00e4ngd", + "is_on": "{entity_name} \u00e4r p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} avst\u00e4ngd", + "turned_on": "{entity_name} slogs p\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/zh-Hant.json b/homeassistant/components/light/.translations/zh-Hant.json index 5ac06129463b35..d8bda90de85742 100644 --- a/homeassistant/components/light/.translations/zh-Hant.json +++ b/homeassistant/components/light/.translations/zh-Hant.json @@ -1,17 +1,17 @@ { "device_automation": { "action_type": { - "toggle": "\u5207\u63db {entity_name}", - "turn_off": "\u95dc\u9589 {entity_name}", - "turn_on": "\u958b\u555f {entity_name}" + "toggle": "\u5207\u63db{entity_name}", + "turn_off": "\u95dc\u9589{entity_name}", + "turn_on": "\u958b\u555f{entity_name}" }, "condition_type": { - "is_off": "{entity_name} \u5df2\u95dc\u9589", - "is_on": "{entity_name} \u5df2\u958b\u555f" + "is_off": "{entity_name}\u5df2\u95dc\u9589", + "is_on": "{entity_name}\u5df2\u958b\u555f" }, "trigger_type": { - "turned_off": "{entity_name} \u5df2\u95dc\u9589", - "turned_on": "{entity_name} \u5df2\u958b\u555f" + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" } } } \ No newline at end of file diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 791f7328cf80f2..5b9b923cc56f8c 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,5 +1,4 @@ """Provides functionality to interact with lights.""" -import asyncio import csv from datetime import timedelta import logging @@ -8,15 +7,12 @@ import voluptuous as vol -from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.exceptions import Unauthorized, UnknownUser import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -61,6 +57,8 @@ # Brightness of the light, 0..255 or percentage ATTR_BRIGHTNESS = "brightness" ATTR_BRIGHTNESS_PCT = "brightness_pct" +ATTR_BRIGHTNESS_STEP = "brightness_step" +ATTR_BRIGHTNESS_STEP_PCT = "brightness_step_pct" # String representing a profile (built-in ones or external defined). ATTR_PROFILE = "profile" @@ -87,12 +85,16 @@ VALID_TRANSITION = vol.All(vol.Coerce(float), vol.Clamp(min=0, max=6553)) VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)) VALID_BRIGHTNESS_PCT = vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) +VALID_BRIGHTNESS_STEP = vol.All(vol.Coerce(int), vol.Clamp(min=-255, max=255)) +VALID_BRIGHTNESS_STEP_PCT = vol.All(vol.Coerce(float), vol.Clamp(min=-100, max=100)) LIGHT_TURN_ON_SCHEMA = { vol.Exclusive(ATTR_PROFILE, COLOR_GROUP): cv.string, ATTR_TRANSITION: VALID_TRANSITION, - ATTR_BRIGHTNESS: VALID_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, + vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, + vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): vol.All( vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple) @@ -169,7 +171,7 @@ def preprocess_turn_off(params): """Process data for turning light off if brightness is 0.""" if ATTR_BRIGHTNESS in params and params[ATTR_BRIGHTNESS] == 0: # Zero brightness: Light will be turned off - params = {k: v for k, v in params.items() if k in [ATTR_TRANSITION, ATTR_FLASH]} + params = {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} return (True, params) # Light should be turned off return (False, None) # Light should be turned on @@ -187,70 +189,65 @@ async def async_setup(hass, config): if not profiles_valid: return False - async def async_handle_light_on_service(service): - """Handle a turn light on service call.""" - # Get the validated data - params = service.data.copy() - - # Convert the entity ids to valid light ids - target_lights = await component.async_extract_from_service(service) - params.pop(ATTR_ENTITY_ID, None) - - if service.context.user_id: - user = await hass.auth.async_get_user(service.context.user_id) - if user is None: - raise UnknownUser(context=service.context) - - entity_perms = user.permissions.check_entity - - for light in target_lights: - if not entity_perms(light, POLICY_CONTROL): - raise Unauthorized( - context=service.context, - entity_id=light, - permission=POLICY_CONTROL, - ) - - preprocess_turn_on_alternatives(params) - turn_lights_off, off_params = preprocess_turn_off(params) - - poll_lights = [] - change_tasks = [] - for light in target_lights: - light.async_set_context(service.context) - - pars = params - off_pars = off_params - turn_light_off = turn_lights_off - if not pars: - pars = params.copy() - pars[ATTR_PROFILE] = Profiles.get_default(light.entity_id) - preprocess_turn_on_alternatives(pars) - turn_light_off, off_pars = preprocess_turn_off(pars) - if turn_light_off: - task = light.async_request_call(light.async_turn_off(**off_pars)) - else: - task = light.async_request_call(light.async_turn_on(**pars)) + def preprocess_data(data): + """Preprocess the service data.""" + base = {} - change_tasks.append(task) + for entity_field in cv.ENTITY_SERVICE_FIELDS: + if entity_field in data: + base[entity_field] = data.pop(entity_field) - if light.should_poll: - poll_lights.append(light) + preprocess_turn_on_alternatives(data) + turn_lights_off, off_params = preprocess_turn_off(data) - if change_tasks: - await asyncio.wait(change_tasks) + base["params"] = data + base["turn_lights_off"] = turn_lights_off + base["off_params"] = off_params - if poll_lights: - await asyncio.wait( - [light.async_update_ha_state(True) for light in poll_lights] - ) + return base + + async def async_handle_light_on_service(light, call): + """Handle turning a light on. + + If brightness is set to 0, this service will turn the light off. + """ + params = call.data["params"] + turn_light_off = call.data["turn_lights_off"] + off_params = call.data["off_params"] + + if not params: + default_profile = Profiles.get_default(light.entity_id) + + if default_profile is not None: + params = {ATTR_PROFILE: default_profile} + preprocess_turn_on_alternatives(params) + turn_light_off, off_params = preprocess_turn_off(params) + + elif ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params: + brightness = light.brightness if light.is_on else 0 + + params = params.copy() + + if ATTR_BRIGHTNESS_STEP in params: + brightness += params.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness += int(params.pop(ATTR_BRIGHTNESS_STEP_PCT) / 100 * 255) + + params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + turn_light_off, off_params = preprocess_turn_off(params) + + if turn_light_off: + await light.async_turn_off(**off_params) + else: + await light.async_turn_on(**params) # Listen for light on and light off service calls. - hass.services.async_register( - DOMAIN, + + component.async_register_entity_service( SERVICE_TURN_ON, + vol.All(cv.make_entity_service_schema(LIGHT_TURN_ON_SCHEMA), preprocess_data), async_handle_light_on_service, - schema=cv.make_entity_service_schema(LIGHT_TURN_ON_SCHEMA), ) component.async_register_entity_service( diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index c436ce7886ae69..99f5b6b12bc387 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -4,13 +4,32 @@ import voluptuous as vol from homeassistant.components.device_automation import toggle_entity -from homeassistant.const import CONF_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_DOMAIN, + CONF_TYPE, + SERVICE_TURN_ON, +) from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from . import ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +TYPE_BRIGHTNESS_INCREASE = "brightness_increase" +TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_TYPE): vol.In( + toggle_entity.DEVICE_ACTION_TYPES + + [TYPE_BRIGHTNESS_INCREASE, TYPE_BRIGHTNESS_DECREASE] + ), + } +) async def async_call_action_from_config( @@ -20,11 +39,57 @@ async def async_call_action_from_config( context: Context, ) -> None: """Change state based on configuration.""" - await toggle_entity.async_call_action_from_config( - hass, config, variables, context, DOMAIN + if config[CONF_TYPE] in toggle_entity.DEVICE_ACTION_TYPES: + await toggle_entity.async_call_action_from_config( + hass, config, variables, context, DOMAIN + ) + return + + data = {ATTR_ENTITY_ID: config[ATTR_ENTITY_ID]} + + if config[CONF_TYPE] == TYPE_BRIGHTNESS_INCREASE: + data[ATTR_BRIGHTNESS_STEP_PCT] = 10 + else: + data[ATTR_BRIGHTNESS_STEP_PCT] = -10 + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=context ) async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: """List device actions.""" - return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) + actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) + + registry = await entity_registry.async_get_registry(hass) + + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + if state: + supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + else: + supported_features = entry.supported_features + + if supported_features & SUPPORT_BRIGHTNESS: + actions.extend( + ( + { + CONF_TYPE: TYPE_BRIGHTNESS_INCREASE, + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": DOMAIN, + }, + { + CONF_TYPE: TYPE_BRIGHTNESS_DECREASE, + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": DOMAIN, + }, + ) + ) + + return actions diff --git a/homeassistant/components/light/device_condition.py b/homeassistant/components/light/device_condition.py index d27953749f67d2..1d9323907f2048 100644 --- a/homeassistant/components/light/device_condition.py +++ b/homeassistant/components/light/device_condition.py @@ -5,7 +5,7 @@ from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.condition import ConditionCheckerType from homeassistant.helpers.typing import ConfigType @@ -16,6 +16,7 @@ ) +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> ConditionCheckerType: diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index ea8899c44fc1d6..c172ac1330a4cc 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -1,6 +1,7 @@ """Intents for the light integration.""" import voluptuous as vol +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv @@ -8,7 +9,6 @@ from . import ( ATTR_BRIGHTNESS_PCT, - ATTR_ENTITY_ID, ATTR_RGB_COLOR, DOMAIN, SERVICE_TURN_ON, diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 449e5ea5aaf6d1..a2b71f5632bb25 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -36,6 +36,12 @@ turn_on: brightness_pct: description: Number between 0..100 indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. example: 47 + brightness_step: + description: Change brightness by an amount. Should be between -255..255. + example: -25.5 + brightness_step_pct: + description: Change brightness by a percentage. Should be between -100..100. + example: -10 profile: description: Name of a light profile to use. example: relax diff --git a/homeassistant/components/linky/.translations/bg.json b/homeassistant/components/linky/.translations/bg.json index 6eeb898ee1ffc8..cc5239eaf3c56a 100644 --- a/homeassistant/components/linky/.translations/bg.json +++ b/homeassistant/components/linky/.translations/bg.json @@ -1,13 +1,9 @@ { "config": { - "abort": { - "username_exists": "\u0412\u0435\u0447\u0435 \u0438\u043c\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d \u043f\u0440\u043e\u0444\u0438\u043b" - }, "error": { "access": "\u041d\u044f\u043c\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e Enedis.fr, \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e\u0441\u0442\u0442\u0430 \u0441\u0438", "enedis": "Enedis.fr \u043e\u0442\u0433\u043e\u0432\u043e\u0440\u0438 \u0441 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", - "username_exists": "\u0412\u0435\u0447\u0435 \u0438\u043c\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d \u043f\u0440\u043e\u0444\u0438\u043b", "wrong_login": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0432\u043b\u0438\u0437\u0430\u043d\u0435: \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0438\u043c\u0435\u0439\u043b\u0430 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0441\u0438" }, "step": { diff --git a/homeassistant/components/linky/.translations/ca.json b/homeassistant/components/linky/.translations/ca.json index ca437417f590db..9c4a3a190678f4 100644 --- a/homeassistant/components/linky/.translations/ca.json +++ b/homeassistant/components/linky/.translations/ca.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "El compte ja ha estat configurat" + "already_configured": "El compte ja ha estat configurat" }, "error": { "access": "No s'ha pogut accedir a Enedis.fr, comprova la teva connexi\u00f3 a Internet", "enedis": "Enedis.fr ha respost amb un error: torna-ho a provar m\u00e9s tard (millo no entre les 23:00 i les 14:00)", "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard (millor no entre les 23:00 i les 14:00)", - "username_exists": "El compte ja ha estat configurat", "wrong_login": "Error d\u2019inici de sessi\u00f3: comprova el teu correu electr\u00f2nic i la contrasenya" }, "step": { diff --git a/homeassistant/components/linky/.translations/cs.json b/homeassistant/components/linky/.translations/cs.json new file mode 100644 index 00000000000000..f914f0f5a1c150 --- /dev/null +++ b/homeassistant/components/linky/.translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nakonfigurov\u00e1n" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/da.json b/homeassistant/components/linky/.translations/da.json index cacad99de584bb..2097d094a3a367 100644 --- a/homeassistant/components/linky/.translations/da.json +++ b/homeassistant/components/linky/.translations/da.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Kontoen er allerede konfigureret" + "already_configured": "Kontoen er allerede konfigureret" }, "error": { "access": "Kunne ikke f\u00e5 adgang til Enedis.fr, kontroller din internetforbindelse", "enedis": "Enedis.fr svarede med en fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", "unknown": "Ukendt fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", - "username_exists": "Kontoen er allerede konfigureret", "wrong_login": "Loginfejl: Kontroller din e-mail og adgangskode" }, "step": { diff --git a/homeassistant/components/linky/.translations/de.json b/homeassistant/components/linky/.translations/de.json index 3fc13126270c66..83e56a52c6c29b 100644 --- a/homeassistant/components/linky/.translations/de.json +++ b/homeassistant/components/linky/.translations/de.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Konto bereits konfiguriert" + "already_configured": "Konto bereits konfiguriert" }, "error": { "access": "Konnte nicht auf Enedis.fr zugreifen, \u00fcberpr\u00fcfe bitte die Internetverbindung", "enedis": "Enedis.fr antwortete mit einem Fehler: wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", "unknown": "Unbekannter Fehler: Wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", - "username_exists": "Konto bereits konfiguriert", "wrong_login": "Login-Fehler: Pr\u00fcfe bitte E-Mail & Passwort" }, "step": { diff --git a/homeassistant/components/linky/.translations/en.json b/homeassistant/components/linky/.translations/en.json index 6c655b835811cd..13d2553b0c7f4d 100644 --- a/homeassistant/components/linky/.translations/en.json +++ b/homeassistant/components/linky/.translations/en.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Account already configured" + "already_configured": "Account already configured" }, "error": { "access": "Could not access to Enedis.fr, please check your internet connection", "enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)", "unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)", - "username_exists": "Account already configured", "wrong_login": "Login error: please check your email & password" }, "step": { diff --git a/homeassistant/components/linky/.translations/es-419.json b/homeassistant/components/linky/.translations/es-419.json index 130a856826e9d7..5bddb534146db9 100644 --- a/homeassistant/components/linky/.translations/es-419.json +++ b/homeassistant/components/linky/.translations/es-419.json @@ -1,13 +1,9 @@ { "config": { - "abort": { - "username_exists": "La cuenta ya ha sido configurada" - }, "error": { "access": "No se pudo acceder a Enedis.fr, compruebe su conexi\u00f3n a Internet.", "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11 p.m. y las 2 a.m.)", "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11 p.m. y las 2 a.m.)", - "username_exists": "La cuenta ya ha sido configurada", "wrong_login": "Error de inicio de sesi\u00f3n: por favor revise su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" }, "step": { diff --git a/homeassistant/components/linky/.translations/es.json b/homeassistant/components/linky/.translations/es.json index 511f3c9d8e56f8..c0052c356b27ad 100644 --- a/homeassistant/components/linky/.translations/es.json +++ b/homeassistant/components/linky/.translations/es.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Cuenta ya configurada" + "already_configured": "La cuenta ya est\u00e1 configurada" }, "error": { "access": "No se pudo acceder a Enedis.fr, compruebe su conexi\u00f3n a Internet", "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11:00 y las 2 de la ma\u00f1ana)", "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 23:00 y las 02:00 horas).", - "username_exists": "Cuenta ya configurada", "wrong_login": "Error de inicio de sesi\u00f3n: compruebe su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" }, "step": { diff --git a/homeassistant/components/linky/.translations/fr.json b/homeassistant/components/linky/.translations/fr.json index 6ff99c41a161aa..6dba7e9af89348 100644 --- a/homeassistant/components/linky/.translations/fr.json +++ b/homeassistant/components/linky/.translations/fr.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Compte d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9" }, "error": { "access": "Impossible d'acc\u00e9der \u00e0 Enedis.fr, merci de v\u00e9rifier votre connexion internet", "enedis": "Erreur d'Enedis.fr: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", "unknown": "Erreur inconnue: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", - "username_exists": "Compte d\u00e9j\u00e0 configur\u00e9", "wrong_login": "Erreur de connexion: veuillez v\u00e9rifier votre e-mail et votre mot de passe" }, "step": { diff --git a/homeassistant/components/linky/.translations/hu.json b/homeassistant/components/linky/.translations/hu.json new file mode 100644 index 00000000000000..f5c5f7880637dd --- /dev/null +++ b/homeassistant/components/linky/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "access": "Nem siker\u00fclt el\u00e9rni az Enedis.fr webhelyet, ellen\u0151rizze internet-kapcsolat\u00e1t", + "enedis": "Az Enedis.fr hib\u00e1val v\u00e1laszolt: k\u00e9rj\u00fck, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra (\u00e1ltal\u00e1ban nem 23:00 \u00e9s 2:00 k\u00f6z\u00f6tt)", + "unknown": "Ismeretlen hiba: pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb (\u00e1ltal\u00e1ban nem 23:00 \u00e9s 2:00 \u00f3ra k\u00f6z\u00f6tt)" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "E-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/it.json b/homeassistant/components/linky/.translations/it.json index 09d5f7e2d2bcb4..67418f616ad41e 100644 --- a/homeassistant/components/linky/.translations/it.json +++ b/homeassistant/components/linky/.translations/it.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Account gi\u00e0 configurato" + "already_configured": "Account gi\u00e0 configurato" }, "error": { "access": "Impossibile accedere a Enedis.fr, si prega di controllare la connessione internet", "enedis": "Enedis.fr ha risposto con un errore: si prega di riprovare pi\u00f9 tardi (di solito non tra le 23:00 e le 02:00).", "unknown": "Errore sconosciuto: riprova pi\u00f9 tardi (in genere non tra le 23:00 e le 02:00)", - "username_exists": "Account gi\u00e0 configurato", "wrong_login": "Errore di accesso: si prega di controllare la tua E-mail e la password" }, "step": { diff --git a/homeassistant/components/linky/.translations/ko.json b/homeassistant/components/linky/.translations/ko.json index 45172e70097596..78c825398d6cdf 100644 --- a/homeassistant/components/linky/.translations/ko.json +++ b/homeassistant/components/linky/.translations/ko.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "error": { "access": "Enedis.fr \uc5d0 \uc811\uc18d\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc778\ud130\ub137 \uc5f0\uacb0\uc744 \ud655\uc778\ud574\ubcf4\uc138\uc694", "enedis": "Enedis.fr \uc774 \uc624\ub958\ub85c \uc751\ub2f5\ud588\uc2b5\ub2c8\ub2e4: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", - "username_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "wrong_login": "\ub85c\uadf8\uc778 \uc624\ub958: \uc774\uba54\uc77c \ubc0f \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694" }, "step": { diff --git a/homeassistant/components/linky/.translations/lb.json b/homeassistant/components/linky/.translations/lb.json index cd3c7152c89e7d..b4c10bec367ec5 100644 --- a/homeassistant/components/linky/.translations/lb.json +++ b/homeassistant/components/linky/.translations/lb.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Kont ass scho konfigur\u00e9iert" + "already_configured": "Kont ass scho konfigur\u00e9iert" }, "error": { "access": "Keng Verbindung zu Enedis.fr, iwwerpr\u00e9ift d'Internet Verbindung", "enedis": "Enedis.fr huet mat engem Feeler ge\u00e4ntwert: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", "unknown": "Onbekannte Feeler: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", - "username_exists": "Kont ass scho konfigur\u00e9iert", "wrong_login": "Feeler beim Login: iwwerpr\u00e9ift \u00e4r E-Mail & Passwuert" }, "step": { diff --git a/homeassistant/components/linky/.translations/nl.json b/homeassistant/components/linky/.translations/nl.json index 89759fdf21630e..5654edb08f4935 100644 --- a/homeassistant/components/linky/.translations/nl.json +++ b/homeassistant/components/linky/.translations/nl.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Account reeds geconfigureerd" + "already_configured": "Account al geconfigureerd" }, "error": { "access": "Geen toegang tot Enedis.fr, controleer uw internetverbinding", "enedis": "Enedis.fr antwoordde met een fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", "unknown": "Onbekende fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", - "username_exists": "Account reeds geconfigureerd", "wrong_login": "Aanmeldingsfout: controleer uw e-mailadres en wachtwoord" }, "step": { diff --git a/homeassistant/components/linky/.translations/no.json b/homeassistant/components/linky/.translations/no.json index c43f434562c152..77b3bac8032877 100644 --- a/homeassistant/components/linky/.translations/no.json +++ b/homeassistant/components/linky/.translations/no.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Kontoen er allerede konfigurert" + "already_configured": "Kontoen er allerede konfigurert" }, "error": { "access": "Kunne ikke f\u00e5 tilgang til Enedis.fr, vennligst sjekk internettforbindelsen din", "enedis": "Enedis.fr svarte med en feil: vennligst pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", - "username_exists": "Kontoen er allerede konfigurert", "wrong_login": "Innloggingsfeil: vennligst sjekk e-postadressen og passordet ditt" }, "step": { diff --git a/homeassistant/components/linky/.translations/pl.json b/homeassistant/components/linky/.translations/pl.json index d4fa7ee4d11811..62da10e1c96a1d 100644 --- a/homeassistant/components/linky/.translations/pl.json +++ b/homeassistant/components/linky/.translations/pl.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane." }, "error": { "access": "Nie mo\u017cna uzyska\u0107 dost\u0119pu do Enedis.fr, sprawd\u017a po\u0142\u0105czenie internetowe", "enedis": "Enedis.fr odpowiedzia\u0142 b\u0142\u0119dem: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy 23:00, a 2:00)", "unknown": "Nieznany b\u0142\u0105d: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy godzin\u0105 23:00, a 2:00)", - "username_exists": "Konto jest ju\u017c skonfigurowane", "wrong_login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o" }, "step": { diff --git a/homeassistant/components/linky/.translations/pt-BR.json b/homeassistant/components/linky/.translations/pt-BR.json index 23f519353b4852..9a4a710e522046 100644 --- a/homeassistant/components/linky/.translations/pt-BR.json +++ b/homeassistant/components/linky/.translations/pt-BR.json @@ -1,7 +1,6 @@ { "config": { "error": { - "username_exists": "Conta j\u00e1 configurada", "wrong_login": "Erro de Login: por favor, verifique seu e-mail e senha" }, "step": { diff --git a/homeassistant/components/linky/.translations/pt.json b/homeassistant/components/linky/.translations/pt.json index 67e742c5813b70..54619af958ec80 100644 --- a/homeassistant/components/linky/.translations/pt.json +++ b/homeassistant/components/linky/.translations/pt.json @@ -1,11 +1,5 @@ { "config": { - "abort": { - "username_exists": "Conta j\u00e1 configurada" - }, - "error": { - "username_exists": "Conta j\u00e1 configurada" - }, "step": { "user": { "data": { diff --git a/homeassistant/components/linky/.translations/ru.json b/homeassistant/components/linky/.translations/ru.json index da34fbbdb621f4..a868f9666c5c15 100644 --- a/homeassistant/components/linky/.translations/ru.json +++ b/homeassistant/components/linky/.translations/ru.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." }, "error": { "access": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a Enedis.fr, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0443.", "enedis": "Enedis.fr \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b \u043e\u0442\u0432\u0435\u0442 \u0441 \u043e\u0448\u0438\u0431\u043a\u043e\u0439: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00).", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00).", - "username_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", "wrong_login": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u0445\u043e\u0434\u0430: \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b \u0438 \u043f\u0430\u0440\u043e\u043b\u044c." }, "step": { diff --git a/homeassistant/components/linky/.translations/sl.json b/homeassistant/components/linky/.translations/sl.json index 9e9d6668fcb8fb..6ebe598e882db3 100644 --- a/homeassistant/components/linky/.translations/sl.json +++ b/homeassistant/components/linky/.translations/sl.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "Ra\u010dun \u017ee nastavljen" + "already_configured": "Ra\u010dun \u017ee nastavljen" }, "error": { "access": "Do Enedis.fr ni bilo mogo\u010de dostopati, preverite internetno povezavo", "enedis": "Enedis.fr je odgovoril z napako: poskusite pozneje (ponavadi med 23. in 2. uro)", "unknown": "Neznana napaka: Prosimo, poskusite pozneje (obi\u010dajno ne med 23. in 2. uro)", - "username_exists": "Ra\u010dun \u017ee nastavljen", "wrong_login": "Napaka pri prijavi: preverite svoj e-po\u0161tni naslov in geslo" }, "step": { diff --git a/homeassistant/components/linky/.translations/sv.json b/homeassistant/components/linky/.translations/sv.json new file mode 100644 index 00000000000000..4880e065fa2fd1 --- /dev/null +++ b/homeassistant/components/linky/.translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Kontot har redan konfigurerats." + }, + "error": { + "access": "Det gick inte att komma \u00e5t Enedis.fr, kontrollera din internetanslutning", + "enedis": "Enedis.fr svarade med ett fel: f\u00f6rs\u00f6k igen senare (vanligtvis inte mellan 23:00 och 02:00)", + "unknown": "Ok\u00e4nt fel: f\u00f6rs\u00f6k igen senare (vanligtvis inte mellan 23:00 och 02:00)", + "wrong_login": "Inloggningsfel: v\u00e4nligen kontrollera din e-post och l\u00f6senord" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "E-post" + }, + "description": "Ange dina autentiseringsuppgifter", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/zh-Hans.json b/homeassistant/components/linky/.translations/zh-Hans.json index b450a3cbdb08c7..62138856078df0 100644 --- a/homeassistant/components/linky/.translations/zh-Hans.json +++ b/homeassistant/components/linky/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { - "abort": { - "username_exists": "\u8d26\u6237\u5df2\u914d\u7f6e\u5b8c\u6210" + "error": { + "wrong_login": "\u767b\u5f55\u51fa\u9519\uff1a\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u5b50\u90ae\u7bb1\u548c\u5bc6\u7801" }, "step": { "user": { diff --git a/homeassistant/components/linky/.translations/zh-Hant.json b/homeassistant/components/linky/.translations/zh-Hant.json index bcfac6643c8e6f..92ad3ef0ca1a8d 100644 --- a/homeassistant/components/linky/.translations/zh-Hant.json +++ b/homeassistant/components/linky/.translations/zh-Hant.json @@ -1,13 +1,12 @@ { "config": { "abort": { - "username_exists": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" }, "error": { "access": "\u7121\u6cd5\u8a2a\u554f Enedis.fr\uff0c\u8acb\u6aa2\u67e5\u60a8\u7684\u7db2\u969b\u7db2\u8def\u9023\u7dda", "enedis": "Endis.fr \u56de\u5831\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", - "username_exists": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "wrong_login": "\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u78ba\u8a8d\u96fb\u5b50\u90f5\u4ef6\u8207\u79d8\u5bc6\u6b63\u78ba\u6027" }, "step": { diff --git a/homeassistant/components/linky/__init__.py b/homeassistant/components/linky/__init__.py index 1d382b4352542a..d21c007762cf27 100644 --- a/homeassistant/components/linky/__init__.py +++ b/homeassistant/components/linky/__init__.py @@ -47,6 +47,12 @@ async def async_setup(hass, config): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Set up Linky sensors.""" + # For backwards compat + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=entry.data[CONF_USERNAME] + ) + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "sensor") ) diff --git a/homeassistant/components/linky/config_flow.py b/homeassistant/components/linky/config_flow.py index 8a2d307ceabed0..88fa725cc4a634 100644 --- a/homeassistant/components/linky/config_flow.py +++ b/homeassistant/components/linky/config_flow.py @@ -12,9 +12,9 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.core import callback -from .const import DEFAULT_TIMEOUT, DOMAIN +from .const import DEFAULT_TIMEOUT +from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -25,20 +25,6 @@ class LinkyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def __init__(self): - """Initialize Linky config flow.""" - self._username = None - self._password = None - self._timeout = None - - def _configuration_exists(self, username: str) -> bool: - """Return True if username exists in configuration.""" - for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_USERNAME] == username: - return True - return False - - @callback def _show_setup_form(self, user_input=None, errors=None): """Show the setup form to the user.""" @@ -67,15 +53,16 @@ async def async_step_user(self, user_input=None): if user_input is None: return self._show_setup_form(user_input, None) - self._username = user_input[CONF_USERNAME] - self._password = user_input[CONF_PASSWORD] - self._timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - if self._configuration_exists(self._username): - errors[CONF_USERNAME] = "username_exists" - return self._show_setup_form(user_input, errors) + # Check if already configured + if self.unique_id is None: + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() - client = LinkyClient(self._username, self._password, None, self._timeout) + client = LinkyClient(username, password, None, timeout) try: await self.hass.async_add_executor_job(client.login) await self.hass.async_add_executor_job(client.fetch_data) @@ -99,20 +86,14 @@ async def async_step_user(self, user_input=None): client.close_session() return self.async_create_entry( - title=self._username, + title=username, data={ - CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, - CONF_TIMEOUT: self._timeout, + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_TIMEOUT: timeout, }, ) async def async_step_import(self, user_input=None): - """Import a config entry. - - Only host was required in the yaml file all other fields are optional - """ - if self._configuration_exists(user_input[CONF_USERNAME]): - return self.async_abort(reason="username_exists") - + """Import a config entry.""" return await self.async_step_user(user_input) diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py index 9beb9acc403fbd..846b7eeb99f228 100644 --- a/homeassistant/components/linky/sensor.py +++ b/homeassistant/components/linky/sensor.py @@ -13,6 +13,7 @@ CONF_USERNAME, ENERGY_KILO_WATT_HOUR, ) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType @@ -73,6 +74,7 @@ def update_linky_data(self, event_time=None): _LOGGER.debug(json.dumps(self._data, indent=2)) except PyLinkyException as exp: _LOGGER.error(exp) + raise PlatformNotReady finally: client.close_session() @@ -146,6 +148,9 @@ def device_info(self): async def async_update(self) -> None: """Retrieve the new data for the sensor.""" + if self._account.data is None: + return + data = self._account.data[self._scale][self._when] self._consumption = data[CONSUMPTION] self._time = data[TIME] diff --git a/homeassistant/components/linky/strings.json b/homeassistant/components/linky/strings.json index e5aa04cad1f367..dc4c0bb9651ac7 100644 --- a/homeassistant/components/linky/strings.json +++ b/homeassistant/components/linky/strings.json @@ -12,14 +12,13 @@ } }, "error":{ - "username_exists": "Account already configured", "access": "Could not access to Enedis.fr, please check your internet connection", "enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)", "wrong_login": "Login error: please check your email & password", "unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)" }, "abort":{ - "username_exists": "Account already configured" + "already_configured": "Account already configured" } } } diff --git a/homeassistant/components/liveboxplaytv/__init__.py b/homeassistant/components/liveboxplaytv/__init__.py deleted file mode 100644 index 384c0e4c34b76d..00000000000000 --- a/homeassistant/components/liveboxplaytv/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The liveboxplaytv component.""" diff --git a/homeassistant/components/liveboxplaytv/manifest.json b/homeassistant/components/liveboxplaytv/manifest.json deleted file mode 100644 index a05ff27ca901c1..00000000000000 --- a/homeassistant/components/liveboxplaytv/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "liveboxplaytv", - "name": "Orange Livebox Play TV", - "documentation": "https://www.home-assistant.io/integrations/liveboxplaytv", - "requirements": ["liveboxplaytv==2.0.3", "pyteleloisirs==3.6"], - "dependencies": [], - "codeowners": ["@pschmitt"] -} diff --git a/homeassistant/components/liveboxplaytv/media_player.py b/homeassistant/components/liveboxplaytv/media_player.py deleted file mode 100644 index 66fb383d677b2a..00000000000000 --- a/homeassistant/components/liveboxplaytv/media_player.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Support for interface with an Orange Livebox Play TV appliance.""" -from datetime import timedelta -import logging - -from liveboxplaytv import LiveboxPlayTv -import pyteleloisirs -import requests -import voluptuous as vol - -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice -from homeassistant.components.media_player.const import ( - MEDIA_TYPE_CHANNEL, - SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, - SUPPORT_PLAY, - SUPPORT_PREVIOUS_TRACK, - SUPPORT_SELECT_SOURCE, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_STEP, -) -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PORT, - STATE_OFF, - STATE_ON, - STATE_PAUSED, - STATE_PLAYING, -) -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Livebox Play TV" -DEFAULT_PORT = 8080 - -SUPPORT_LIVEBOXPLAYTV = ( - SUPPORT_TURN_OFF - | SUPPORT_TURN_ON - | SUPPORT_NEXT_TRACK - | SUPPORT_PAUSE - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_VOLUME_STEP - | SUPPORT_VOLUME_MUTE - | SUPPORT_SELECT_SOURCE - | SUPPORT_PLAY -) - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Orange Livebox Play TV platform.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME) - - livebox_devices = [] - - try: - device = LiveboxPlayTvDevice(host, port, name) - livebox_devices.append(device) - except OSError: - _LOGGER.error( - "Failed to connect to Livebox Play TV at %s:%s. " - "Please check your configuration", - host, - port, - ) - async_add_entities(livebox_devices, True) - - -class LiveboxPlayTvDevice(MediaPlayerDevice): - """Representation of an Orange Livebox Play TV.""" - - def __init__(self, host, port, name): - """Initialize the Livebox Play TV device.""" - - self._client = LiveboxPlayTv(host, port) - # Assume that the appliance is not muted - self._muted = False - self._name = name - self._current_source = None - self._state = None - self._channel_list = {} - self._current_channel = None - self._current_program = None - self._media_duration = None - self._media_remaining_time = None - self._media_image_url = None - self._media_last_updated = None - - async def async_update(self): - """Retrieve the latest data.""" - - try: - self._state = self.refresh_state() - # Update channel list - self.refresh_channel_list() - # Update current channel - channel = self._client.channel - if channel is not None: - self._current_channel = channel - program = await self._client.async_get_current_program() - if program and self._current_program != program.get("name"): - self._current_program = program.get("name") - # Media progress info - self._media_duration = pyteleloisirs.get_program_duration(program) - rtime = pyteleloisirs.get_remaining_time(program) - if rtime != self._media_remaining_time: - self._media_remaining_time = rtime - self._media_last_updated = dt_util.utcnow() - # Set media image to current program if a thumbnail is - # available. Otherwise we'll use the channel's image. - img_size = 800 - prg_img_url = await self._client.async_get_current_program_image( - img_size - ) - if prg_img_url: - self._media_image_url = prg_img_url - else: - chan_img_url = self._client.get_current_channel_image(img_size) - self._media_image_url = chan_img_url - except requests.ConnectionError: - self._state = None - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._muted - - @property - def source(self): - """Return the current input source.""" - return self._current_channel - - @property - def source_list(self): - """List of available input sources.""" - # Sort channels by tvIndex - return [self._channel_list[c] for c in sorted(self._channel_list.keys())] - - @property - def media_content_type(self): - """Content type of current playing media.""" - # return self._client.media_type - return MEDIA_TYPE_CHANNEL - - @property - def media_image_url(self): - """Image url of current playing media.""" - return self._media_image_url - - @property - def media_title(self): - """Title of current playing media.""" - if self._current_channel: - if self._current_program: - return f"{self._current_channel}: {self._current_program}" - return self._current_channel - - @property - def media_duration(self): - """Duration of current playing media in seconds.""" - return self._media_duration - - @property - def media_position(self): - """Position of current playing media in seconds.""" - return self._media_remaining_time - - @property - def media_position_updated_at(self): - """When was the position of the current playing media valid. - - Returns value from homeassistant.util.dt.utcnow(). - """ - return self._media_last_updated - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_LIVEBOXPLAYTV - - def refresh_channel_list(self): - """Refresh the list of available channels.""" - new_channel_list = {} - # update channels - for channel in self._client.get_channels(): - new_channel_list[int(channel["index"])] = channel["name"] - self._channel_list = new_channel_list - - def refresh_state(self): - """Refresh the current media state.""" - state = self._client.media_state - if state == "PLAY": - return STATE_PLAYING - if state == "PAUSE": - return STATE_PAUSED - - return STATE_ON if self._client.is_on else STATE_OFF - - def turn_off(self): - """Turn off media player.""" - self._state = STATE_OFF - self._client.turn_off() - - def turn_on(self): - """Turn on the media player.""" - self._state = STATE_ON - self._client.turn_on() - - def volume_up(self): - """Volume up the media player.""" - self._client.volume_up() - - def volume_down(self): - """Volume down media player.""" - self._client.volume_down() - - def mute_volume(self, mute): - """Send mute command.""" - self._muted = mute - self._client.mute() - - def media_play_pause(self): - """Simulate play pause media player.""" - self._client.play_pause() - - def select_source(self, source): - """Select input source.""" - self._current_source = source - self._client.set_channel(source) - - def media_play(self): - """Send play command.""" - self._state = STATE_PLAYING - self._client.play() - - def media_pause(self): - """Send media pause command to media player.""" - self._state = STATE_PAUSED - self._client.pause() - - def media_next_track(self): - """Send next track command.""" - self._client.channel_up() - - def media_previous_track(self): - """Send the previous track command.""" - self._client.channel_down() diff --git a/homeassistant/components/local_ip/.translations/hu.json b/homeassistant/components/local_ip/.translations/hu.json new file mode 100644 index 00000000000000..7a78029c379024 --- /dev/null +++ b/homeassistant/components/local_ip/.translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Az integr\u00e1ci\u00f3 m\u00e1r konfigur\u00e1lva van egy ilyen nev\u0171 l\u00e9tez\u0151 \u00e9rz\u00e9kel\u0151vel" + }, + "step": { + "user": { + "data": { + "name": "\u00c9rz\u00e9kel\u0151 neve" + }, + "title": "Helyi IP c\u00edm" + } + }, + "title": "Helyi IP c\u00edm" + } +} \ No newline at end of file diff --git a/homeassistant/components/local_ip/.translations/nl.json b/homeassistant/components/local_ip/.translations/nl.json index 4f0d9a437db495..6f22d2c585a756 100644 --- a/homeassistant/components/local_ip/.translations/nl.json +++ b/homeassistant/components/local_ip/.translations/nl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Integratie is al geconfigureerd met een bestaande sensor met die naam" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/local_ip/.translations/pl.json b/homeassistant/components/local_ip/.translations/pl.json index a4032eeebd11e8..82b614a8e1791a 100644 --- a/homeassistant/components/local_ip/.translations/pl.json +++ b/homeassistant/components/local_ip/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Integracja jest ju\u017c skonfigurowana z istniej\u0105cym sensorem o tej nazwie" + "already_configured": "Integracja jest ju\u017c skonfigurowana z istniej\u0105cym sensorem o tej nazwie." }, "step": { "user": { diff --git a/homeassistant/components/local_ip/.translations/ru.json b/homeassistant/components/local_ip/.translations/ru.json index de92b9680f07f5..2cf8791e5057e8 100644 --- a/homeassistant/components/local_ip/.translations/ru.json +++ b/homeassistant/components/local_ip/.translations/ru.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u0434\u0430\u0442\u0447\u0438\u043a\u0430." + "already_configured": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c." }, "step": { "user": { diff --git a/homeassistant/components/local_ip/.translations/sv.json b/homeassistant/components/local_ip/.translations/sv.json new file mode 100644 index 00000000000000..d9f9b474f9c48b --- /dev/null +++ b/homeassistant/components/local_ip/.translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Integrationen \u00e4r redan konfigurerad med en befintlig sensor med det namnet" + }, + "step": { + "user": { + "data": { + "name": "Sensor Namn" + }, + "title": "Lokal IP-adress" + } + }, + "title": "Lokal IP-adress" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/pl.json b/homeassistant/components/locative/.translations/pl.json index 9c22a8e3fea594..23a4c98a54cfbc 100644 --- a/homeassistant/components/locative/.translations/pl.json +++ b/homeassistant/components/locative/.translations/pl.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Na pewno chcesz skonfigurowa\u0107 Locative Webhook?", - "title": "Skonfiguruj Locative Webhook" + "title": "Konfiguracja Locative Webhook" } }, "title": "Locative Webhook" diff --git a/homeassistant/components/lock/.translations/sv.json b/homeassistant/components/lock/.translations/sv.json new file mode 100644 index 00000000000000..7d50b4ea61a992 --- /dev/null +++ b/homeassistant/components/lock/.translations/sv.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "lock": "L\u00e5s {entity_name}", + "open": "\u00d6ppna {entity_name}", + "unlock": "L\u00e5s upp {entity_name}" + }, + "condition_type": { + "is_locked": "{entity_name} \u00e4r l\u00e5st", + "is_unlocked": "{entity_name} \u00e4r ol\u00e5st" + }, + "trigger_type": { + "locked": "{entity_name} l\u00e5st", + "unlocked": "{entity_name} ol\u00e5st" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lock/.translations/zh-Hant.json b/homeassistant/components/lock/.translations/zh-Hant.json index b5d69a21f9af83..054f7a5a18d6f2 100644 --- a/homeassistant/components/lock/.translations/zh-Hant.json +++ b/homeassistant/components/lock/.translations/zh-Hant.json @@ -1,17 +1,17 @@ { "device_automation": { "action_type": { - "lock": "\u4e0a\u9396 {entity_name}", - "open": "\u958b\u555f {entity_name}", - "unlock": "\u89e3\u9396 {entity_name}" + "lock": "\u4e0a\u9396{entity_name}", + "open": "\u958b\u555f{entity_name}", + "unlock": "\u89e3\u9396{entity_name}" }, "condition_type": { - "is_locked": "{entity_name} \u5df2\u4e0a\u9396", - "is_unlocked": "{entity_name} \u5df2\u89e3\u9396" + "is_locked": "{entity_name}\u5df2\u4e0a\u9396", + "is_unlocked": "{entity_name}\u5df2\u89e3\u9396" }, "trigger_type": { - "locked": "{entity_name} \u5df2\u4e0a\u9396", - "unlocked": "{entity_name} \u5df2\u89e3\u9396" + "locked": "{entity_name}\u5df2\u4e0a\u9396", + "unlocked": "{entity_name}\u5df2\u89e3\u9396" } } } \ No newline at end of file diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index c788a7c3e8c2dd..92da3a030853e1 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -104,34 +104,25 @@ def lock(self, **kwargs): """Lock the lock.""" raise NotImplementedError() - def async_lock(self, **kwargs): - """Lock the lock. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.lock, **kwargs)) + async def async_lock(self, **kwargs): + """Lock the lock.""" + await self.hass.async_add_job(ft.partial(self.lock, **kwargs)) def unlock(self, **kwargs): """Unlock the lock.""" raise NotImplementedError() - def async_unlock(self, **kwargs): - """Unlock the lock. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.unlock, **kwargs)) + async def async_unlock(self, **kwargs): + """Unlock the lock.""" + await self.hass.async_add_job(ft.partial(self.unlock, **kwargs)) def open(self, **kwargs): """Open the door latch.""" raise NotImplementedError() - def async_open(self, **kwargs): - """Open the door latch. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.open, **kwargs)) + async def async_open(self, **kwargs): + """Open the door latch.""" + await self.hass.async_add_job(ft.partial(self.open, **kwargs)) @property def state_attributes(self): diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 447913206691dd..a25018dc70981d 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -13,7 +13,7 @@ STATE_LOCKED, STATE_UNLOCKED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -63,6 +63,7 @@ async def async_get_conditions(hass: HomeAssistant, device_id: str) -> List[dict return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 8043469d43b396..961718a30ee319 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -1,4 +1,4 @@ -"""Support for settting the level of logging for components.""" +"""Support for setting the level of logging for components.""" from collections import OrderedDict import logging diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 6c99356907f6c1..c78356e0dd6a70 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,244 +1,165 @@ """Support for the Lovelace UI.""" -from functools import wraps import logging -import os -import time +from typing import Any import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import frontend +from homeassistant.const import CONF_FILENAME, CONF_ICON from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.yaml import load_yaml +from homeassistant.helpers import config_validation as cv +from homeassistant.util import sanitize_filename, slugify + +from . import dashboard, resources, websocket +from .const import ( + CONF_RESOURCES, + DOMAIN, + LOVELACE_CONFIG_FILE, + MODE_STORAGE, + MODE_YAML, + RESOURCE_SCHEMA, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = "lovelace" -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 CONF_MODE = "mode" -MODE_YAML = "yaml" -MODE_STORAGE = "storage" + +CONF_DASHBOARDS = "dashboards" +CONF_SIDEBAR = "sidebar" +CONF_TITLE = "title" +CONF_REQUIRE_ADMIN = "require_admin" + +DASHBOARD_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, + vol.Optional(CONF_SIDEBAR): { + vol.Required(CONF_ICON): cv.icon, + vol.Required(CONF_TITLE): cv.string, + }, + } +) + +YAML_DASHBOARD_SCHEMA = DASHBOARD_BASE_SCHEMA.extend( + { + vol.Required(CONF_MODE): MODE_YAML, + vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename), + } +) + + +def url_slug(value: Any) -> str: + """Validate value is a valid url slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + str_value = str(value) + slg = slugify(str_value, separator="-") + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( + vol.Optional(DOMAIN, default={}): vol.Schema( { vol.Optional(CONF_MODE, default=MODE_STORAGE): vol.All( vol.Lower, vol.In([MODE_YAML, MODE_STORAGE]) - ) + ), + vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys( + YAML_DASHBOARD_SCHEMA, slug_validator=url_slug, + ), + vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA], } ) }, extra=vol.ALLOW_EXTRA, ) -EVENT_LOVELACE_UPDATED = "lovelace_updated" - -LOVELACE_CONFIG_FILE = "ui-lovelace.yaml" - - -class ConfigNotFound(HomeAssistantError): - """When no config available.""" - async def async_setup(hass, config): """Set up the Lovelace commands.""" # Pass in default to `get` because defaults not set if loaded as dep - mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) + mode = config[DOMAIN][CONF_MODE] + yaml_resources = config[DOMAIN].get(CONF_RESOURCES) - hass.components.frontend.async_register_built_in_panel( - DOMAIN, config={"mode": mode} - ) + frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode}) if mode == MODE_YAML: - hass.data[DOMAIN] = LovelaceYAML(hass) + default_config = dashboard.LovelaceYAML(hass, None, LOVELACE_CONFIG_FILE) + + if yaml_resources is None: + try: + ll_conf = await default_config.async_load(False) + except HomeAssistantError: + pass + else: + if CONF_RESOURCES in ll_conf: + _LOGGER.warning( + "Resources need to be specified in your configuration.yaml. Please see the docs." + ) + yaml_resources = ll_conf[CONF_RESOURCES] + + resource_collection = resources.ResourceYAMLCollection(yaml_resources or []) + else: - hass.data[DOMAIN] = LovelaceStorage(hass) + default_config = dashboard.LovelaceStorage(hass, None) - hass.components.websocket_api.async_register_command(websocket_lovelace_config) + if yaml_resources is not None: + _LOGGER.warning( + "Lovelace is running in storage mode. Define resources via user interface" + ) - hass.components.websocket_api.async_register_command(websocket_lovelace_save_config) + resource_collection = resources.ResourceStorageCollection(hass, default_config) hass.components.websocket_api.async_register_command( - websocket_lovelace_delete_config + websocket.websocket_lovelace_config + ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_save_config + ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_delete_config + ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_resources ) hass.components.system_health.async_register_info(DOMAIN, system_health_info) - return True - - -class LovelaceStorage: - """Class to handle Storage based Lovelace config.""" - - def __init__(self, hass): - """Initialize Lovelace config based on storage helper.""" - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - self._data = None - self._hass = hass - - async def async_get_info(self): - """Return the YAML storage mode.""" - if self._data is None: - await self._load() - - if self._data["config"] is None: - return {"mode": "auto-gen"} - - return _config_info("storage", self._data["config"]) - - async def async_load(self, force): - """Load config.""" - if self._data is None: - await self._load() - - config = self._data["config"] - - if config is None: - raise ConfigNotFound - - return config - - async def async_save(self, config): - """Save config.""" - if self._data is None: - await self._load() - self._data["config"] = config - self._hass.bus.async_fire(EVENT_LOVELACE_UPDATED) - await self._store.async_save(self._data) - - async def async_delete(self): - """Delete config.""" - await self.async_save(None) - - async def _load(self): - """Load the config.""" - data = await self._store.async_load() - self._data = data if data else {"config": None} - - -class LovelaceYAML: - """Class to handle YAML-based Lovelace config.""" - - def __init__(self, hass): - """Initialize the YAML config.""" - self.hass = hass - self._cache = None - - async def async_get_info(self): - """Return the YAML storage mode.""" - try: - config = await self.async_load(False) - except ConfigNotFound: - return { - "mode": "yaml", - "error": "{} not found".format( - self.hass.config.path(LOVELACE_CONFIG_FILE) - ), - } - - return _config_info("yaml", config) - - async def async_load(self, force): - """Load config.""" - is_updated, config = await self.hass.async_add_executor_job( - self._load_config, force - ) - if is_updated: - self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED) - return config - - def _load_config(self, force): - """Load the actual config.""" - fname = self.hass.config.path(LOVELACE_CONFIG_FILE) - # Check for a cached version of the config - if not force and self._cache is not None: - config, last_update = self._cache - modtime = os.path.getmtime(fname) - if config and last_update > modtime: - return False, config - - is_updated = self._cache is not None - - try: - config = load_yaml(fname) - except FileNotFoundError: - raise ConfigNotFound from None - - self._cache = (config, time.time()) - return is_updated, config + hass.data[DOMAIN] = { + # We store a dictionary mapping url_path: config. None is the default. + "dashboards": {None: default_config}, + "resources": resource_collection, + } - async def async_save(self, config): - """Save config.""" - raise HomeAssistantError("Not supported") + if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]: + return True - async def async_delete(self): - """Delete config.""" - raise HomeAssistantError("Not supported") + for url_path, dashboard_conf in config[DOMAIN][CONF_DASHBOARDS].items(): + # For now always mode=yaml + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf[CONF_FILENAME]) + hass.data[DOMAIN]["dashboards"][url_path] = config + kwargs = { + "hass": hass, + "component_name": DOMAIN, + "frontend_url_path": url_path, + "require_admin": dashboard_conf[CONF_REQUIRE_ADMIN], + "config": {"mode": dashboard_conf[CONF_MODE]}, + } -def handle_yaml_errors(func): - """Handle error with WebSocket calls.""" + if CONF_SIDEBAR in dashboard_conf: + kwargs["sidebar_title"] = dashboard_conf[CONF_SIDEBAR][CONF_TITLE] + kwargs["sidebar_icon"] = dashboard_conf[CONF_SIDEBAR][CONF_ICON] - @wraps(func) - async def send_with_error_handling(hass, connection, msg): - error = None try: - result = await func(hass, connection, msg) - except ConfigNotFound: - error = "config_not_found", "No config found." - except HomeAssistantError as err: - error = "error", str(err) - - if error is not None: - connection.send_error(msg["id"], *error) - return - - if msg is not None: - await connection.send_big_result(msg["id"], result) - else: - connection.send_result(msg["id"], result) - - return send_with_error_handling - - -@websocket_api.async_response -@websocket_api.websocket_command( - {"type": "lovelace/config", vol.Optional("force", default=False): bool} -) -@handle_yaml_errors -async def websocket_lovelace_config(hass, connection, msg): - """Send Lovelace UI config over WebSocket configuration.""" - return await hass.data[DOMAIN].async_load(msg["force"]) + frontend.async_register_built_in_panel(**kwargs) + except ValueError: + _LOGGER.warning("Panel url path %s is not unique", url_path) - -@websocket_api.async_response -@websocket_api.websocket_command( - {"type": "lovelace/config/save", "config": vol.Any(str, dict)} -) -@handle_yaml_errors -async def websocket_lovelace_save_config(hass, connection, msg): - """Save Lovelace UI configuration.""" - await hass.data[DOMAIN].async_save(msg["config"]) - - -@websocket_api.async_response -@websocket_api.websocket_command({"type": "lovelace/config/delete"}) -@handle_yaml_errors -async def websocket_lovelace_delete_config(hass, connection, msg): - """Delete Lovelace UI configuration.""" - await hass.data[DOMAIN].async_delete() + return True async def system_health_info(hass): """Get info for the info page.""" - return await hass.data[DOMAIN].async_get_info() - - -def _config_info(mode, config): - """Generate info about the config.""" - return { - "mode": mode, - "resources": len(config.get("resources", [])), - "views": len(config.get("views", [])), - } + return await hass.data[DOMAIN]["dashboards"][None].async_get_info() diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py new file mode 100644 index 00000000000000..2bf2b34098c507 --- /dev/null +++ b/homeassistant/components/lovelace/const.py @@ -0,0 +1,26 @@ +"""Constants for Lovelace.""" +import voluptuous as vol + +from homeassistant.const import CONF_TYPE, CONF_URL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +DOMAIN = "lovelace" +EVENT_LOVELACE_UPDATED = "lovelace_updated" + +MODE_YAML = "yaml" +MODE_STORAGE = "storage" + +LOVELACE_CONFIG_FILE = "ui-lovelace.yaml" +CONF_RESOURCES = "resources" +CONF_URL_PATH = "url_path" + +RESOURCE_FIELDS = { + CONF_TYPE: vol.In(["js", "css", "module", "html"]), + CONF_URL: cv.string, +} +RESOURCE_SCHEMA = vol.Schema(RESOURCE_FIELDS) + + +class ConfigNotFound(HomeAssistantError): + """When no config available.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py new file mode 100644 index 00000000000000..dcd7a6c4e52b43 --- /dev/null +++ b/homeassistant/components/lovelace/dashboard.py @@ -0,0 +1,181 @@ +"""Lovelace dashboard support.""" +from abc import ABC, abstractmethod +import os +import time + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import storage +from homeassistant.util.yaml import load_yaml + +from .const import ( + DOMAIN, + EVENT_LOVELACE_UPDATED, + MODE_STORAGE, + MODE_YAML, + ConfigNotFound, +) + +CONFIG_STORAGE_KEY_DEFAULT = DOMAIN +CONFIG_STORAGE_VERSION = 1 + + +class LovelaceConfig(ABC): + """Base class for Lovelace config.""" + + def __init__(self, hass, url_path): + """Initialize Lovelace config.""" + self.hass = hass + self.url_path = url_path + + @property + @abstractmethod + def mode(self) -> str: + """Return mode of the lovelace config.""" + + @abstractmethod + async def async_get_info(self): + """Return the config info.""" + + @abstractmethod + async def async_load(self, force): + """Load config.""" + + async def async_save(self, config): + """Save config.""" + raise HomeAssistantError("Not supported") + + async def async_delete(self): + """Delete config.""" + raise HomeAssistantError("Not supported") + + @callback + def _config_updated(self): + """Fire config updated event.""" + self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path}) + + +class LovelaceStorage(LovelaceConfig): + """Class to handle Storage based Lovelace config.""" + + def __init__(self, hass, url_path): + """Initialize Lovelace config based on storage helper.""" + super().__init__(hass, url_path) + if url_path is None: + storage_key = CONFIG_STORAGE_KEY_DEFAULT + else: + raise ValueError("Storage-based dashboards are not supported") + + self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) + self._data = None + + @property + def mode(self) -> str: + """Return mode of the lovelace config.""" + return MODE_STORAGE + + async def async_get_info(self): + """Return the YAML storage mode.""" + if self._data is None: + await self._load() + + if self._data["config"] is None: + return {"mode": "auto-gen"} + + return _config_info(self.mode, self._data["config"]) + + async def async_load(self, force): + """Load config.""" + if self.hass.config.safe_mode: + raise ConfigNotFound + + if self._data is None: + await self._load() + + config = self._data["config"] + + if config is None: + raise ConfigNotFound + + return config + + async def async_save(self, config): + """Save config.""" + if self._data is None: + await self._load() + self._data["config"] = config + self._config_updated() + await self._store.async_save(self._data) + + async def async_delete(self): + """Delete config.""" + await self.async_save(None) + + async def _load(self): + """Load the config.""" + data = await self._store.async_load() + self._data = data if data else {"config": None} + + +class LovelaceYAML(LovelaceConfig): + """Class to handle YAML-based Lovelace config.""" + + def __init__(self, hass, url_path, path): + """Initialize the YAML config.""" + super().__init__(hass, url_path) + self.path = hass.config.path(path) + self._cache = None + + @property + def mode(self) -> str: + """Return mode of the lovelace config.""" + return MODE_YAML + + async def async_get_info(self): + """Return the YAML storage mode.""" + try: + config = await self.async_load(False) + except ConfigNotFound: + return { + "mode": self.mode, + "error": "{} not found".format(self.path), + } + + return _config_info(self.mode, config) + + async def async_load(self, force): + """Load config.""" + is_updated, config = await self.hass.async_add_executor_job( + self._load_config, force + ) + if is_updated: + self._config_updated() + return config + + def _load_config(self, force): + """Load the actual config.""" + # Check for a cached version of the config + if not force and self._cache is not None: + config, last_update = self._cache + modtime = os.path.getmtime(self.path) + if config and last_update > modtime: + return False, config + + is_updated = self._cache is not None + + try: + config = load_yaml(self.path) + except FileNotFoundError: + raise ConfigNotFound from None + + self._cache = (config, time.time()) + return is_updated, config + + +def _config_info(mode, config): + """Generate info about the config.""" + return { + "mode": mode, + "resources": len(config.get("resources", [])), + "views": len(config.get("views", [])), + } diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py new file mode 100644 index 00000000000000..4244feb26dd043 --- /dev/null +++ b/homeassistant/components/lovelace/resources.py @@ -0,0 +1,96 @@ +"""Lovelace resources support.""" +import logging +from typing import List, Optional, cast +import uuid + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import collection, storage + +from .const import CONF_RESOURCES, DOMAIN, RESOURCE_SCHEMA +from .dashboard import LovelaceConfig + +RESOURCE_STORAGE_KEY = f"{DOMAIN}_resources" +RESOURCES_STORAGE_VERSION = 1 +_LOGGER = logging.getLogger(__name__) + + +class ResourceYAMLCollection: + """Collection representing static YAML.""" + + loaded = True + + def __init__(self, data): + """Initialize a resource YAML collection.""" + self.data = data + + @callback + def async_items(self) -> List[dict]: + """Return list of items in collection.""" + return self.data + + +class ResourceStorageCollection(collection.StorageCollection): + """Collection to store resources.""" + + loaded = False + + def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig): + """Initialize the storage collection.""" + super().__init__( + storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY), + _LOGGER, + ) + self.ll_config = ll_config + + async def _async_load_data(self) -> Optional[dict]: + """Load the data.""" + data = await self.store.async_load() + + if data is not None: + return cast(Optional[dict], data) + + # Import it from config. + try: + conf = await self.ll_config.async_load(False) + except HomeAssistantError: + return None + + if CONF_RESOURCES not in conf: + return None + + # Remove it from config and save both resources + config + data = conf[CONF_RESOURCES] + + try: + vol.Schema([RESOURCE_SCHEMA])(data) + except vol.Invalid as err: + _LOGGER.warning("Resource import failed. Data invalid: %s", err) + return None + + conf.pop(CONF_RESOURCES) + + for item in data: + item[collection.CONF_ID] = uuid.uuid4().hex + + data = {"items": data} + + await self.store.async_save(data) + await self.ll_config.async_save(conf) + + return data + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + raise NotImplementedError + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + raise NotImplementedError + + async def _update_data(self, data: dict, update_data: dict) -> dict: + """Return a new updated data object.""" + raise NotImplementedError diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py new file mode 100644 index 00000000000000..d80764f4ed954e --- /dev/null +++ b/homeassistant/components/lovelace/websocket.py @@ -0,0 +1,98 @@ +"""Websocket API for Lovelace.""" +from functools import wraps + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound + + +def _handle_errors(func): + """Handle error with WebSocket calls.""" + + @wraps(func) + async def send_with_error_handling(hass, connection, msg): + url_path = msg.get(CONF_URL_PATH) + config = hass.data[DOMAIN]["dashboards"].get(url_path) + + if config is None: + connection.send_error( + msg["id"], "config_not_found", f"Unknown config specified: {url_path}" + ) + return + + error = None + try: + result = await func(hass, connection, msg, config) + except ConfigNotFound: + error = "config_not_found", "No config found." + except HomeAssistantError as err: + error = "error", str(err) + + if error is not None: + connection.send_error(msg["id"], *error) + return + + if msg is not None: + await connection.send_big_result(msg["id"], result) + else: + connection.send_result(msg["id"], result) + + return send_with_error_handling + + +@websocket_api.async_response +@websocket_api.websocket_command({"type": "lovelace/resources"}) +async def websocket_lovelace_resources(hass, connection, msg): + """Send Lovelace UI resources over WebSocket configuration.""" + resources = hass.data[DOMAIN]["resources"] + + if not resources.loaded: + await resources.async_load() + resources.loaded = True + + connection.send_result(msg["id"], resources.async_items()) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "lovelace/config", + vol.Optional("force", default=False): bool, + vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string), + } +) +@_handle_errors +async def websocket_lovelace_config(hass, connection, msg, config): + """Send Lovelace UI config over WebSocket configuration.""" + return await config.async_load(msg["force"]) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "lovelace/config/save", + "config": vol.Any(str, dict), + vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string), + } +) +@_handle_errors +async def websocket_lovelace_save_config(hass, connection, msg, config): + """Save Lovelace UI configuration.""" + await config.async_save(msg["config"]) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "lovelace/config/delete", + vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string), + } +) +@_handle_errors +async def websocket_lovelace_delete_config(hass, connection, msg, config): + """Delete Lovelace UI configuration.""" + await config.async_delete() diff --git a/homeassistant/components/luftdaten/.translations/pl.json b/homeassistant/components/luftdaten/.translations/pl.json index 5a2c30db44c3c7..19e71b5156fd6d 100644 --- a/homeassistant/components/luftdaten/.translations/pl.json +++ b/homeassistant/components/luftdaten/.translations/pl.json @@ -3,7 +3,7 @@ "error": { "communication_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z API Luftdaten", "invalid_sensor": "Sensor niedost\u0119pny lub nieprawid\u0142owy", - "sensor_exists": "Sensor zosta\u0142 ju\u017c zarejestrowany" + "sensor_exists": "Sensor zosta\u0142 ju\u017c zarejestrowany." }, "step": { "user": { diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json index 1a05137f82d310..759fd926bdc4bf 100644 --- a/homeassistant/components/luftdaten/.translations/ru.json +++ b/homeassistant/components/luftdaten/.translations/ru.json @@ -2,14 +2,14 @@ "config": { "error": { "communication_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a API Luftdaten.", - "invalid_sensor": "\u0414\u0430\u0442\u0447\u0438\u043a \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", - "sensor_exists": "\u0414\u0430\u0442\u0447\u0438\u043a \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d." + "invalid_sensor": "\u0421\u0435\u043d\u0441\u043e\u0440 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", + "sensor_exists": "\u0421\u0435\u043d\u0441\u043e\u0440 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d." }, "step": { "user": { "data": { "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435", - "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u0430\u0442\u0447\u0438\u043a\u0430 Luftdaten" + "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0441\u0435\u043d\u0441\u043e\u0440\u0430 Luftdaten" }, "title": "Luftdaten" } diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index 4daadcd9c94c05..a722829a4a2520 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS, @@ -39,18 +40,20 @@ TOPIC_UPDATE = f"{DOMAIN}_data_update" -VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" - SENSORS = { SENSOR_TEMPERATURE: ["Temperature", "mdi:thermometer", TEMP_CELSIUS], SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", "%"], SENSOR_PRESSURE: ["Pressure", "mdi:arrow-down-bold", "Pa"], SENSOR_PRESSURE_AT_SEALEVEL: ["Pressure at sealevel", "mdi:download", "Pa"], - SENSOR_PM10: ["PM10", "mdi:thought-bubble", VOLUME_MICROGRAMS_PER_CUBIC_METER], + SENSOR_PM10: [ + "PM10", + "mdi:thought-bubble", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ], SENSOR_PM2_5: [ "PM2.5", "mdi:thought-bubble-outline", - VOLUME_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ], } diff --git a/homeassistant/components/lutron_caseta/.translations/hu.json b/homeassistant/components/lutron_caseta/.translations/hu.json new file mode 100644 index 00000000000000..cfc3c290afeebd --- /dev/null +++ b/homeassistant/components/lutron_caseta/.translations/hu.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Lutron Cas\u00e9ta" + } +} \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/.translations/sv.json b/homeassistant/components/lutron_caseta/.translations/sv.json new file mode 100644 index 00000000000000..cfc3c290afeebd --- /dev/null +++ b/homeassistant/components/lutron_caseta/.translations/sv.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Lutron Cas\u00e9ta" + } +} \ No newline at end of file diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py index 1b90d66398e831..d76fe9f0dc5515 100644 --- a/homeassistant/components/lyft/sensor.py +++ b/homeassistant/components/lyft/sensor.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -88,7 +89,7 @@ def __init__(self, sensorType, products, product_id, product): if "lyft" not in self._name.lower(): self._name = f"Lyft{self._name}" if self._sensortype == "time": - self._unit_of_measurement = "min" + self._unit_of_measurement = TIME_MINUTES elif self._sensortype == "price": estimate = self._product["estimate"] if estimate is not None: diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 0381d9323285a7..8526f6658c70b4 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -141,6 +141,7 @@ def __init__(self, hass, name): self.hass = hass self.name = name + @callback def async_update(self): """Send event notification of updated mailbox.""" self.hass.bus.async_fire(EVENT) @@ -168,7 +169,7 @@ async def async_get_messages(self): """Return a list of the current messages.""" raise NotImplementedError() - def async_delete(self, msgid): + async def async_delete(self, msgid): """Delete the specified messages.""" raise NotImplementedError() @@ -236,7 +237,7 @@ class MailboxDeleteView(MailboxView): async def delete(self, request, platform, msgid): """Delete items.""" mailbox = self.get_mailbox(platform) - mailbox.async_delete(msgid) + await mailbox.async_delete(msgid) class MailboxMediaView(MailboxView): diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index f11dac357e615e..00a82118ec4137 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,7 +29,6 @@ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change, track_point_in_time import homeassistant.util.dt as dt_util @@ -427,17 +426,16 @@ async def async_added_to_hass(self): self.hass, self.entity_id, self._async_state_changed_listener ) - @callback - def message_received(msg): + async def message_received(msg): """Run when new MQTT message has been received.""" if msg.payload == self._payload_disarm: - self.async_alarm_disarm(self._code) + await self.async_alarm_disarm(self._code) elif msg.payload == self._payload_arm_home: - self.async_alarm_arm_home(self._code) + await self.async_alarm_arm_home(self._code) elif msg.payload == self._payload_arm_away: - self.async_alarm_arm_away(self._code) + await self.async_alarm_arm_away(self._code) elif msg.payload == self._payload_arm_night: - self.async_alarm_arm_night(self._code) + await self.async_alarm_arm_night(self._code) else: _LOGGER.warning("Received unexpected payload: %s", msg.payload) return diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index 1b65cb161e12bb..3e6ecbc948b7b5 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -90,14 +90,14 @@ def __init__(self, cube, scan_interval): self.cube = cube self.scan_interval = scan_interval self.mutex = Lock() - self._updatets = time.time() + self._updatets = time.monotonic() def update(self): """Pull the latest data from the MAX! Cube.""" # Acquire mutex to prevent simultaneous update from multiple threads with self.mutex: # Only update every update_interval - if (time.time() - self._updatets) >= self.scan_interval: + if (time.monotonic() - self._updatets) >= self.scan_interval: _LOGGER.debug("Updating") try: @@ -106,6 +106,6 @@ def update(self): _LOGGER.error("Max!Cube connection failed") return False - self._updatets = time.time() + self._updatets = time.monotonic() else: _LOGGER.debug("Skipping update") diff --git a/homeassistant/components/mcp23017/binary_sensor.py b/homeassistant/components/mcp23017/binary_sensor.py index e95b91389cd53b..59f268e657c05b 100644 --- a/homeassistant/components/mcp23017/binary_sensor.py +++ b/homeassistant/components/mcp23017/binary_sensor.py @@ -1,7 +1,7 @@ """Support for binary sensor using I2C MCP23017 chip.""" import logging -import adafruit_mcp230xx # pylint: disable=import-error +from adafruit_mcp230xx.mcp23017 import MCP23017 # pylint: disable=import-error import board # pylint: disable=import-error import busio # pylint: disable=import-error import digitalio # pylint: disable=import-error @@ -46,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): i2c_address = config[CONF_I2C_ADDRESS] i2c = busio.I2C(board.SCL, board.SDA) - mcp = adafruit_mcp230xx.MCP23017(i2c, address=i2c_address) + mcp = MCP23017(i2c, address=i2c_address) binary_sensors = [] pins = config[CONF_PINS] diff --git a/homeassistant/components/mcp23017/manifest.json b/homeassistant/components/mcp23017/manifest.json index ebf796fe3dbc8e..8bdd897d34eb4d 100644 --- a/homeassistant/components/mcp23017/manifest.json +++ b/homeassistant/components/mcp23017/manifest.json @@ -4,8 +4,8 @@ "documentation": "https://www.home-assistant.io/integrations/mcp23017", "requirements": [ "RPi.GPIO==0.7.0", - "adafruit-blinka==1.2.1", - "adafruit-circuitpython-mcp230xx==1.1.2" + "adafruit-blinka==3.9.0", + "adafruit-circuitpython-mcp230xx==2.2.2" ], "dependencies": [], "codeowners": ["@jardiamj"] diff --git a/homeassistant/components/mcp23017/switch.py b/homeassistant/components/mcp23017/switch.py index 8506106b705be2..fe76f4ce632703 100644 --- a/homeassistant/components/mcp23017/switch.py +++ b/homeassistant/components/mcp23017/switch.py @@ -1,7 +1,7 @@ """Support for switch sensor using I2C MCP23017 chip.""" import logging -import adafruit_mcp230xx # pylint: disable=import-error +from adafruit_mcp230xx.mcp23017 import MCP23017 # pylint: disable=import-error import board # pylint: disable=import-error import busio # pylint: disable=import-error import digitalio # pylint: disable=import-error @@ -39,7 +39,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): i2c_address = config.get(CONF_I2C_ADDRESS) i2c = busio.I2C(board.SCL, board.SDA) - mcp = adafruit_mcp230xx.MCP23017(i2c, address=i2c_address) + mcp = MCP23017(i2c, address=i2c_address) switches = [] pins = config.get(CONF_PINS) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 28bbc92b850938..815a00c5223f00 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2020.01.24"], + "requirements": ["youtube_dl==2020.02.16"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/media_player/.translations/hu.json b/homeassistant/components/media_player/.translations/hu.json new file mode 100644 index 00000000000000..fbefbc43e083ba --- /dev/null +++ b/homeassistant/components/media_player/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "device_automation": { + "condition_type": { + "is_idle": "{entity_name} t\u00e9tlen", + "is_off": "{entity_name} ki van kapcsolva", + "is_on": "{entity_name} be van kapcsolva", + "is_paused": "{entity_name} sz\u00fcneteltetve van", + "is_playing": "{entity_name} lej\u00e1tszik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/media_player/.translations/zh-Hant.json b/homeassistant/components/media_player/.translations/zh-Hant.json index abd2f75950b6c1..e3353c5e5b9ec4 100644 --- a/homeassistant/components/media_player/.translations/zh-Hant.json +++ b/homeassistant/components/media_player/.translations/zh-Hant.json @@ -1,11 +1,11 @@ { "device_automation": { "condition_type": { - "is_idle": "{entity_name} \u9592\u7f6e", - "is_off": "{entity_name} \u95dc\u9589", - "is_on": "{entity_name} \u958b\u555f", - "is_paused": "{entity_name} \u5df2\u66ab\u505c", - "is_playing": "{entity_name} \u6b63\u5728\u64ad\u653e" + "is_idle": "{entity_name}\u9592\u7f6e", + "is_off": "{entity_name}\u95dc\u9589", + "is_on": "{entity_name}\u958b\u555f", + "is_paused": "{entity_name}\u5df2\u66ab\u505c", + "is_playing": "{entity_name}\u6b63\u5728\u64ad\u653e" } } } \ No newline at end of file diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b73208402f8716..8a31dbe6bdb611 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -173,6 +173,23 @@ def is_on(hass, entity_id=None): ) +def _rename_keys(**keys): + """Create validator that renames keys. + + Necessary because the service schema names do not match the command parameters. + + Async friendly. + """ + + def rename(value): + for to_key, from_key in keys.items(): + if from_key in value: + value[to_key] = value.pop(from_key) + return value + + return rename + + async def async_setup(hass, config): """Track states and offer events for media_players.""" component = hass.data[DOMAIN] = EntityComponent( @@ -238,30 +255,39 @@ async def async_setup(hass, config): ) component.async_register_entity_service( SERVICE_VOLUME_SET, - {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, - lambda entity, call: entity.async_set_volume_level( - volume=call.data[ATTR_MEDIA_VOLUME_LEVEL] + vol.All( + cv.make_entity_service_schema( + {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float} + ), + _rename_keys(volume=ATTR_MEDIA_VOLUME_LEVEL), ), + "async_set_volume_level", [SUPPORT_VOLUME_SET], ) component.async_register_entity_service( SERVICE_VOLUME_MUTE, - {vol.Required(ATTR_MEDIA_VOLUME_MUTED): cv.boolean}, - lambda entity, call: entity.async_mute_volume( - mute=call.data[ATTR_MEDIA_VOLUME_MUTED] + vol.All( + cv.make_entity_service_schema( + {vol.Required(ATTR_MEDIA_VOLUME_MUTED): cv.boolean} + ), + _rename_keys(mute=ATTR_MEDIA_VOLUME_MUTED), ), + "async_mute_volume", [SUPPORT_VOLUME_MUTE], ) component.async_register_entity_service( SERVICE_MEDIA_SEEK, - { - vol.Required(ATTR_MEDIA_SEEK_POSITION): vol.All( - vol.Coerce(float), vol.Range(min=0) - ) - }, - lambda entity, call: entity.async_media_seek( - position=call.data[ATTR_MEDIA_SEEK_POSITION] + vol.All( + cv.make_entity_service_schema( + { + vol.Required(ATTR_MEDIA_SEEK_POSITION): vol.All( + vol.Coerce(float), vol.Range(min=0) + ) + } + ), + _rename_keys(position=ATTR_MEDIA_SEEK_POSITION), ), + "async_media_seek", [SUPPORT_SEEK], ) component.async_register_entity_service( @@ -278,12 +304,15 @@ async def async_setup(hass, config): ) component.async_register_entity_service( SERVICE_PLAY_MEDIA, - MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, - lambda entity, call: entity.async_play_media( - media_type=call.data[ATTR_MEDIA_CONTENT_TYPE], - media_id=call.data[ATTR_MEDIA_CONTENT_ID], - enqueue=call.data.get(ATTR_MEDIA_ENQUEUE), + vol.All( + cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA), + _rename_keys( + media_type=ATTR_MEDIA_CONTENT_TYPE, + media_id=ATTR_MEDIA_CONTENT_ID, + enqueue=ATTR_MEDIA_ENQUEUE, + ), ), + "async_play_media", [SUPPORT_PLAY_MEDIA], ) component.async_register_entity_service( @@ -485,122 +514,89 @@ def turn_on(self): """Turn the media player on.""" raise NotImplementedError() - def async_turn_on(self): - """Turn the media player on. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.turn_on) + async def async_turn_on(self): + """Turn the media player on.""" + await self.hass.async_add_job(self.turn_on) def turn_off(self): """Turn the media player off.""" raise NotImplementedError() - def async_turn_off(self): - """Turn the media player off. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.turn_off) + async def async_turn_off(self): + """Turn the media player off.""" + await self.hass.async_add_job(self.turn_off) def mute_volume(self, mute): """Mute the volume.""" raise NotImplementedError() - def async_mute_volume(self, mute): - """Mute the volume. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.mute_volume, mute) + async def async_mute_volume(self, mute): + """Mute the volume.""" + await self.hass.async_add_job(self.mute_volume, mute) def set_volume_level(self, volume): """Set volume level, range 0..1.""" raise NotImplementedError() - def async_set_volume_level(self, volume): - """Set volume level, range 0..1. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_volume_level, volume) + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self.hass.async_add_job(self.set_volume_level, volume) def media_play(self): """Send play command.""" raise NotImplementedError() - def async_media_play(self): - """Send play command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.media_play) + async def async_media_play(self): + """Send play command.""" + await self.hass.async_add_job(self.media_play) def media_pause(self): """Send pause command.""" raise NotImplementedError() - def async_media_pause(self): - """Send pause command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.media_pause) + async def async_media_pause(self): + """Send pause command.""" + await self.hass.async_add_job(self.media_pause) def media_stop(self): """Send stop command.""" raise NotImplementedError() - def async_media_stop(self): - """Send stop command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.media_stop) + async def async_media_stop(self): + """Send stop command.""" + await self.hass.async_add_job(self.media_stop) def media_previous_track(self): """Send previous track command.""" raise NotImplementedError() - def async_media_previous_track(self): - """Send previous track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.media_previous_track) + async def async_media_previous_track(self): + """Send previous track command.""" + await self.hass.async_add_job(self.media_previous_track) def media_next_track(self): """Send next track command.""" raise NotImplementedError() - def async_media_next_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.media_next_track) + async def async_media_next_track(self): + """Send next track command.""" + await self.hass.async_add_job(self.media_next_track) def media_seek(self, position): """Send seek command.""" raise NotImplementedError() - def async_media_seek(self, position): - """Send seek command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.media_seek, position) + async def async_media_seek(self, position): + """Send seek command.""" + await self.hass.async_add_job(self.media_seek, position) def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" raise NotImplementedError() - def async_play_media(self, media_type, media_id, **kwargs): - """Play a piece of media. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job( + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + await self.hass.async_add_job( ft.partial(self.play_media, media_type, media_id, **kwargs) ) @@ -608,45 +604,33 @@ def select_source(self, source): """Select input source.""" raise NotImplementedError() - def async_select_source(self, source): - """Select input source. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.select_source, source) + async def async_select_source(self, source): + """Select input source.""" + await self.hass.async_add_job(self.select_source, source) def select_sound_mode(self, sound_mode): """Select sound mode.""" raise NotImplementedError() - def async_select_sound_mode(self, sound_mode): - """Select sound mode. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.select_sound_mode, sound_mode) + async def async_select_sound_mode(self, sound_mode): + """Select sound mode.""" + await self.hass.async_add_job(self.select_sound_mode, sound_mode) def clear_playlist(self): """Clear players playlist.""" raise NotImplementedError() - def async_clear_playlist(self): - """Clear players playlist. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.clear_playlist) + async def async_clear_playlist(self): + """Clear players playlist.""" + await self.hass.async_add_job(self.clear_playlist) def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" raise NotImplementedError() - def async_set_shuffle(self, shuffle): - """Enable/disable shuffle mode. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_shuffle, shuffle) + async def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + await self.hass.async_add_job(self.set_shuffle, shuffle) # No need to overwrite these. @property @@ -714,18 +698,17 @@ def support_shuffle_set(self): """Boolean if shuffle is supported.""" return bool(self.supported_features & SUPPORT_SHUFFLE_SET) - def async_toggle(self): - """Toggle the power on the media player. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_toggle(self): + """Toggle the power on the media player.""" if hasattr(self, "toggle"): # pylint: disable=no-member - return self.hass.async_add_job(self.toggle) + await self.hass.async_add_job(self.toggle) + return if self.state in [STATE_OFF, STATE_IDLE]: - return self.async_turn_on() - return self.async_turn_off() + await self.async_turn_on() + else: + await self.async_turn_off() async def async_volume_up(self): """Turn volume up for media player. @@ -753,18 +736,17 @@ async def async_volume_down(self): if self.volume_level > 0 and self.supported_features & SUPPORT_VOLUME_SET: await self.async_set_volume_level(max(0, self.volume_level - 0.1)) - def async_media_play_pause(self): - """Play or pause the media player. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_play_pause(self): + """Play or pause the media player.""" if hasattr(self, "media_play_pause"): # pylint: disable=no-member - return self.hass.async_add_job(self.media_play_pause) + await self.hass.async_add_job(self.media_play_pause) + return if self.state == STATE_PLAYING: - return self.async_media_pause() - return self.async_media_play() + await self.async_media_pause() + else: + await self.async_media_play() @property def entity_picture(self): @@ -784,7 +766,7 @@ def entity_picture(self): @property def capability_attributes(self): - """Return capabilitiy attributes.""" + """Return capability attributes.""" supported_features = self.supported_features or 0 data = {} diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index a8091a6aed81b7..6faa6521b707b3 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -16,7 +16,7 @@ STATE_PAUSED, STATE_PLAYING, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -95,6 +95,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: diff --git a/homeassistant/components/melcloud/.translations/ca.json b/homeassistant/components/melcloud/.translations/ca.json new file mode 100644 index 00000000000000..1dc5156f7e7349 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "La integraci\u00f3 MELCloud ja est\u00e0 configurada amb aquest correu electr\u00f2nic. El testimoni d'acc\u00e9s s'ha actualitzat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya de MELCloud.", + "username": "Correu electr\u00f2nic d'inici de sessi\u00f3 a MELCloud." + }, + "description": "Connecta\u2019t amb el teu compte de MELCloud.", + "title": "Connexi\u00f3 amb MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/da.json b/homeassistant/components/melcloud/.translations/da.json new file mode 100644 index 00000000000000..6901ed229345da --- /dev/null +++ b/homeassistant/components/melcloud/.translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud-integration er allerede konfigureret for denne e-mail. Adgangstoken er blevet opdateret." + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse. Pr\u00f8v igen", + "invalid_auth": "Ugyldig godkendelse", + "unknown": "Uventet fejl" + }, + "step": { + "user": { + "data": { + "password": "MELCloud-adgangskode.", + "username": "E-mail, der bruges til at logge ind p\u00e5 MELCloud." + }, + "description": "Opret forbindelse ved hj\u00e6lp af din MELCloud-konto.", + "title": "Opret forbindelse til MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/de.json b/homeassistant/components/melcloud/.translations/de.json new file mode 100644 index 00000000000000..f4e2a3b1ebc7df --- /dev/null +++ b/homeassistant/components/melcloud/.translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Die MELCloud-Integration ist bereits f\u00fcr diese E-Mail konfiguriert. Das Zugriffstoken wurde aktualisiert." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "MELCloud Passwort.", + "username": "E-Mail-Adresse f\u00fcr die Anmeldung bei MELCloud." + }, + "description": "Verbinden Sie sich mit Ihrem MELCloud-Konto.", + "title": "Stellen Sie eine Verbindung zu MELCloud her" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/en.json b/homeassistant/components/melcloud/.translations/en.json new file mode 100644 index 00000000000000..48682f617a3ba6 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "MELCloud password.", + "username": "Email used to login to MELCloud." + }, + "description": "Connect using your MELCloud account.", + "title": "Connect to MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/es.json b/homeassistant/components/melcloud/.translations/es.json new file mode 100644 index 00000000000000..182f06c33c3984 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Integraci\u00f3n mELCloud ya configurada para este correo electr\u00f3nico. Se ha actualizado el token de acceso." + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntelo de nuevo.", + "invalid_auth": "Autentificaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a de MELCloud.", + "username": "Correo electr\u00f3nico utilizado para iniciar sesi\u00f3n en MELCloud." + }, + "description": "Con\u00e9ctate usando tu cuenta de MELCloud.", + "title": "Con\u00e9ctese a MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/fr.json b/homeassistant/components/melcloud/.translations/fr.json new file mode 100644 index 00000000000000..e442325d9dc3fb --- /dev/null +++ b/homeassistant/components/melcloud/.translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "username": "E-mail utilis\u00e9e pour vous connecter \u00e0 MELCloud." + }, + "description": "Se connecter en utilisant votre MELCloud compte.", + "title": "Se connecter \u00e0 MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/it.json b/homeassistant/components/melcloud/.translations/it.json new file mode 100644 index 00000000000000..029fc2526b28e1 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Integrazione MELCloud gi\u00e0 configurata per questa e-mail. Il token di accesso \u00e8 stato aggiornato." + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password MELCloud.", + "username": "Email utilizzata per accedere a MELCloud." + }, + "description": "Connettiti utilizzando il tuo account MELCloud.", + "title": "Connettersi a MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/ko.json b/homeassistant/components/melcloud/.translations/ko.json new file mode 100644 index 00000000000000..1557abf5a32696 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uc774\uba54\uc77c\uc5d0 \ub300\ud55c MELCloud \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uac31\uc2e0\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "MELCloud \uc758 \ube44\ubc00\ubc88\ud638\ub97c \ub123\uc5b4\uc8fc\uc138\uc694.", + "username": "MELCloud \ub85c\uadf8\uc778 \uc774\uba54\uc77c \uc8fc\uc18c\ub97c \ub123\uc5b4\uc8fc\uc138\uc694." + }, + "description": "MELCloud \uacc4\uc815\uc73c\ub85c \uc5f0\uacb0\ud558\uc138\uc694.", + "title": "MELCloud \uc5f0\uacb0" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/lb.json b/homeassistant/components/melcloud/.translations/lb.json new file mode 100644 index 00000000000000..b082ef78965392 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud Integratioun ass scho konfigur\u00e9iert fir d\u00ebs Email. Acc\u00e8s Jeton gouf erneiert." + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "password": "MELCloud Passwuert", + "username": "Email d\u00e9i benotz g\u00ebtt fir sech mat MELCloud ze verbannen" + }, + "description": "Verbann dech mat dengem MElCloud Kont.", + "title": "Mat MELCloud verbannen" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/nl.json b/homeassistant/components/melcloud/.translations/nl.json new file mode 100644 index 00000000000000..b60495e7f47422 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud integratie is al geconfigureerd voor deze e-mail. Toegangstoken is vernieuwd." + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "MELCloud wachtwoord.", + "username": "E-mail gebruikt om in te loggen op MELCloud." + }, + "description": "Maak verbinding via uw MELCloud account.", + "title": "Maak verbinding met MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/no.json b/homeassistant/components/melcloud/.translations/no.json new file mode 100644 index 00000000000000..a464bbfda1960d --- /dev/null +++ b/homeassistant/components/melcloud/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud integrasjon er allerede konfigurert p\u00e5 denne e-posten. Access token har blitt oppdatert." + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "MELCloud passord.", + "username": "E-post som blir brukt til \u00e5 logge inn p\u00e5 MELCloud." + }, + "description": "Koble til ved hjelp av MELCloud-kontoen din.", + "title": "Koble til MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/pl.json b/homeassistant/components/melcloud/.translations/pl.json new file mode 100644 index 00000000000000..9abb68ca85a4bb --- /dev/null +++ b/homeassistant/components/melcloud/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Integracja MELCloud jest ju\u017c skonfigurowana dla tego adresu e-mail. Token dost\u0119pu zosta\u0142 od\u015bwie\u017cony." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o MELCloud.", + "username": "Adres e-mail u\u017cywany do logowania do MELCloud" + }, + "description": "Po\u0142\u0105cz u\u017cywaj\u0105c swojego konta MELCloud.", + "title": "Po\u0142\u0105cz si\u0119 z MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/ru.json b/homeassistant/components/melcloud/.translations/ru.json new file mode 100644 index 00000000000000..d4bab0e417e13a --- /dev/null +++ b/homeassistant/components/melcloud/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f MELCloud \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0430\u0434\u0440\u0435\u0441\u0430 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b. \u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c MELCloud.", + "username": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u0430\u044f \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u0432 MELCloud." + }, + "description": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0435\u0441\u044c, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c MELCloud.", + "title": "MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/sl.json b/homeassistant/components/melcloud/.translations/sl.json new file mode 100644 index 00000000000000..04dbb953d0dd43 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Za to e-po\u0161to je \u017ee konfigurirana integracija MELCloud. \u017deton za dostop je bil osve\u017een." + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "password": "MELCloud geslo.", + "username": "E-po\u0161tni naslov za prijavo v MELCloud." + }, + "description": "Pove\u017eite se s svojim ra\u010dunom MELCloud.", + "title": "Pove\u017eite se z MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/sv.json b/homeassistant/components/melcloud/.translations/sv.json new file mode 100644 index 00000000000000..72a251ef9d0704 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud-integration redan konfigurerad f\u00f6r den h\u00e4r e-postadressen. \u00c5tkomsttoken har uppdaterats." + }, + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "password": "MELCloud-l\u00f6senord.", + "username": "E-post som anv\u00e4nds f\u00f6r att logga in p\u00e5 MELCloud." + }, + "description": "Anslut med ditt MELCloud-konto.", + "title": "Anslut till MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/zh-Hant.json b/homeassistant/components/melcloud/.translations/zh-Hant.json new file mode 100644 index 00000000000000..c098d04159831d --- /dev/null +++ b/homeassistant/components/melcloud/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u5df2\u4f7f\u7528\u6b64\u90f5\u4ef6\u8a2d\u5b9a MELCloud \u6574\u5408\u3002\u5b58\u53d6\u5bc6\u9470\u5df2\u66f4\u65b0\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "MELCloud \u5bc6\u78bc\u3002", + "username": "MELCloud \u767b\u5165\u90f5\u4ef6\u3002" + }, + "description": "\u4f7f\u7528 MELCloud \u5e33\u865f\u9032\u884c\u9023\u7dda\u3002", + "title": "\u9023\u7dda\u81f3 MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py new file mode 100644 index 00000000000000..ef932f36aa4bbd --- /dev/null +++ b/homeassistant/components/melcloud/__init__.py @@ -0,0 +1,160 @@ +"""The MELCloud Climate integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Dict, List + +from aiohttp import ClientConnectionError +from async_timeout import timeout +from pymelcloud import Device, get_devices +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +PLATFORMS = ["climate", "sensor"] + +CONF_LANGUAGE = "language" +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_TOKEN): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigEntry): + """Establish connection with MELCloud.""" + if DOMAIN not in config: + return True + + username = config[DOMAIN][CONF_USERNAME] + token = config[DOMAIN][CONF_TOKEN] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_USERNAME: username, CONF_TOKEN: token}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Establish connection with MELClooud.""" + conf = entry.data + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return True + + +class MelCloudDevice: + """MELCloud Device instance.""" + + def __init__(self, device: Device): + """Construct a device wrapper.""" + self.device = device + self.name = device.name + self._available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self, **kwargs): + """Pull the latest data from MELCloud.""" + try: + await self.device.update() + self._available = True + except ClientConnectionError: + _LOGGER.warning("Connection failed for %s", self.name) + self._available = False + + async def async_set(self, properties: Dict[str, Any]): + """Write state changes to the MELCloud API.""" + try: + await self.device.set(properties) + self._available = True + except ClientConnectionError: + _LOGGER.warning("Connection failed for %s", self.name) + self._available = False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_id(self): + """Return device ID.""" + return self.device.device_id + + @property + def building_id(self): + """Return building ID of the device.""" + return self.device.building_id + + @property + def device_info(self): + """Return a device description for device registry.""" + _device_info = { + "identifiers": {(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, + "manufacturer": "Mitsubishi Electric", + "name": self.name, + } + unit_infos = self.device.units + if unit_infos is not None: + _device_info["model"] = ", ".join( + [x["model"] for x in unit_infos if x["model"]] + ) + return _device_info + + +async def mel_devices_setup(hass, token) -> List[MelCloudDevice]: + """Query connected devices from MELCloud.""" + session = hass.helpers.aiohttp_client.async_get_clientsession() + try: + with timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady() from ex + + wrapped_devices = {} + for device_type, devices in all_devices.items(): + wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] + return wrapped_devices diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py new file mode 100644 index 00000000000000..95cb1489f45998 --- /dev/null +++ b/homeassistant/components/melcloud/climate.py @@ -0,0 +1,171 @@ +"""Platform for climate integration.""" +from datetime import timedelta +import logging +from typing import List, Optional + +from pymelcloud import DEVICE_TYPE_ATA + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.temperature import convert as convert_temperature + +from . import MelCloudDevice +from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +): + """Set up MelCloud device climate based on config_entry.""" + mel_devices = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]], + True, + ) + + +class AtaDeviceClimate(ClimateDevice): + """Air-to-Air climate device.""" + + def __init__(self, device: MelCloudDevice): + """Initialize the climate.""" + self._api = device + self._device = self._api.device + self._name = device.name + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._device.serial}-{self._device.mac}" + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + async def async_update(self): + """Update state from MELCloud.""" + await self._api.async_update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + mode = self._device.operation_mode + if not self._device.power or mode is None: + return HVAC_MODE_OFF + return HVAC_MODE_LOOKUP.get(mode) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._device.set({"power": False}) + return + + operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) + if operation_mode is None: + raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") + + props = {"operation_mode": operation_mode} + if self.hvac_mode == HVAC_MODE_OFF: + props["power"] = True + await self._device.set(props) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_OFF] + [ + HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes + ] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self._device.target_temperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self._device.set( + {"target_temperature": kwargs.get("temperature", self.target_temperature)} + ) + + @property + def target_temperature_step(self) -> Optional[float]: + """Return the supported step of target temperature.""" + return self._device.target_temperature_step + + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + return self._device.fan_speed + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._device.set({"fan_speed": fan_mode}) + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + return self._device.fan_speeds + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set({"power": True}) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.set({"power": False}) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + min_value = self._device.target_temperature_min + if min_value is not None: + return min_value + + return convert_temperature( + DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit + ) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + max_value = self._device.target_temperature_max + if max_value is not None: + return max_value + + return convert_temperature( + DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit + ) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py new file mode 100644 index 00000000000000..6bda8cc3c28242 --- /dev/null +++ b/homeassistant/components/melcloud/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for the MELCloud platform.""" +import asyncio +import logging +from typing import Optional + +from aiohttp import ClientError, ClientResponseError +from async_timeout import timeout +import pymelcloud +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _create_entry(self, username: str, token: str): + """Register new entry.""" + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured({CONF_TOKEN: token}) + return self.async_create_entry( + title=username, data={CONF_USERNAME: username, CONF_TOKEN: token}, + ) + + async def _create_client( + self, + username: str, + *, + password: Optional[str] = None, + token: Optional[str] = None, + ): + """Create client.""" + if password is None and token is None: + raise ValueError( + "Invalid internal state. Called without either password or token", + ) + + try: + with timeout(10): + acquired_token = token + if acquired_token is None: + acquired_token = await pymelcloud.login( + username, + password, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + await pymelcloud.get_devices( + acquired_token, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + except ClientResponseError as err: + if err.status == 401 or err.status == 403: + return self.async_abort(reason="invalid_auth") + return self.async_abort(reason="cannot_connect") + except (asyncio.TimeoutError, ClientError): + return self.async_abort(reason="cannot_connect") + + return await self._create_entry(username, acquired_token) + + async def async_step_user(self, user_input=None): + """User initiated config flow.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + ) + username = user_input[CONF_USERNAME] + return await self._create_client(username, password=user_input[CONF_PASSWORD]) + + async def async_step_import(self, user_input): + """Import a config entry.""" + return await self._create_client( + user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] + ) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py new file mode 100644 index 00000000000000..e262be2c3fb3b1 --- /dev/null +++ b/homeassistant/components/melcloud/const.py @@ -0,0 +1,29 @@ +"""Constants for the MELCloud Climate integration.""" +import pymelcloud.ata_device as ata_device +from pymelcloud.const import UNIT_TEMP_CELSIUS, UNIT_TEMP_FAHRENHEIT + +from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, +) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + +DOMAIN = "melcloud" + +HVAC_MODE_LOOKUP = { + ata_device.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + ata_device.OPERATION_MODE_DRY: HVAC_MODE_DRY, + ata_device.OPERATION_MODE_COOL: HVAC_MODE_COOL, + ata_device.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, + ata_device.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, +} +HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in HVAC_MODE_LOOKUP.items()} + +TEMP_UNIT_LOOKUP = { + UNIT_TEMP_CELSIUS: TEMP_CELSIUS, + UNIT_TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, +} +TEMP_UNIT_REVERSE_LOOKUP = {v: k for k, v in TEMP_UNIT_LOOKUP.items()} diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json new file mode 100644 index 00000000000000..55edcdd0d9f9c8 --- /dev/null +++ b/homeassistant/components/melcloud/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "melcloud", + "name": "MELCloud", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/melcloud", + "requirements": ["pymelcloud==2.1.0"], + "dependencies": [], + "codeowners": ["@vilppuvuorinen"] +} diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py new file mode 100644 index 00000000000000..8f55906443ea98 --- /dev/null +++ b/homeassistant/components/melcloud/sensor.py @@ -0,0 +1,102 @@ +"""Support for MelCloud device sensors.""" +import logging + +from pymelcloud import DEVICE_TYPE_ATA + +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +from . import MelCloudDevice +from .const import DOMAIN, TEMP_UNIT_LOOKUP + +ATTR_MEASUREMENT_NAME = "measurement_name" +ATTR_ICON = "icon" +ATTR_UNIT_FN = "unit_fn" +ATTR_DEVICE_CLASS = "device_class" +ATTR_VALUE_FN = "value_fn" +ATTR_ENABLED_FN = "enabled" + +SENSORS = { + "room_temperature": { + ATTR_MEASUREMENT_NAME: "Room Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda x: x.device.room_temperature, + ATTR_ENABLED_FN: lambda x: True, + }, + "energy": { + ATTR_MEASUREMENT_NAME: "Energy", + ATTR_ICON: "mdi:factory", + ATTR_UNIT_FN: lambda x: "kWh", + ATTR_DEVICE_CLASS: None, + ATTR_VALUE_FN: lambda x: x.device.total_energy_consumed, + ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter, + }, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up MELCloud device sensors based on config_entry.""" + mel_devices = hass.data[DOMAIN].get(entry.entry_id) + async_add_entities( + [ + MelCloudSensor(mel_device, measurement, definition) + for measurement, definition in SENSORS.items() + for mel_device in mel_devices[DEVICE_TYPE_ATA] + if definition[ATTR_ENABLED_FN](mel_device) + ], + True, + ) + + +class MelCloudSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, device: MelCloudDevice, measurement, definition): + """Initialize the sensor.""" + self._api = device + self._name_slug = device.name + self._measurement = measurement + self._def = definition + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._api.device.serial}-{self._api.device.mac}-{self._measurement}" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._def[ATTR_ICON] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name_slug} {self._def[ATTR_MEASUREMENT_NAME]}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._def[ATTR_VALUE_FN](self._api) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._def[ATTR_UNIT_FN](self._api) + + @property + def device_class(self): + """Return device class.""" + return self._def[ATTR_DEVICE_CLASS] + + async def async_update(self): + """Retrieve latest state.""" + await self._api.async_update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json new file mode 100644 index 00000000000000..477ca7eb5e2844 --- /dev/null +++ b/homeassistant/components/melcloud/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "MELCloud", + "step": { + "user": { + "title": "Connect to MELCloud", + "description": "Connect using your MELCloud account.", + "data": { + "username": "Email used to login to MELCloud.", + "password": "MELCloud password." + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." + } + } +} diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 1aa1485922e32c..614c2943530710 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -1,10 +1,4 @@ -""" -Support for the Meraki CMX location service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.meraki/ - -""" +"""Support for the Meraki CMX location service.""" import json import logging diff --git a/homeassistant/components/met/.translations/pl.json b/homeassistant/components/met/.translations/pl.json index f647dcf7b45365..e22ac763d568bd 100644 --- a/homeassistant/components/met/.translations/pl.json +++ b/homeassistant/components/met/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Lokalizacja ju\u017c istnieje" + "name_exists": "Lokalizacja ju\u017c istnieje." }, "step": { "user": { diff --git a/homeassistant/components/met/.translations/sv.json b/homeassistant/components/met/.translations/sv.json index aa860e27307abb..d8b461913da83d 100644 --- a/homeassistant/components/met/.translations/sv.json +++ b/homeassistant/components/met/.translations/sv.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Namnet finns redan" + "name_exists": "Plats finns redan" }, "step": { "user": { diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 759f7f6fc89994..683390429c3923 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -12,15 +12,15 @@ @callback def configured_instances(hass): """Return a set of configured SimpliSafe instances.""" - entites = [] + entries = [] for entry in hass.config_entries.async_entries(DOMAIN): if entry.data.get("track_home"): - entites.append("home") + entries.append("home") continue - entites.append( + entries.append( f"{entry.data.get(CONF_LATITUDE)}-{entry.data.get(CONF_LONGITUDE)}" ) - return set(entites) + return set(entries) class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/meteo_france/.translations/ca.json b/homeassistant/components/meteo_france/.translations/ca.json new file mode 100644 index 00000000000000..aeceb80a063136 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Ciutat ja configurada", + "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard" + }, + "step": { + "user": { + "data": { + "city": "Ciutat" + }, + "description": "Introdueix el codi postal (nom\u00e9s recomanat per Fran\u00e7a) o nom de la ciutat", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/da.json b/homeassistant/components/meteo_france/.translations/da.json new file mode 100644 index 00000000000000..7c49d6f15ee7b6 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "By er allerede konfigureret", + "unknown": "Ukendt fejl: Pr\u00f8v igen senere" + }, + "step": { + "user": { + "data": { + "city": "By" + }, + "description": "Indtast postnummer (kun for Frankrig, anbefalet) eller bynavn", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/de.json b/homeassistant/components/meteo_france/.translations/de.json new file mode 100644 index 00000000000000..8f05ad18df3747 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Stadt bereits konfiguriert", + "unknown": "Unbekannter Fehler: Bitte versuchen Sie es sp\u00e4ter erneut" + }, + "step": { + "user": { + "data": { + "city": "Stadt" + }, + "description": "Geben Sie die Postleitzahl (nur f\u00fcr Frankreich empfohlen) oder den St\u00e4dtenamen ein", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/en.json b/homeassistant/components/meteo_france/.translations/en.json new file mode 100644 index 00000000000000..804ad9d67b1bbb --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "City already configured", + "unknown": "Unknown error: please retry later" + }, + "step": { + "user": { + "data": { + "city": "City" + }, + "description": "Enter the postal code (only for France, recommended) or city name", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/es.json b/homeassistant/components/meteo_france/.translations/es.json new file mode 100644 index 00000000000000..3cd7ee56252fc6 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "La ciudad ya est\u00e1 configurada", + "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde" + }, + "step": { + "user": { + "data": { + "city": "Ciudad" + }, + "description": "Introduzca el c\u00f3digo postal (solo para Francia, recomendado) o el nombre de la ciudad", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/fr.json b/homeassistant/components/meteo_france/.translations/fr.json new file mode 100644 index 00000000000000..7dff0d237fdebc --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Ville d\u00e9j\u00e0 configur\u00e9e", + "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + }, + "step": { + "user": { + "data": { + "city": "Ville" + }, + "description": "Entrez le code postal (uniquement pour la France, recommand\u00e9) ou le nom de la ville", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/hu.json b/homeassistant/components/meteo_france/.translations/hu.json new file mode 100644 index 00000000000000..f1719f4bf30339 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "A v\u00e1ros m\u00e1r konfigur\u00e1lva van", + "unknown": "Ismeretlen hiba: k\u00e9rj\u00fck, pr\u00f3b\u00e1lja \u00fajra k\u00e9s\u0151bb" + }, + "step": { + "user": { + "data": { + "city": "V\u00e1ros" + }, + "description": "\u00cdrja be az ir\u00e1ny\u00edt\u00f3sz\u00e1mot (csak Franciaorsz\u00e1g eset\u00e9ben aj\u00e1nlott) vagy a v\u00e1ros nev\u00e9t", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/it.json b/homeassistant/components/meteo_france/.translations/it.json new file mode 100644 index 00000000000000..5a067430906737 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Citt\u00e0 gi\u00e0 configurata", + "unknown": "Errore sconosciuto: riprovare pi\u00f9 tardi" + }, + "step": { + "user": { + "data": { + "city": "Citt\u00e0" + }, + "description": "Inserisci il codice postale (solo per la Francia, consigliato) o il nome della citt\u00e0", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/ko.json b/homeassistant/components/meteo_france/.translations/ko.json new file mode 100644 index 00000000000000..8b2c7f49735341 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\ub3c4\uc2dc\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" + }, + "step": { + "user": { + "data": { + "city": "\ub3c4\uc2dc" + }, + "description": "\uc6b0\ud3b8\ubc88\ud638 (\ud504\ub791\uc2a4) \ub610\ub294 \ub3c4\uc2dc \uc774\ub984\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "\ud504\ub791\uc2a4 \uae30\uc0c1\uccad (M\u00e9t\u00e9o-France)" + } + }, + "title": "\ud504\ub791\uc2a4 \uae30\uc0c1\uccad (M\u00e9t\u00e9o-France)" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/lb.json b/homeassistant/components/meteo_france/.translations/lb.json new file mode 100644 index 00000000000000..e2ee25882be8a4 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Stad ass scho konfigur\u00e9iert", + "unknown": "Onbekannte Feeler: prob\u00e9iert sp\u00e9ider nach emol" + }, + "step": { + "user": { + "data": { + "city": "Stad" + }, + "description": "Gitt de Postcode an (n\u00ebmme fir Frankr\u00e4ich, recommand\u00e9iert) oder den Numm vun der Stad", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/nl.json b/homeassistant/components/meteo_france/.translations/nl.json new file mode 100644 index 00000000000000..648ef0c5fbd551 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Stad al geconfigureerd", + "unknown": "Onbekende fout: probeer het later nog eens" + }, + "step": { + "user": { + "data": { + "city": "Stad" + }, + "description": "Vul de postcode (alleen voor Frankrijk, aanbevolen) of de plaatsnaam in", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/no.json b/homeassistant/components/meteo_france/.translations/no.json new file mode 100644 index 00000000000000..1de1094f0a5f5d --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Byen er allerede konfigurert", + "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere" + }, + "step": { + "user": { + "data": { + "city": "By" + }, + "description": "Skriv inn postnummeret (bare for Frankrike, anbefalt) eller bynavn", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/pl.json b/homeassistant/components/meteo_france/.translations/pl.json new file mode 100644 index 00000000000000..38aa1944fac3b4 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Miasto jest ju\u017c skonfigurowane.", + "unknown": "Nieznany b\u0142\u0105d: spr\u00f3buj ponownie p\u00f3\u017aniej" + }, + "step": { + "user": { + "data": { + "city": "Miasto" + }, + "description": "Wprowad\u017a kod pocztowy (tylko dla Francji, zalecane) lub nazw\u0119 miasta", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/ru.json b/homeassistant/components/meteo_france/.translations/ru.json new file mode 100644 index 00000000000000..6aaff5f723f59a --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c\u0438 \u0436\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043c\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435." + }, + "step": { + "user": { + "data": { + "city": "\u0413\u043e\u0440\u043e\u0434" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 (\u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0424\u0440\u0430\u043d\u0446\u0438\u0438) \u0438\u043b\u0438 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0433\u043e\u0440\u043e\u0434\u0430", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/sl.json b/homeassistant/components/meteo_france/.translations/sl.json new file mode 100644 index 00000000000000..845a89c477559c --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Mesto je \u017ee konfigurirano", + "unknown": "Neznana napaka: poskusite pozneje" + }, + "step": { + "user": { + "data": { + "city": "Mesto" + }, + "description": "Vnesite po\u0161tno \u0161tevilko (samo za Francijo) ali ime mesta", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/sv.json b/homeassistant/components/meteo_france/.translations/sv.json new file mode 100644 index 00000000000000..a7d021066d57c2 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Staden har redan konfigurerats", + "unknown": "Ok\u00e4nt fel: f\u00f6rs\u00f6k igen senare" + }, + "step": { + "user": { + "data": { + "city": "Stad" + }, + "description": "Ange postnumret (endast f\u00f6r Frankrike, rekommenderat) eller ortsnamn", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/zh-Hant.json b/homeassistant/components/meteo_france/.translations/zh-Hant.json new file mode 100644 index 00000000000000..d3a35a6c713200 --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u57ce\u5e02\u5df2\u8a2d\u5b9a\u5b8c\u6210", + "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66" + }, + "step": { + "user": { + "data": { + "city": "\u57ce\u5e02\u540d\u7a31" + }, + "description": "\u8f38\u5165\u90f5\u905e\u5340\u865f\uff08\u50c5\u652f\u63f4\u6cd5\u570b\uff09\u6216\u57ce\u5e02\u540d\u7a31", + "title": "M\u00e9t\u00e9o-France" + } + }, + "title": "M\u00e9t\u00e9o-France" + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 73b8dbb0e3963d..b7eda51b95560a 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -1,4 +1,5 @@ """Support for Meteo-France weather data.""" +import asyncio import datetime import logging @@ -6,116 +7,96 @@ from vigilancemeteo import VigilanceMeteoError, VigilanceMeteoFranceProxy import voluptuous as vol -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util import Throttle -from .const import CONF_CITY, DATA_METEO_FRANCE, DOMAIN, SENSOR_TYPES +from .const import CONF_CITY, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=5) -def has_all_unique_cities(value): - """Validate that all cities are unique.""" - cities = [location[CONF_CITY] for location in value] - vol.Schema(vol.Unique())(cities) - return value - +CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_CITY): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - } - ) - ], - has_all_unique_cities, - ) - }, - extra=vol.ALLOW_EXTRA, + {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CITY_SCHEMA]))}, extra=vol.ALLOW_EXTRA, ) -def setup(hass, config): - """Set up the Meteo-France component.""" - hass.data[DATA_METEO_FRANCE] = {} +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up Meteo-France from legacy config file.""" - # Check if at least weather alert have to be monitored for one location. - need_weather_alert_watcher = False - for location in config[DOMAIN]: - if ( - CONF_MONITORED_CONDITIONS in location - and "weather_alert" in location[CONF_MONITORED_CONDITIONS] - ): - need_weather_alert_watcher = True + conf = config.get(DOMAIN) + if conf is None: + return True - # If weather alert monitoring is expected initiate a client to be used by - # all weather_alert entities. - if need_weather_alert_watcher: - _LOGGER.debug("Weather Alert monitoring expected. Loading vigilancemeteo") - - weather_alert_client = VigilanceMeteoFranceProxy() - try: - weather_alert_client.update_data() - except VigilanceMeteoError as exp: - _LOGGER.error( - "Unexpected error when creating the vigilance_meteoFrance proxy: %s ", - exp, + for city_conf in conf: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf.copy() ) - else: - weather_alert_client = None - hass.data[DATA_METEO_FRANCE]["weather_alert_client"] = weather_alert_client + ) - for location in config[DOMAIN]: + return True - city = location[CONF_CITY] - try: - client = meteofranceClient(city) - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when creating the meteofrance proxy: %s", exp - ) - return +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up an Meteo-France account from a config entry.""" + hass.data.setdefault(DOMAIN, {}) - client.need_rain_forecast = bool( - CONF_MONITORED_CONDITIONS in location - and "next_rain" in location[CONF_MONITORED_CONDITIONS] + # Weather alert + weather_alert_client = VigilanceMeteoFranceProxy() + try: + await hass.async_add_executor_job(weather_alert_client.update_data) + except VigilanceMeteoError as exp: + _LOGGER.error( + "Unexpected error when creating the vigilance_meteoFrance proxy: %s ", exp + ) + return False + hass.data[DOMAIN]["weather_alert_client"] = weather_alert_client + + # Weather + city = entry.data[CONF_CITY] + try: + client = await hass.async_add_executor_job(meteofranceClient, city) + except meteofranceError as exp: + _LOGGER.error("Unexpected error when creating the meteofrance proxy: %s", exp) + return False + + hass.data[DOMAIN][city] = MeteoFranceUpdater(client) + await hass.async_add_executor_job(hass.data[DOMAIN][city].update) + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) ) + _LOGGER.debug("meteo_france sensor platform loaded for %s", city) + return True - hass.data[DATA_METEO_FRANCE][city] = MeteoFranceUpdater(client) - hass.data[DATA_METEO_FRANCE][city].update() - - if CONF_MONITORED_CONDITIONS in location: - monitored_conditions = location[CONF_MONITORED_CONDITIONS] - _LOGGER.debug("meteo_france sensor platform loaded for %s", city) - load_platform( - hass, - "sensor", - DOMAIN, - {CONF_CITY: city, CONF_MONITORED_CONDITIONS: monitored_conditions}, - config, - ) - load_platform(hass, "weather", DOMAIN, {CONF_CITY: city}, config) +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.data[CONF_CITY]) - return True + return unload_ok class MeteoFranceUpdater: """Update data from Meteo-France.""" - def __init__(self, client): + def __init__(self, client: meteofranceClient): """Initialize the data object.""" self._client = client diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py new file mode 100644 index 00000000000000..c7673020360095 --- /dev/null +++ b/homeassistant/components/meteo_france/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow to configure the Meteo-France integration.""" +import logging + +from meteofrance.client import meteofranceClient, meteofranceError +import voluptuous as vol + +from homeassistant import config_entries + +from .const import CONF_CITY +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Meteo-France config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_CITY, default=user_input.get(CONF_CITY, "")): str} + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self._show_setup_form(user_input, errors) + + city = user_input[CONF_CITY] # Might be a city name or a postal code + city_name = None + + try: + client = await self.hass.async_add_executor_job(meteofranceClient, city) + city_name = client.get_data()["name"] + except meteofranceError as exp: + _LOGGER.error( + "Unexpected error when creating the meteofrance proxy: %s", exp + ) + return self.async_abort(reason="unknown") + + # Check if already configured + await self.async_set_unique_id(city_name) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=city_name, data={CONF_CITY: city}) + + async def async_step_import(self, user_input): + """Import a config entry.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 223aca20bac240..9fde6f38b51098 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,9 +1,9 @@ """Meteo-France component constants.""" -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_MINUTES DOMAIN = "meteo_france" -DATA_METEO_FRANCE = "data_meteo_france" +PLATFORMS = ["sensor", "weather"] ATTRIBUTION = "Data provided by Météo-France" CONF_CITY = "city" @@ -47,13 +47,13 @@ }, "wind_speed": { SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: "km/h", + SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, SENSOR_TYPE_ICON: "mdi:weather-windy", SENSOR_TYPE_CLASS: None, }, "next_rain": { SENSOR_TYPE_NAME: "Next rain", - SENSOR_TYPE_UNIT: "min", + SENSOR_TYPE_UNIT: TIME_MINUTES, SENSOR_TYPE_ICON: "mdi:weather-rainy", SENSOR_TYPE_CLASS: None, }, diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 41a003ea4f758d..77f8fca984d6fc 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -1,8 +1,9 @@ { "domain": "meteo_france", "name": "Météo-France", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", "requirements": ["meteofrance==0.3.7", "vigilancemeteo==3.0.0"], "dependencies": [], - "codeowners": ["@victorcerutti", "@oncleben31"] + "codeowners": ["@victorcerutti", "@oncleben31", "@Quentame"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index f0c08ac18220a3..cf28b9ea558d20 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,15 +1,18 @@ """Support for Meteo-France raining forecast sensor.""" import logging -from vigilancemeteo import DepartmentWeatherAlert +from meteofrance.client import meteofranceClient +from vigilancemeteo import DepartmentWeatherAlert, VigilanceMeteoFranceProxy -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType from .const import ( ATTRIBUTION, CONF_CITY, - DATA_METEO_FRANCE, + DOMAIN, SENSOR_TYPE_CLASS, SENSOR_TYPE_ICON, SENSOR_TYPE_NAME, @@ -23,52 +26,47 @@ STATE_ATTR_BULLETIN_TIME = "Bulletin date" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Meteo-France sensor.""" - if discovery_info is None: - return - - city = discovery_info[CONF_CITY] - monitored_conditions = discovery_info[CONF_MONITORED_CONDITIONS] - client = hass.data[DATA_METEO_FRANCE][city] - weather_alert_client = hass.data[DATA_METEO_FRANCE]["weather_alert_client"] +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Meteo-France sensor platform.""" + city = entry.data[CONF_CITY] + client = hass.data[DOMAIN][city] + weather_alert_client = hass.data[DOMAIN]["weather_alert_client"] alert_watcher = None - if "weather_alert" in monitored_conditions: - datas = hass.data[DATA_METEO_FRANCE][city].get_data() - # Check if a department code is available for this city. - if "dept" in datas: - try: - # If yes create the watcher DepartmentWeatherAlert object. - alert_watcher = DepartmentWeatherAlert( - datas["dept"], weather_alert_client - ) - except ValueError as exp: - _LOGGER.error( - "Unexpected error when creating the weather alert sensor for %s in department %s: %s", - city, - datas["dept"], - exp, - ) - alert_watcher = None - else: - _LOGGER.info( - "Weather alert watcher added for %s in department %s", - city, - datas["dept"], - ) - else: - _LOGGER.warning( - "No 'dept' key found for '%s'. So weather alert information won't be available", + datas = client.get_data() + # Check if a department code is available for this city. + if "dept" in datas: + try: + # If yes create the watcher DepartmentWeatherAlert object. + alert_watcher = await hass.async_add_executor_job( + DepartmentWeatherAlert, datas["dept"], weather_alert_client + ) + _LOGGER.info( + "Weather alert watcher added for %s in department %s", city, + datas["dept"], ) - # Exit and don't create the sensor if no department code available. - return + except ValueError as exp: + _LOGGER.error( + "Unexpected error when creating the weather alert sensor for %s in department %s: %s", + city, + datas["dept"], + exp, + ) + else: + _LOGGER.warning( + "No 'dept' key found for '%s'. So weather alert information won't be available", + city, + ) + # Exit and don't create the sensor if no department code available. + return - add_entities( + async_add_entities( [ - MeteoFranceSensor(variable, client, alert_watcher) - for variable in monitored_conditions + MeteoFranceSensor(sensor_type, client, alert_watcher) + for sensor_type in SENSOR_TYPES ], True, ) @@ -77,9 +75,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class MeteoFranceSensor(Entity): """Representation of a Meteo-France sensor.""" - def __init__(self, condition, client, alert_watcher): + def __init__( + self, + sensor_type: str, + client: meteofranceClient, + alert_watcher: VigilanceMeteoFranceProxy, + ): """Initialize the Meteo-France sensor.""" - self._condition = condition + self._type = sensor_type self._client = client self._alert_watcher = alert_watcher self._state = None @@ -88,7 +91,12 @@ def __init__(self, condition, client, alert_watcher): @property def name(self): """Return the name of the sensor.""" - return f"{self._data['name']} {SENSOR_TYPES[self._condition][SENSOR_TYPE_NAME]}" + return f"{self._data['name']} {SENSOR_TYPES[self._type][SENSOR_TYPE_NAME]}" + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return self.name @property def state(self): @@ -99,7 +107,7 @@ def state(self): def device_state_attributes(self): """Return the state attributes of the sensor.""" # Attributes for next_rain sensor. - if self._condition == "next_rain" and "rain_forecast" in self._data: + if self._type == "next_rain" and "rain_forecast" in self._data: return { **{STATE_ATTR_FORECAST: self._data["rain_forecast"]}, **self._data["next_rain_intervals"], @@ -107,7 +115,7 @@ def device_state_attributes(self): } # Attributes for weather_alert sensor. - if self._condition == "weather_alert" and self._alert_watcher is not None: + if self._type == "weather_alert" and self._alert_watcher is not None: return { **{STATE_ATTR_BULLETIN_TIME: self._alert_watcher.bulletin_date}, **self._alert_watcher.alerts_list, @@ -120,17 +128,17 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return SENSOR_TYPES[self._condition][SENSOR_TYPE_UNIT] + return SENSOR_TYPES[self._type][SENSOR_TYPE_UNIT] @property def icon(self): """Return the icon.""" - return SENSOR_TYPES[self._condition][SENSOR_TYPE_ICON] + return SENSOR_TYPES[self._type][SENSOR_TYPE_ICON] @property def device_class(self): """Return the device class of the sensor.""" - return SENSOR_TYPES[self._condition][SENSOR_TYPE_CLASS] + return SENSOR_TYPES[self._type][SENSOR_TYPE_CLASS] def update(self): """Fetch new state data for the sensor.""" @@ -138,13 +146,12 @@ def update(self): self._client.update() self._data = self._client.get_data() - if self._condition == "weather_alert": + if self._type == "weather_alert": if self._alert_watcher is not None: self._alert_watcher.update_department_status() self._state = self._alert_watcher.department_color _LOGGER.debug( - "weather alert watcher for %s updated. Proxy" - " have the status: %s", + "weather alert watcher for %s updated. Proxy have the status: %s", self._data["name"], self._alert_watcher.proxy.status, ) @@ -153,9 +160,9 @@ def update(self): "No weather alert data for location %s", self._data["name"] ) else: - self._state = self._data[self._condition] + self._state = self._data[self._type] except KeyError: _LOGGER.error( - "No condition %s for location %s", self._condition, self._data["name"] + "No condition %s for location %s", self._type, self._data["name"] ) self._state = None diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json new file mode 100644 index 00000000000000..8bb02f28bd06c4 --- /dev/null +++ b/homeassistant/components/meteo_france/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "Météo-France", + "step": { + "user": { + "title": "Météo-France", + "description": "Enter the postal code (only for France, recommended) or city name", + "data": { + "city": "City" + } + } + }, + "abort":{ + "already_configured": "City already configured", + "unknown": "Unknown error: please retry later" + } + } +} diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index c96080808e97e8..1bdea073aae0f0 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +from meteofrance.client import meteofranceClient + from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, @@ -9,29 +11,30 @@ ATTR_FORECAST_TIME, WeatherEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.dt as dt_util -from .const import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DATA_METEO_FRANCE +from .const import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up the Meteo-France weather platform.""" - if discovery_info is None: - return - - city = discovery_info[CONF_CITY] - client = hass.data[DATA_METEO_FRANCE][city] + city = entry.data[CONF_CITY] + client = hass.data[DOMAIN][city] - add_entities([MeteoFranceWeather(client)], True) + async_add_entities([MeteoFranceWeather(client)], True) class MeteoFranceWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, client): + def __init__(self, client: meteofranceClient): """Initialise the platform with a data instance and station name.""" self._client = client self._data = {} @@ -46,6 +49,11 @@ def name(self): """Return the name of the sensor.""" return self._data["name"] + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return self.name + @property def condition(self): """Return the current condition.""" diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 98d94ebe6caf76..6e6fde06d8caa8 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -13,6 +13,7 @@ CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv @@ -64,9 +65,9 @@ "weather": ["Weather", None], "temperature": ["Temperature", TEMP_CELSIUS], "feels_like_temperature": ["Feels Like Temperature", TEMP_CELSIUS], - "wind_speed": ["Wind Speed", "mph"], + "wind_speed": ["Wind Speed", SPEED_MILES_PER_HOUR], "wind_direction": ["Wind Direction", None], - "wind_gust": ["Wind Gust", "mph"], + "wind_gust": ["Wind Gust", SPEED_MILES_PER_HOUR], "visibility": ["Visibility", None], "visibility_distance": ["Visibility Distance", "km"], "uv": ["UV", None], diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py index 18809f08d4f0cc..b3d3e0ea285fdb 100644 --- a/homeassistant/components/mfi/switch.py +++ b/homeassistant/components/mfi/switch.py @@ -113,7 +113,7 @@ def current_power_w(self): @property def device_state_attributes(self): - """Return the state attributes fof the device.""" + """Return the state attributes for the device.""" attr = {} attr["volts"] = round(self._port.data.get("v_rms", 0), 1) attr["amps"] = round(self._port.data.get("i_rms", 0), 1) diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index aedd5ea9b09221..961d8646979c2e 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_TEMPERATURE, + CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT, @@ -28,7 +29,10 @@ SENSOR_TEMPERATURE = "temperature" SENSOR_CO2 = "co2" -SENSOR_TYPES = {SENSOR_TEMPERATURE: ["Temperature", None], SENSOR_CO2: ["CO2", "ppm"]} +SENSOR_TYPES = { + SENSOR_TEMPERATURE: ["Temperature", None], + SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION], +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, diff --git a/homeassistant/components/mikrotik/.translations/ca.json b/homeassistant/components/mikrotik/.translations/ca.json new file mode 100644 index 00000000000000..75a116a3f9f87f --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/ca.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "La connexi\u00f3 no ha tingut \u00e8xit", + "name_exists": "El nom existeix", + "wrong_credentials": "Credencials incorrectes" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari", + "verify_ssl": "Utilitza SSL" + }, + "title": "Configuraci\u00f3 de Mikrotik Router" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Activa el ping ARP", + "detection_time": "Interval per considerar a casa", + "force_dhcp": "For\u00e7a l'escaneig mitjan\u00e7ant DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/da.json b/homeassistant/components/mikrotik/.translations/da.json new file mode 100644 index 00000000000000..35e3cd5a08a756 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/da.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik er allerede konfigureret" + }, + "error": { + "cannot_connect": "Forbindelsen mislykkedes", + "name_exists": "Navnet findes allerede", + "wrong_credentials": "Forkerte legitimationsoplysninger" + }, + "step": { + "user": { + "data": { + "host": "V\u00e6rt", + "name": "Navn", + "password": "Adgangskode", + "port": "Port", + "username": "Brugernavn", + "verify_ssl": "Brug ssl" + }, + "title": "Konfigurer Mikrotik-router" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Aktiver ARP-ping", + "detection_time": "'Betragt som hjemme'-interval", + "force_dhcp": "Gennemtving scanning ved hj\u00e6lp af DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/de.json b/homeassistant/components/mikrotik/.translations/de.json new file mode 100644 index 00000000000000..97d28db4cfbc5b --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "name_exists": "Name vorhanden", + "wrong_credentials": "Falsche Zugangsdaten" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Passwort", + "port": "Port", + "username": "Benutzername", + "verify_ssl": "Verwenden Sie SSL" + }, + "title": "Richten Sie den Mikrotik Router ein" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP Ping aktivieren", + "force_dhcp": "Erzwingen Sie das Scannen \u00fcber DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json new file mode 100644 index 00000000000000..0423401bf83cd8 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik is already configured" + }, + "error": { + "cannot_connect": "Connection Unsuccessful", + "name_exists": "Name exists", + "wrong_credentials": "Wrong Credentials" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Password", + "port": "Port", + "username": "Username", + "verify_ssl": "Use ssl" + }, + "title": "Set up Mikrotik Router" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "detection_time": "Consider home interval", + "force_dhcp": "Force scanning using DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/es.json b/homeassistant/components/mikrotik/.translations/es.json new file mode 100644 index 00000000000000..61bce851f42b99 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/es.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Conexi\u00f3n fallida", + "name_exists": "El nombre ya existe", + "wrong_credentials": "Credenciales incorrectas" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Nombre de usuario", + "verify_ssl": "Usar ssl" + }, + "title": "Configurar el router Mikrotik" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Habilitar ping ARP", + "detection_time": "Considere el intervalo de inicio", + "force_dhcp": "Forzar el escaneo usando DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/fr.json b/homeassistant/components/mikrotik/.translations/fr.json new file mode 100644 index 00000000000000..220da6fcbafab8 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de la connexion", + "name_exists": "Le nom existe", + "wrong_credentials": "Identifiants erron\u00e9s" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur", + "verify_ssl": "Utiliser SSL" + }, + "title": "Configurer le routeur Mikrotik" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Activer le ping ARP", + "force_dhcp": "Forcer l'analyse \u00e0 l'aide de DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/hu.json b/homeassistant/components/mikrotik/.translations/hu.json new file mode 100644 index 00000000000000..8afbeb699256ac --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/hu.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "A Mikrotik m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "A kapcsolat sikertelen", + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik", + "wrong_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "verify_ssl": "SSL haszn\u00e1lata" + }, + "title": "Mikrotik \u00fatv\u00e1laszt\u00f3 be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP-ping enged\u00e9lyez\u00e9se", + "detection_time": "Otthoni intervallumk\u00e9nt vegye figyelembe", + "force_dhcp": "A szkennel\u00e9s k\u00e9nyszer\u00edt\u00e9se DHCP seg\u00edts\u00e9g\u00e9vel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/it.json b/homeassistant/components/mikrotik/.translations/it.json new file mode 100644 index 00000000000000..9bc10220a9b07f --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/it.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Connessione Non Riuscita", + "name_exists": "Il Nome esiste gi\u00e0", + "wrong_credentials": "Credenziali Errate" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome", + "password": "Password", + "port": "Porta", + "username": "Nome utente", + "verify_ssl": "Usa SSL" + }, + "title": "Configurare il router Mikrotik" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Attivare il ping ARP", + "detection_time": "Considerare l'intervallo di casa", + "force_dhcp": "Scansione forzata con DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/ko.json b/homeassistant/components/mikrotik/.translations/ko.json new file mode 100644 index 00000000000000..c91fd798d64ff3 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/ko.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4", + "wrong_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "SSL \uc0ac\uc6a9" + }, + "title": "Mikrotik \ub77c\uc6b0\ud130 \uc124\uc815" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP \ud551 \ud65c\uc131\ud654", + "detection_time": "\uc2a4\uce94 \uac04\uaca9", + "force_dhcp": "DHCP \ub97c \uc0ac\uc6a9\ud558\uc5ec \uac15\uc81c \uc2a4\uce94" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/lb.json b/homeassistant/components/mikrotik/.translations/lb.json new file mode 100644 index 00000000000000..2f11bad696b6eb --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/lb.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Verbindung net erfollegr\u00e4ich", + "name_exists": "Numm g\u00ebtt et schonn", + "wrong_credentials": "Falsh Login Informatiounen" + }, + "step": { + "user": { + "data": { + "host": "Apparat", + "name": "Numm", + "password": "Passwuert", + "port": "Port", + "username": "Benotzernumm", + "verify_ssl": "SSL benotzen" + }, + "title": "Mikrotik Router ariichten" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP ping aktiv\u00e9ieren", + "detection_time": "Home Intervall betruechten", + "force_dhcp": "Scannen erzw\u00e9ngen mat DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/nl.json b/homeassistant/components/mikrotik/.translations/nl.json new file mode 100644 index 00000000000000..d4996d492a5fbb --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/nl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik is al geconfigureerd" + }, + "error": { + "cannot_connect": "Verbinding niet geslaagd", + "name_exists": "Naam bestaat al", + "wrong_credentials": "Ongeldige inloggegevens" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam", + "verify_ssl": "Gebruik SSL" + }, + "title": "Mikrotik Router instellen" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "ARP-ping inschakelen", + "detection_time": "Overweeg thuisinterval", + "force_dhcp": "Forceer scannen met DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/no.json b/homeassistant/components/mikrotik/.translations/no.json new file mode 100644 index 00000000000000..f842dd148ecd59 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/no.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislykket", + "name_exists": "Navnet eksisterer", + "wrong_credentials": "Feil legitimasjon" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn", + "password": "Passord", + "port": "Port", + "username": "Brukernavn", + "verify_ssl": "Bruk ssl" + }, + "title": "Konfigurere Mikrotik-ruter" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Aktiver ARP-ping", + "detection_time": "Vurder hjemmeintervall", + "force_dhcp": "Tving skanning ved hjelp av DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/pl.json b/homeassistant/components/mikrotik/.translations/pl.json new file mode 100644 index 00000000000000..6d807672398a27 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/pl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikronik jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119", + "name_exists": "Nazwa ju\u017c istnieje.", + "wrong_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nazwa", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika", + "verify_ssl": "U\u017cyj SSL" + }, + "title": "Konfiguracja routera Mikrotik" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "W\u0142\u0105cz ping ARP", + "detection_time": "Czas przed oznaczeniem \"poza domem\"", + "force_dhcp": "Wymu\u015b skanowanie przy u\u017cyciu DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/ru.json b/homeassistant/components/mikrotik/.translations/ru.json new file mode 100644 index 00000000000000..844181b5b64c29 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/ru.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f.", + "wrong_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041b\u043e\u0433\u0438\u043d", + "verify_ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL" + }, + "title": "MikroTik" + } + }, + "title": "MikroTik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c ARP-\u043f\u0438\u043d\u0433", + "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "force_dhcp": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/sl.json b/homeassistant/components/mikrotik/.translations/sl.json new file mode 100644 index 00000000000000..a10508f8bbef16 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/sl.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik je \u017ee konfiguriran" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "name_exists": "Ime obstaja", + "wrong_credentials": "Napa\u010dne poverilnice" + }, + "step": { + "user": { + "data": { + "host": "Gostitelj", + "name": "Ime", + "password": "Geslo", + "port": "Vrata", + "username": "Uporabni\u0161ko ime", + "verify_ssl": "Uporaba SSL" + }, + "title": "Nastavite Mikrotik usmerjevalnik" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Omogo\u010di ARP ping", + "detection_time": "Interval \"doma\" ", + "force_dhcp": "Vsilite skeniranje z uporabo DHCP-ja" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/sv.json b/homeassistant/components/mikrotik/.translations/sv.json new file mode 100644 index 00000000000000..7be080d96a2b62 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/sv.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Anslutningen misslyckades", + "name_exists": "Namnet finns", + "wrong_credentials": "Fel autentiseringsuppgifter" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "name": "Namn", + "password": "L\u00f6senord", + "port": "Port", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "Anv\u00e4nd ssl" + }, + "title": "Konfigurera Mikrotik-router" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Aktivera ARP-ping", + "detection_time": "Intervall f\u00f6r att betraktas som hemma", + "force_dhcp": "Tvinga skanning med DHCP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/zh-Hans.json b/homeassistant/components/mikrotik/.translations/zh-Hans.json new file mode 100644 index 00000000000000..9604af534955a1 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a", + "name": "\u540d\u5b57", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u4f7f\u7528 ssl" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/zh-Hant.json b/homeassistant/components/mikrotik/.translations/zh-Hant.json new file mode 100644 index 00000000000000..6913f2c91f1701 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/zh-Hant.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Mikrotik \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u672a\u6210\u529f", + "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728", + "wrong_credentials": "\u6191\u8b49\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "verify_ssl": "\u4f7f\u7528 SSL" + }, + "title": "\u8a2d\u5b9a Mikrotik \u8def\u7531\u5668" + } + }, + "title": "Mikrotik" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "\u958b\u555f ARP ping", + "detection_time": "\u5224\u5b9a\u5728\u5bb6\u9593\u9694", + "force_dhcp": "\u5f37\u5236\u4f7f\u7528 DHCP \u6383\u63cf" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 8c21b2e1c35725..9a8ee7bdb45c26 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,43 +1,28 @@ -"""The mikrotik component.""" -import logging -import ssl - -from librouteros import connect -from librouteros.exceptions import LibRouterosError -from librouteros.login import plain as login_plain, token as login_token +"""The Mikrotik component.""" import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, - CONF_METHOD, + CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import load_platform from .const import ( + ATTR_MANUFACTURER, CONF_ARP_PING, - CONF_ENCODING, - CONF_LOGIN_METHOD, - CONF_TRACK_DEVICES, - DEFAULT_ENCODING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_API_PORT, + DEFAULT_DETECTION_TIME, + DEFAULT_NAME, DOMAIN, - HOSTS, - IDENTITY, - MIKROTIK_SERVICES, - MTK_LOGIN_PLAIN, - MTK_LOGIN_TOKEN, - NAME, ) - -_LOGGER = logging.getLogger(__name__) - -MTK_DEFAULT_API_PORT = "8728" -MTK_DEFAULT_API_SSL_PORT = "8729" +from .hub import MikrotikHub MIKROTIK_SCHEMA = vol.All( vol.Schema( @@ -45,13 +30,14 @@ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_METHOD): cv.string, - vol.Optional(CONF_LOGIN_METHOD): vol.Any(MTK_LOGIN_PLAIN, MTK_LOGIN_TOKEN), - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, - vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, + vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, vol.Optional(CONF_ARP_PING, default=False): cv.boolean, + vol.Optional(CONF_FORCE_DHCP, default=False): cv.boolean, + vol.Optional( + CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME + ): cv.time_period, } ) ) @@ -61,124 +47,45 @@ ) -def setup(hass, config): - """Set up the Mikrotik component.""" - hass.data[DOMAIN] = {HOSTS: {}} - - for device in config[DOMAIN]: - host = device[CONF_HOST] - use_ssl = device.get(CONF_SSL) - user = device.get(CONF_USERNAME) - password = device.get(CONF_PASSWORD, "") - login = device.get(CONF_LOGIN_METHOD) - encoding = device.get(CONF_ENCODING) - track_devices = device.get(CONF_TRACK_DEVICES) - - if CONF_PORT in device: - port = device.get(CONF_PORT) - else: - if use_ssl: - port = MTK_DEFAULT_API_SSL_PORT - else: - port = MTK_DEFAULT_API_PORT - - if login == MTK_LOGIN_PLAIN: - login_method = login_plain - else: - login_method = login_token - - try: - api = MikrotikClient( - host, use_ssl, port, user, password, login_method, encoding +async def async_setup(hass, config): + """Import the Mikrotik component from config.""" + + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) ) - api.connect_to_device() - hass.data[DOMAIN][HOSTS][host] = {"config": device, "api": api} - except LibRouterosError as api_error: - _LOGGER.error("Mikrotik %s error %s", host, api_error) - continue - if track_devices: - hass.data[DOMAIN][HOSTS][host][DEVICE_TRACKER] = True - load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Mikrotik component.""" - if not hass.data[DOMAIN][HOSTS]: + hub = MikrotikHub(hass, config_entry) + if not await hub.async_setup(): return False + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub + device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(DOMAIN, hub.serial_num)}, + manufacturer=ATTR_MANUFACTURER, + model=hub.model, + name=hub.hostname, + sw_version=hub.firmware, + ) + return True -class MikrotikClient: - """Handle all communication with the Mikrotik API.""" - - def __init__(self, host, use_ssl, port, user, password, login_method, encoding): - """Initialize the Mikrotik Client.""" - self._host = host - self._use_ssl = use_ssl - self._port = port - self._user = user - self._password = password - self._login_method = login_method - self._encoding = encoding - self._ssl_wrapper = None - self.hostname = None - self._client = None - self._connected = False - - def connect_to_device(self): - """Connect to Mikrotik device.""" - self._connected = False - _LOGGER.debug("[%s] Connecting to Mikrotik device", self._host) - - kwargs = { - "encoding": self._encoding, - "login_methods": self._login_method, - "port": self._port, - } +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - if self._use_ssl: - if self._ssl_wrapper is None: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - self._ssl_wrapper = ssl_context.wrap_socket - kwargs["ssl_wrapper"] = self._ssl_wrapper - - try: - self._client = connect(self._host, self._user, self._password, **kwargs) - self._connected = True - except LibRouterosError as api_error: - _LOGGER.error("Mikrotik %s: %s", self._host, api_error) - self._client = None - return False - - self.hostname = self.get_hostname() - _LOGGER.info("Mikrotik Connected to %s (%s)", self.hostname, self._host) - return self._connected - - def get_hostname(self): - """Return device host name.""" - data = list(self.command(MIKROTIK_SERVICES[IDENTITY])) - return data[0][NAME] if data else None - - def connected(self): - """Return connected boolean.""" - return self._connected - - def command(self, cmd, params=None): - """Retrieve data from Mikrotik API.""" - if not self._connected or not self._client: - if not self.connect_to_device(): - return None - try: - if params: - response = self._client(cmd=cmd, **params) - else: - response = self._client(cmd=cmd) - except LibRouterosError as api_error: - _LOGGER.error( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) - return None - return response if response else None + hass.data[DOMAIN].pop(config_entry.entry_id) + + return True diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py new file mode 100644 index 00000000000000..c1a41abf0d07b8 --- /dev/null +++ b/homeassistant/components/mikrotik/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for Mikrotik.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback + +from .const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_API_PORT, + DEFAULT_DETECTION_TIME, + DEFAULT_NAME, + DOMAIN, +) +from .errors import CannotConnect, LoginError +from .hub import get_api + + +class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Mikrotik config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MikrotikOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == user_input[CONF_HOST]: + return self.async_abort(reason="already_configured") + if entry.data[CONF_NAME] == user_input[CONF_NAME]: + errors[CONF_NAME] = "name_exists" + break + + try: + await self.hass.async_add_executor_job(get_api, self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "wrong_credentials" + errors[CONF_PASSWORD] = "wrong_credentials" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Import Miktortik from config.""" + + import_config[CONF_DETECTION_TIME] = import_config[CONF_DETECTION_TIME].seconds + return await self.async_step_user(user_input=import_config) + + +class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Mikrotik options.""" + + def __init__(self, config_entry): + """Initialize Mikrotik options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Mikrotik options.""" + return await self.async_step_device_tracker() + + async def async_step_device_tracker(self, user_input=None): + """Manage the device tracker options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_FORCE_DHCP, + default=self.config_entry.options.get(CONF_FORCE_DHCP, False), + ): bool, + vol.Optional( + CONF_ARP_PING, + default=self.config_entry.options.get(CONF_ARP_PING, False), + ): bool, + vol.Optional( + CONF_DETECTION_TIME, + default=self.config_entry.options.get( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + ): int, + } + + return self.async_show_form( + step_id="device_tracker", data_schema=vol.Schema(options) + ) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index bd26b02fe1b924..d81e8878d1cf47 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -1,32 +1,40 @@ """Constants used in the Mikrotik components.""" DOMAIN = "mikrotik" -MIKROTIK = DOMAIN -HOSTS = "hosts" -MTK_LOGIN_PLAIN = "plain" -MTK_LOGIN_TOKEN = "token" +DEFAULT_NAME = "Mikrotik" +DEFAULT_API_PORT = 8728 +DEFAULT_DETECTION_TIME = 300 + +ATTR_MANUFACTURER = "Mikrotik" +ATTR_SERIAL_NUMBER = "serial-number" +ATTR_FIRMWARE = "current-firmware" +ATTR_MODEL = "model" CONF_ARP_PING = "arp_ping" -CONF_TRACK_DEVICES = "track_devices" -CONF_LOGIN_METHOD = "login_method" -CONF_ENCODING = "encoding" -DEFAULT_ENCODING = "utf-8" +CONF_FORCE_DHCP = "force_dhcp" +CONF_DETECTION_TIME = "detection_time" + NAME = "name" INFO = "info" IDENTITY = "identity" ARP = "arp" + +CAPSMAN = "capsman" DHCP = "dhcp" WIRELESS = "wireless" -CAPSMAN = "capsman" +IS_WIRELESS = "is_wireless" +IS_CAPSMAN = "is_capsman" MIKROTIK_SERVICES = { - INFO: "/system/routerboard/getall", - IDENTITY: "/system/identity/getall", ARP: "/ip/arp/getall", + CAPSMAN: "/caps-man/registration-table/getall", DHCP: "/ip/dhcp-server/lease/getall", + IDENTITY: "/system/identity/getall", + INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", - CAPSMAN: "/caps-man/registration-table/getall", + IS_WIRELESS: "/interface/wireless/print", + IS_CAPSMAN: "/caps-man/interface/print", } ATTR_DEVICE_TRACKER = [ @@ -34,16 +42,8 @@ "mac-address", "ssid", "interface", - "host-name", - "last-seen", - "rx-signal", "signal-strength", - "tx-ccq", "signal-to-noise", - "wmm-enabled", - "authentication-type", - "encryption", - "tx-rate-set", "rx-rate", "tx-rate", "uptime", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 92fcfac4ae4d90..e7c5e5655a0ba5 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,191 +1,142 @@ """Support for Mikrotik routers as device tracker.""" import logging -from homeassistant.components.device_tracker import ( +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import ( DOMAIN as DEVICE_TRACKER, - DeviceScanner, -) -from homeassistant.const import CONF_METHOD -from homeassistant.util import slugify - -from .const import ( - ARP, - ATTR_DEVICE_TRACKER, - CAPSMAN, - CONF_ARP_PING, - DHCP, - HOSTS, - MIKROTIK, - MIKROTIK_SERVICES, - WIRELESS, + SOURCE_TYPE_ROUTER, ) +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.dt as dt_util + +from .const import ATTR_MANUFACTURER, DOMAIN _LOGGER = logging.getLogger(__name__) -def get_scanner(hass, config): - """Validate the configuration and return MikrotikScanner.""" - for host in hass.data[MIKROTIK][HOSTS]: - if DEVICE_TRACKER not in hass.data[MIKROTIK][HOSTS][host]: - continue - hass.data[MIKROTIK][HOSTS][host].pop(DEVICE_TRACKER, None) - api = hass.data[MIKROTIK][HOSTS][host]["api"] - config = hass.data[MIKROTIK][HOSTS][host]["config"] - hostname = api.get_hostname() - scanner = MikrotikScanner(api, host, hostname, config) - return scanner if scanner.success_init else None - - -class MikrotikScanner(DeviceScanner): - """This class queries a Mikrotik device.""" - - def __init__(self, api, host, hostname, config): - """Initialize the scanner.""" - self.api = api - self.config = config - self.host = host - self.hostname = hostname - self.method = config.get(CONF_METHOD) - self.arp_ping = config.get(CONF_ARP_PING) - self.dhcp = None - self.devices_arp = {} - self.devices_dhcp = {} - self.device_tracker = None - self.success_init = self.api.connected() - - def get_extra_attributes(self, device): - """ - Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - return self.device_tracker.get(device) or {} - - def get_device_name(self, device): - """Get name for a device.""" - host = self.device_tracker.get(device, {}) - return host.get("host_name") - - def scan_devices(self): - """Scan for new devices and return a list with found device MACs.""" - self.update_device_tracker() - return list(self.device_tracker) - - def get_method(self): - """Determine the device tracker polling method.""" - if self.method: - _LOGGER.debug( - "Mikrotik %s: Manually selected polling method %s", - self.host, - self.method, - ) - return self.method - - capsman = self.api.command(MIKROTIK_SERVICES[CAPSMAN]) - if not capsman: - _LOGGER.debug( - "Mikrotik %s: Not a CAPsMAN controller. " - "Trying local wireless interfaces", - (self.host), - ) - else: - return CAPSMAN - - wireless = self.api.command(MIKROTIK_SERVICES[WIRELESS]) - if not wireless: - _LOGGER.info( - "Mikrotik %s: Wireless adapters not found. Try to " - "use DHCP lease table as presence tracker source. " - "Please decrease lease time as much as possible", - self.host, - ) - return DHCP - - return WIRELESS - - def update_device_tracker(self): - """Update device_tracker from Mikrotik API.""" - self.device_tracker = {} - if not self.method: - self.method = self.get_method() - - data = self.api.command(MIKROTIK_SERVICES[self.method]) - if data is None: - return - - if self.method != DHCP: - dhcp = self.api.command(MIKROTIK_SERVICES[DHCP]) - if dhcp is not None: - self.devices_dhcp = load_mac(dhcp) - - arp = self.api.command(MIKROTIK_SERVICES[ARP]) - self.devices_arp = load_mac(arp) - - for device in data: - mac = device.get("mac-address") - if self.method == DHCP: - if "active-address" not in device: - continue - - if self.arp_ping and self.devices_arp: - if mac not in self.devices_arp: - continue - ip_address = self.devices_arp[mac]["address"] - interface = self.devices_arp[mac]["interface"] - if not self.do_arp_ping(ip_address, interface): - continue - - attrs = {} - if mac in self.devices_dhcp and "host-name" in self.devices_dhcp[mac]: - hostname = self.devices_dhcp[mac].get("host-name") - if hostname: - attrs["host_name"] = hostname - - if self.devices_arp and mac in self.devices_arp: - attrs["ip_address"] = self.devices_arp[mac].get("address") - - for attr in ATTR_DEVICE_TRACKER: - if attr in device and device[attr] is not None: - attrs[slugify(attr)] = device[attr] - attrs["scanner_type"] = self.method - attrs["scanner_host"] = self.host - attrs["scanner_hostname"] = self.hostname - self.device_tracker[mac] = attrs - - def do_arp_ping(self, ip_address, interface): - """Attempt to arp ping MAC address via interface.""" - params = { - "arp-ping": "yes", - "interval": "100ms", - "count": 3, - "interface": interface, - "address": ip_address, - } - cmd = "/ping" - data = self.api.command(cmd, params) - if data is not None: - status = 0 - for result in data: - if "status" in result: - _LOGGER.debug( - "Mikrotik %s arp_ping error: %s", self.host, result["status"] - ) - status += 1 - if status == len(data): - return None - return data - - -def load_mac(devices=None): - """Load dictionary using MAC address as key.""" - if not devices: +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up device tracker for Mikrotik component.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + tracked = {} + + registry = await entity_registry.async_get_registry(hass) + + # Restore clients that is not a part of active clients list. + for entity in registry.entities.values(): + + if ( + entity.config_entry_id == config_entry.entry_id + and entity.domain == DEVICE_TRACKER + ): + + if ( + entity.unique_id in hub.api.devices + or entity.unique_id not in hub.api.all_devices + ): + continue + hub.api.restore_device(entity.unique_id) + + @callback + def update_hub(): + """Update the status of the device.""" + update_items(hub, async_add_entities, tracked) + + async_dispatcher_connect(hass, hub.signal_update, update_hub) + + update_hub() + + +@callback +def update_items(hub, async_add_entities, tracked): + """Update tracked device state from the hub.""" + new_tracked = [] + for mac, device in hub.api.devices.items(): + if mac not in tracked: + tracked[mac] = MikrotikHubTracker(device, hub) + new_tracked.append(tracked[mac]) + + if new_tracked: + async_add_entities(new_tracked) + + +class MikrotikHubTracker(ScannerEntity): + """Representation of network device.""" + + def __init__(self, device, hub): + """Initialize the tracked device.""" + self.device = device + self.hub = hub + self.unsub_dispatcher = None + + @property + def is_connected(self): + """Return true if the client is connected to the network.""" + if ( + self.device.last_seen + and (dt_util.utcnow() - self.device.last_seen) + < self.hub.option_detection_time + ): + return True + return False + + @property + def source_type(self): + """Return the source type of the client.""" + return SOURCE_TYPE_ROUTER + + @property + def name(self) -> str: + """Return the name of the client.""" + return self.device.name + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return self.device.mac + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self.hub.available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.is_connected: + return self.device.attrs return None - mac_devices = {} - for device in devices: - if "mac-address" in device: - mac = device.pop("mac-address") - mac_devices[mac] = device - return mac_devices + + @property + def device_info(self): + """Return a client description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, + "manufacturer": ATTR_MANUFACTURER, + "identifiers": {(DOMAIN, self.device.mac)}, + "name": self.name, + "via_device": (DOMAIN, self.hub.serial_num), + } + return info + + async def async_added_to_hass(self): + """Client entity created.""" + _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, self.hub.signal_update, self.async_write_ha_state + ) + + async def async_update(self): + """Synchronize state with hub.""" + _LOGGER.debug( + "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id + ) + await self.hub.request_update() + + async def will_remove_from_hass(self): + """Disconnect from dispatcher.""" + if self.unsub_dispatcher: + self.unsub_dispatcher() diff --git a/homeassistant/components/mikrotik/errors.py b/homeassistant/components/mikrotik/errors.py new file mode 100644 index 00000000000000..22cd63d74689ae --- /dev/null +++ b/homeassistant/components/mikrotik/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Mikrotik component.""" +from homeassistant.exceptions import HomeAssistantError + + +class CannotConnect(HomeAssistantError): + """Unable to connect to the hub.""" + + +class LoginError(HomeAssistantError): + """Component got logged out.""" diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py new file mode 100644 index 00000000000000..300d73b6b11e80 --- /dev/null +++ b/homeassistant/components/mikrotik/hub.py @@ -0,0 +1,415 @@ +"""The Mikrotik router class.""" +from datetime import timedelta +import logging +import socket +import ssl + +import librouteros +from librouteros.login import plain as login_plain, token as login_token + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +from .const import ( + ARP, + ATTR_DEVICE_TRACKER, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + CAPSMAN, + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_DETECTION_TIME, + DHCP, + IDENTITY, + INFO, + IS_CAPSMAN, + IS_WIRELESS, + MIKROTIK_SERVICES, + NAME, + WIRELESS, +) +from .errors import CannotConnect, LoginError + +_LOGGER = logging.getLogger(__name__) + + +class Device: + """Represents a network device.""" + + def __init__(self, mac, params): + """Initialize the network device.""" + self._mac = mac + self._params = params + self._last_seen = None + self._attrs = {} + self._wireless_params = None + + @property + def name(self): + """Return device name.""" + return self._params.get("host-name", self.mac) + + @property + def mac(self): + """Return device mac.""" + return self._mac + + @property + def last_seen(self): + """Return device last seen.""" + return self._last_seen + + @property + def attrs(self): + """Return device attributes.""" + attr_data = self._wireless_params if self._wireless_params else self._params + for attr in ATTR_DEVICE_TRACKER: + if attr in attr_data: + self._attrs[slugify(attr)] = attr_data[attr] + self._attrs["ip_address"] = self._params.get("active-address") + return self._attrs + + def update(self, wireless_params=None, params=None, active=False): + """Update Device params.""" + if wireless_params: + self._wireless_params = wireless_params + if params: + self._params = params + if active: + self._last_seen = dt_util.utcnow() + + +class MikrotikData: + """Handle all communication with the Mikrotik API.""" + + def __init__(self, hass, config_entry, api): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self.api = api + self._host = self.config_entry.data[CONF_HOST] + self.all_devices = {} + self.devices = {} + self.available = True + self.support_capsman = False + self.support_wireless = False + self.hostname = None + self.model = None + self.firmware = None + self.serial_number = None + + @staticmethod + def load_mac(devices=None): + """Load dictionary using MAC address as key.""" + if not devices: + return None + mac_devices = {} + for device in devices: + if "mac-address" in device: + mac = device["mac-address"] + mac_devices[mac] = device + return mac_devices + + @property + def arp_enabled(self): + """Return arp_ping option setting.""" + return self.config_entry.options[CONF_ARP_PING] + + @property + def force_dhcp(self): + """Return force_dhcp option setting.""" + return self.config_entry.options[CONF_FORCE_DHCP] + + def get_info(self, param): + """Return device model name.""" + cmd = IDENTITY if param == NAME else INFO + data = self.command(MIKROTIK_SERVICES[cmd]) + return data[0].get(param) if data else None + + def get_hub_details(self): + """Get Hub info.""" + self.hostname = self.get_info(NAME) + self.model = self.get_info(ATTR_MODEL) + self.firmware = self.get_info(ATTR_FIRMWARE) + self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) + self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) + self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + + def connect_to_hub(self): + """Connect to hub.""" + try: + self.api = get_api(self.hass, self.config_entry.data) + self.available = True + return True + except (LoginError, CannotConnect): + self.available = False + return False + + def get_list_from_interface(self, interface): + """Get devices from interface.""" + result = self.command(MIKROTIK_SERVICES[interface]) + return self.load_mac(result) if result else {} + + def restore_device(self, mac): + """Restore a missing device after restart.""" + self.devices[mac] = Device(mac, self.all_devices[mac]) + + def update_devices(self): + """Get list of devices with latest status.""" + arp_devices = {} + device_list = {} + wireless_devices = {} + try: + self.all_devices = self.get_list_from_interface(DHCP) + if self.support_capsman: + _LOGGER.debug("Hub is a CAPSman manager") + device_list = wireless_devices = self.get_list_from_interface(CAPSMAN) + elif self.support_wireless: + _LOGGER.debug("Hub supports wireless Interface") + device_list = wireless_devices = self.get_list_from_interface(WIRELESS) + + if not device_list or self.force_dhcp: + device_list = self.all_devices + _LOGGER.debug("Falling back to DHCP for scanning devices") + + if self.arp_enabled: + _LOGGER.debug("Using arp-ping to check devices") + arp_devices = self.get_list_from_interface(ARP) + + # get new hub firmware version if updated + self.firmware = self.get_info(ATTR_FIRMWARE) + + except (CannotConnect, socket.timeout, socket.error): + self.available = False + return + + if not device_list: + return + + for mac, params in device_list.items(): + if mac not in self.devices: + self.devices[mac] = Device(mac, self.all_devices.get(mac, {})) + else: + self.devices[mac].update(params=self.all_devices.get(mac, {})) + + if mac in wireless_devices: + # if wireless is supported then wireless_params are params + self.devices[mac].update( + wireless_params=wireless_devices[mac], active=True + ) + continue + # for wired devices or when forcing dhcp check for active-address + if not params.get("active-address"): + self.devices[mac].update(active=False) + continue + # ping check the rest of active devices if arp ping is enabled + active = True + if self.arp_enabled and mac in arp_devices: + active = self.do_arp_ping( + params.get("active-address"), arp_devices[mac].get("interface") + ) + self.devices[mac].update(active=active) + + def do_arp_ping(self, ip_address, interface): + """Attempt to arp ping MAC address via interface.""" + _LOGGER.debug("pinging - %s", ip_address) + params = { + "arp-ping": "yes", + "interval": "100ms", + "count": 3, + "interface": interface, + "address": ip_address, + } + cmd = "/ping" + data = self.command(cmd, params) + if data is not None: + status = 0 + for result in data: + if "status" in result: + status += 1 + if status == len(data): + _LOGGER.debug( + "Mikrotik %s - %s arp_ping timed out", ip_address, interface + ) + return False + return True + + def command(self, cmd, params=None): + """Retrieve data from Mikrotik API.""" + try: + _LOGGER.info("Running command %s", cmd) + if params: + response = list(self.api(cmd=cmd, **params)) + else: + response = list(self.api(cmd=cmd)) + except ( + librouteros.exceptions.ConnectionClosed, + socket.error, + socket.timeout, + ) as api_error: + _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) + raise CannotConnect + except librouteros.exceptions.ProtocolError as api_error: + _LOGGER.warning( + "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", + self._host, + cmd, + api_error, + ) + return None + + return response if response else None + + def update(self): + """Update device_tracker from Mikrotik API.""" + if not self.available or not self.api: + if not self.connect_to_hub(): + return + _LOGGER.debug("updating network devices for host: %s", self._host) + self.update_devices() + + +class MikrotikHub: + """Mikrotik Hub Object.""" + + def __init__(self, hass, config_entry): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self._mk_data = None + self.progress = None + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data[CONF_HOST] + + @property + def hostname(self): + """Return the hostname of the hub.""" + return self._mk_data.hostname + + @property + def model(self): + """Return the model of the hub.""" + return self._mk_data.model + + @property + def firmware(self): + """Return the firmware of the hub.""" + return self._mk_data.firmware + + @property + def serial_num(self): + """Return the serial number of the hub.""" + return self._mk_data.serial_number + + @property + def available(self): + """Return if the hub is connected.""" + return self._mk_data.available + + @property + def option_detection_time(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) + + @property + def signal_update(self): + """Event specific per Mikrotik entry to signal updates.""" + return f"mikrotik-update-{self.host}" + + @property + def api(self): + """Represent Mikrotik data object.""" + return self._mk_data + + async def async_add_options(self): + """Populate default options for Mikrotik.""" + if not self.config_entry.options: + options = { + CONF_ARP_PING: self.config_entry.data.pop(CONF_ARP_PING, False), + CONF_FORCE_DHCP: self.config_entry.data.pop(CONF_FORCE_DHCP, False), + CONF_DETECTION_TIME: self.config_entry.data.pop( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + } + + self.hass.config_entries.async_update_entry( + self.config_entry, options=options + ) + + async def request_update(self): + """Request an update.""" + if self.progress is not None: + await self.progress + return + + self.progress = self.hass.async_create_task(self.async_update()) + await self.progress + + self.progress = None + + async def async_update(self): + """Update Mikrotik devices information.""" + await self.hass.async_add_executor_job(self._mk_data.update) + async_dispatcher_send(self.hass, self.signal_update) + + async def async_setup(self): + """Set up the Mikrotik hub.""" + try: + api = await self.hass.async_add_executor_job( + get_api, self.hass, self.config_entry.data + ) + except CannotConnect: + raise ConfigEntryNotReady + except LoginError: + return False + + self._mk_data = MikrotikData(self.hass, self.config_entry, api) + await self.async_add_options() + await self.hass.async_add_executor_job(self._mk_data.get_hub_details) + await self.hass.async_add_executor_job(self._mk_data.update) + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "device_tracker" + ) + ) + return True + + +def get_api(hass, entry): + """Connect to Mikrotik hub.""" + _LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST]) + + _login_method = (login_plain, login_token) + kwargs = {"login_methods": _login_method, "port": entry["port"]} + + if entry[CONF_VERIFY_SSL]: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + _ssl_wrapper = ssl_context.wrap_socket + kwargs["ssl_wrapper"] = _ssl_wrapper + + try: + api = librouteros.connect( + entry[CONF_HOST], entry[CONF_USERNAME], entry[CONF_PASSWORD], **kwargs, + ) + _LOGGER.debug("Connected to %s successfully", entry[CONF_HOST]) + return api + except ( + librouteros.exceptions.LibRouterosError, + socket.error, + socket.timeout, + ) as api_error: + _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error) + if "invalid user name or password" in str(api_error): + raise LoginError + raise CannotConnect diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 932df2edd296cf..72f98a11709cf5 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -1,8 +1,13 @@ { "domain": "mikrotik", - "name": "MikroTik", + "name": "Mikrotik", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", - "requirements": ["librouteros==3.0.0"], + "requirements": [ + "librouteros==3.0.0" + ], "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@engrbm87" + ] +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json new file mode 100644 index 00000000000000..590563993d6a5d --- /dev/null +++ b/homeassistant/components/mikrotik/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl" + } + } + }, + "error": { + "name_exists": "Name exists", + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 875d217247c96b..d904538451c149 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -6,6 +6,8 @@ from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, FAN_ON, HVAC_MODE_HEAT, HVAC_MODE_OFF, @@ -167,6 +169,13 @@ def max_temp(self): """Return the maximum temperature.""" return MAX_TEMP + @property + def hvac_action(self): + """Return current hvac i.e. heat, cool, idle.""" + if self._heater.is_gen1 or self._heater.is_heating == 1: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + @property def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode. diff --git a/homeassistant/components/minecraft_server/.translations/ca.json b/homeassistant/components/minecraft_server/.translations/ca.json new file mode 100644 index 00000000000000..86856ac2d11aa4 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3 amb el servidor. Comprova l'amfitri\u00f3 i el port i torna-ho a provar. Assegurat que estas utilitzant la versi\u00f3 del servidor 1.7 o superior.", + "invalid_ip": "L\u2019adre\u00e7a IP \u00e9s inv\u00e0lida (no s\u2019ha pogut determinar l\u2019adre\u00e7a MAC). Corregeix-la i torna-ho a provar.", + "invalid_port": "El port ha d'estar compr\u00e8s entre 1024 i 65535. Corregeix-lo i torna-ho a provar." + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom", + "port": "Port" + }, + "description": "Configuraci\u00f3 d'una inst\u00e0ncia de servidor de Minecraft per poder monitoritzar-lo.", + "title": "Enlla\u00e7 del servidor de Minecraft" + } + }, + "title": "Servidor de Minecraft" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/da.json b/homeassistant/components/minecraft_server/.translations/da.json new file mode 100644 index 00000000000000..bf930f2f2775e7 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/da.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e6rten er allerede konfigureret." + }, + "error": { + "cannot_connect": "Det lykkedes ikke at oprette forbindelse til serveren. Kontroller v\u00e6rten og porten, og pr\u00f8v igen. S\u00f8rg ogs\u00e5 for, at du k\u00f8rer mindst Minecraft version 1.7 p\u00e5 din server.", + "invalid_ip": "IP-adressen er ugyldig (MAC-adressen kunne ikke bestemmes). Ret den, og pr\u00f8v igen.", + "invalid_port": "Porten skal v\u00e6re i intervallet fra 1024 til 65535. Ret den, og pr\u00f8v igen." + }, + "step": { + "user": { + "data": { + "host": "V\u00e6rt", + "name": "Navn", + "port": "Port" + }, + "description": "Konfigurer din Minecraft-server-instans for at tillade overv\u00e5gning.", + "title": "Forbind din Minecraft-server" + } + }, + "title": "Minecraft-server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/de.json b/homeassistant/components/minecraft_server/.translations/de.json new file mode 100644 index 00000000000000..0042630823923b --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Der Host ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung zum Server fehlgeschlagen. Bitte \u00fcberpr\u00fcfe den Host und den Port und versuche es erneut. Stelle au\u00dferdem sicher, dass Du mindestens Minecraft Version 1.7 auf Deinem Server ausf\u00fchrst.", + "invalid_ip": "IP-Adresse ist ung\u00fcltig (MAC-Adresse konnte nicht ermittelt werden). Bitte korrigieren und erneut versuchen.", + "invalid_port": "Der Port muss im Bereich von 1024 bis 65535 liegen. Bitte korrigieren und erneut versuchen." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "port": "Port" + }, + "description": "Richte deine Minecraft Server-Instanz ein, um es \u00fcberwachen zu k\u00f6nnen.", + "title": "Verkn\u00fcpfe deinen Minecraft Server" + } + }, + "title": "Minecraft Server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/en.json b/homeassistant/components/minecraft_server/.translations/en.json new file mode 100644 index 00000000000000..d0f7a5d6300d83 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Host is already configured." + }, + "error": { + "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server.", + "invalid_ip": "IP address is invalid (MAC address could not be determined). Please correct it and try again.", + "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "port": "Port" + }, + "description": "Set up your Minecraft Server instance to allow monitoring.", + "title": "Link your Minecraft Server" + } + }, + "title": "Minecraft Server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/es.json b/homeassistant/components/minecraft_server/.translations/es.json new file mode 100644 index 00000000000000..14831ef45e1426 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El host ya est\u00e1 configurado." + }, + "error": { + "cannot_connect": "No se pudo conectar al servidor. Compruebe el host y el puerto e int\u00e9ntelo de nuevo. Tambi\u00e9n aseg\u00farese de que est\u00e1 ejecutando al menos Minecraft versi\u00f3n 1.7 en su servidor.", + "invalid_ip": "La direcci\u00f3n IP no es valida (no se pudo determinar la direcci\u00f3n MAC). Por favor, corr\u00edgelo e int\u00e9ntalo de nuevo.", + "invalid_port": "El puerto debe estar en el rango de 1024 a 65535. Por favor, corr\u00edgelo e int\u00e9ntalo de nuevo." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nombre", + "port": "Puerto" + }, + "description": "Configura tu instancia de Minecraft Server para permitir la supervisi\u00f3n.", + "title": "Enlace su servidor Minecraft" + } + }, + "title": "Servidor Minecraft" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/fr.json b/homeassistant/components/minecraft_server/.translations/fr.json new file mode 100644 index 00000000000000..bf87c6f3d738b5 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "port": "Port" + }, + "title": "Reliez votre serveur Minecraft" + } + }, + "title": "Serveur Minecraft" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/hu.json b/homeassistant/components/minecraft_server/.translations/hu.json new file mode 100644 index 00000000000000..9341bdbe4d18b5 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Kiszolg\u00e1l\u00f3 m\u00e1r konfigur\u00e1lva van." + }, + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "name": "N\u00e9v", + "port": "Port" + }, + "title": "Kapcsolja \u00f6ssze a Minecraft szervert" + } + }, + "title": "Minecraft szerver" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/it.json b/homeassistant/components/minecraft_server/.translations/it.json new file mode 100644 index 00000000000000..5861eebcc9a443 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "L'host \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi al server. Controllare l'host e la porta e riprovare. Assicurarsi inoltre che si esegue almeno Minecraft versione 1.7 sul server.", + "invalid_ip": "L'indirizzo IP non \u00e8 valido (non \u00e8 stato possibile determinare l'indirizzo MAC). Correggilo e riprova.", + "invalid_port": "La porta deve essere compresa tra 1024 e 65535. Correggila e riprova." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome", + "port": "Porta" + }, + "description": "Configurare l'istanza del Server Minecraft per consentire il monitoraggio.", + "title": "Collega il tuo Server Minecraft" + } + }, + "title": "Server Minecraft" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/ko.json b/homeassistant/components/minecraft_server/.translations/ko.json new file mode 100644 index 00000000000000..66b281cc5d9c6b --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\ub97c \ud655\uc778\ud55c \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \ub610\ud55c \uc11c\ubc84\uc5d0\uc11c Minecraft \ubc84\uc804 1.7 \uc774\uc0c1\uc744 \uc2e4\ud589 \uc911\uc778\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "invalid_ip": "IP \uc8fc\uc18c\uac00 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4 (MAC \uc8fc\uc18c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4). \uc218\uc815 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_port": "\ud3ec\ud2b8\ub294 1024-65535 \ubc94\uc704\uc5d0 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4. \ud3ec\ud2b8\ub97c \uc218\uc815\ud55c \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "port": "\ud3ec\ud2b8" + }, + "description": "\ubaa8\ub2c8\ud130\ub9c1\uc774 \uac00\ub2a5\ud558\ub3c4\ub85d Minecraft \uc11c\ubc84 \uc778\uc2a4\ud134\uc2a4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "title": "Minecraft \uc11c\ubc84 \uc5f0\uacb0" + } + }, + "title": "Minecraft \uc11c\ubc84" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/lb.json b/homeassistant/components/minecraft_server/.translations/lb.json new file mode 100644 index 00000000000000..f95dd0620052d5 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen mam Server. Iwwerpr\u00e9if den Numm a Port a prob\u00e9ier nach emol. G\u00e9i och s\u00e9cher dass op d'mannst Minecraft Versioun 1.7 um Server leeft.", + "invalid_ip": "IP Adress ass ong\u00eblteg (MAC Adress konnt net best\u00ebmmt ginn). Korrig\u00e9iert et a prob\u00e9iert et nach eng K\u00e9ier w.e.g.", + "invalid_port": "Port muss zw\u00ebscht 1024 a 65535 sinn. Korrig\u00e9iert et a prob\u00e9iert et nach eng K\u00e9ier w.e.g." + }, + "step": { + "user": { + "data": { + "host": "Apparat", + "name": "Numm", + "port": "Port" + }, + "description": "Riicht deng Minecraft Server Instanz a fir d'Iwwerwaachung z'erlaben", + "title": "Verbann d\u00e4in Minecraft Server" + } + }, + "title": "Minecraft Server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/nl.json b/homeassistant/components/minecraft_server/.translations/nl.json new file mode 100644 index 00000000000000..75e19bc2550130 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Host is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met de server. Controleer de host en de poort en probeer het opnieuw. Zorg er ook voor dat u minimaal Minecraft versie 1.7 op uw server uitvoert.", + "invalid_ip": "IP-adres is ongeldig (MAC-adres kon niet worden bepaald). Corrigeer het en probeer het opnieuw.", + "invalid_port": "Poort moet tussen 1024 en 65535 liggen. Corrigeer dit en probeer het opnieuw." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam", + "port": "Poort" + }, + "description": "Stel uw Minecraft server in om monitoring toe te staan.", + "title": "Koppel uw Minecraft server" + } + }, + "title": "Minecraft server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/no.json b/homeassistant/components/minecraft_server/.translations/no.json new file mode 100644 index 00000000000000..f7be289d48ca55 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Verten er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kan ikke koble til serveren. Kontroller verten og porten, og pr\u00f8v p\u00e5 nytt. S\u00f8rg ogs\u00e5 for at du kj\u00f8rer minst Minecraft versjon 1.7 p\u00e5 serveren din.", + "invalid_ip": "IP-adressen er ugyldig (MAC-adressen kan ikke fastsl\u00e5s). Vennligst korriger den og pr\u00f8v p\u00e5 nytt.", + "invalid_port": "Porten m\u00e5 v\u00e6re i omr\u00e5det 1024 til 65535. Vennligst korriger den og pr\u00f8v p\u00e5 nytt." + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn", + "port": "Port" + }, + "description": "Konfigurer Minecraft Server-forekomsten slik at den kan overv\u00e5kes.", + "title": "Link din Minecraft Server" + } + }, + "title": "Minecraft Server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/pl.json b/homeassistant/components/minecraft_server/.translations/pl.json new file mode 100644 index 00000000000000..f9c4a515566ae0 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/pl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Host jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "B\u0142\u0105d po\u0142\u0105czenia z serwerem. Sprawd\u017a adres hosta i port i spr\u00f3buj ponownie. Upewnij si\u0119 tak\u017ce, \u017ce na serwerze dzia\u0142a Minecraft w wersji przynajmniej 1.7.", + "invalid_ip": "Adres IP jest nieprawid\u0142owy (nie mo\u017cna ustali\u0107 adresu MAC). Popraw to i spr\u00f3buj ponownie.", + "invalid_port": "Port musi znajdowa\u0107 si\u0119 w zakresie od 1024 do 65535. Popraw go i spr\u00f3buj ponownie." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nazwa", + "port": "Port" + }, + "description": "Skonfiguruj instancj\u0119 serwera Minecraft, aby umo\u017cliwi\u0107 monitorowanie.", + "title": "Po\u0142\u0105cz sw\u00f3j serwer Minecraft" + } + }, + "title": "Serwer Minecraft" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/ru.json b/homeassistant/components/minecraft_server/.translations/ru.json new file mode 100644 index 00000000000000..916b342ee4a950 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0445 \u0434\u0430\u043d\u043d\u044b\u0445 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0422\u0430\u043a\u0436\u0435 \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043d\u0430 \u0412\u0430\u0448\u0435\u043c \u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d Minecraft \u0432\u0435\u0440\u0441\u0438\u0438 1.7, \u0438\u043b\u0438 \u0432\u044b\u0448\u0435.", + "invalid_ip": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441 (\u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c MAC-\u0430\u0434\u0440\u0435\u0441).", + "invalid_port": "\u041f\u043e\u0440\u0442 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0432 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u0435 \u043e\u0442 1024 \u0434\u043e 65535." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0412\u0430\u0448\u0435\u0433\u043e \u0441\u0435\u0440\u0432\u0435\u0440\u0430 Minecraft.", + "title": "Minecraft Server" + } + }, + "title": "Minecraft Server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/sl.json b/homeassistant/components/minecraft_server/.translations/sl.json new file mode 100644 index 00000000000000..cf8a8af54ee8f5 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Gostitelj je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Povezava s stre\u017enikom ni uspela. Preverite gostitelja in vrata in poskusite znova. Zagotovite tudi, da na stre\u017eniku izvajate vsaj Minecraft razli\u010dice 1.7.", + "invalid_ip": "IP naslov ni veljaven (MAC naslova ni mogo\u010de dolo\u010diti). Popravite ga in poskusite znova.", + "invalid_port": "Vrata morajo biti v razponu od 1024 do 65535. Prosimo, popravite in poskusite znova." + }, + "step": { + "user": { + "data": { + "host": "Gostitelj", + "name": "Ime", + "port": "Vrata" + }, + "description": "Nastavite svoj Minecraft stre\u017enik, da omogo\u010dite spremljanje.", + "title": "Pove\u017eite svoj Minecraft stre\u017enik" + } + }, + "title": "Minecraft stre\u017enik" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/sv.json b/homeassistant/components/minecraft_server/.translations/sv.json new file mode 100644 index 00000000000000..acf941878dda52 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad." + }, + "error": { + "cannot_connect": "Misslyckades med att ansluta till servern. Kontrollera v\u00e4rden och porten och f\u00f6rs\u00f6k igen. Se ocks\u00e5 till att du k\u00f6r minst Minecraft version 1.7 p\u00e5 din server.", + "invalid_ip": "IP-adressen \u00e4r ogiltig (MAC-adressen kunde inte fastst\u00e4llas). Korrigera det och f\u00f6rs\u00f6k igen.", + "invalid_port": "Porten m\u00e5ste ligga inom intervallet 1024 till 65535. Korrigera den och f\u00f6rs\u00f6k igen." + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "name": "Namn", + "port": "Port" + }, + "description": "St\u00e4ll in din Minecraft Server-instans f\u00f6r att till\u00e5ta \u00f6vervakning.", + "title": "L\u00e4nka din Minecraft-server" + } + }, + "title": "Minecraft-server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/tr.json b/homeassistant/components/minecraft_server/.translations/tr.json new file mode 100644 index 00000000000000..595c1686982281 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/tr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Host zaten ayarlanm\u0131\u015f." + }, + "error": { + "cannot_connect": "Server ile ba\u011flant\u0131 kurulamad\u0131. L\u00fctfen host ve port ayarlar\u0131n\u0131 kontrol et ve tekrar dene. Ayr\u0131ca, serverda en az Minecraft s\u00fcr\u00fcm 1.7 \u00e7al\u0131\u015ft\u0131rd\u0131\u011f\u0131ndan emin ol.", + "invalid_ip": "IP adresi ge\u00e7ersiz (MAC adresi belirlenemedi). L\u00fctfen d\u00fczelt ve tekrar dene.", + "invalid_port": "Port 1024 ile 65535 aral\u0131\u011f\u0131nda olmal\u0131d\u0131r. L\u00fctfen d\u00fczelt ve yeniden dene." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Ad", + "port": "Port" + }, + "description": "G\u00f6zetmeye izin vermek i\u00e7in Minecraft server nesnesini ayarla.", + "title": "Minecraft Servern\u0131 ba\u011fla" + } + }, + "title": "Minecraft Server" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/zh-Hant.json b/homeassistant/components/minecraft_server/.translations/zh-Hant.json new file mode 100644 index 00000000000000..c451ad7106558a --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u4f3a\u670d\u5668\u9023\u7dda\u5931\u6557\u3002\u8acb\u6aa2\u67e5\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u5f8c\u518d\u8a66\u4e00\u6b21\u3002\u53e6\u8acb\u78ba\u8a8d\u65bc\u4f3a\u670d\u5668\u4e0a\u57f7\u884c\u6700\u65b0\u7248\u672c Minecraft 1.7 \u7248\u3002", + "invalid_ip": "IP \u4f4d\u5740\u7121\u6548\uff08MAC \u4f4d\u5740\u7121\u6cd5\u78ba\u8a8d\uff09\u3002\u8acb\u4fee\u6b63\u5f8c\u3001\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_port": "\u901a\u8a0a\u57e0\u7bc4\u570d\u4ecb\u65bc 1024 \u81f3 65535\u3002\u8acb\u4fee\u6b63\u5f8c\u3001\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u8a2d\u5b9a Minecraft \u4f3a\u670d\u5668\u4ee5\u9032\u884c\u76e3\u63a7\u3002", + "title": "\u9023\u7d50 Minecraft \u4f3a\u670d\u5668" + } + }, + "title": "Minecraft \u4f3a\u670d\u5668" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py new file mode 100644 index 00000000000000..789e4d8f1b83f9 --- /dev/null +++ b/homeassistant/components/minecraft_server/__init__.py @@ -0,0 +1,273 @@ +"""The Minecraft Server integration.""" + +import asyncio +from datetime import datetime, timedelta +import logging +from typing import Any, Dict + +from mcstatus.server import MinecraftServer as MCStatus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import DOMAIN, MANUFACTURER, SCAN_INTERVAL, SIGNAL_NAME_PREFIX + +PLATFORMS = ["binary_sensor", "sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Minecraft Server component.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: + """Set up Minecraft Server from a config entry.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + + # Create and store server instance. + unique_id = config_entry.unique_id + _LOGGER.debug( + "Creating server instance for '%s' (host='%s', port=%s)", + config_entry.data[CONF_NAME], + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + ) + server = MinecraftServer(hass, unique_id, config_entry.data) + domain_data[unique_id] = server + await server.async_update() + server.start_periodic_update() + + # Set up platform(s). + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload Minecraft Server config entry.""" + unique_id = config_entry.unique_id + server = hass.data[DOMAIN][unique_id] + + # Unload platforms. + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) + + # Clean up. + server.stop_periodic_update() + hass.data[DOMAIN].pop(unique_id) + + return True + + +class MinecraftServer: + """Representation of a Minecraft server.""" + + # Private constants + _MAX_RETRIES_PING = 3 + _MAX_RETRIES_STATUS = 3 + + def __init__( + self, hass: HomeAssistantType, unique_id: str, config_data: ConfigType + ) -> None: + """Initialize server instance.""" + self._hass = hass + + # Server data + self.unique_id = unique_id + self.name = config_data[CONF_NAME] + self.host = config_data[CONF_HOST] + self.port = config_data[CONF_PORT] + self.online = False + self._last_status_request_failed = False + + # 3rd party library instance + self._mc_status = MCStatus(self.host, self.port) + + # Data provided by 3rd party library + self.description = None + self.version = None + self.protocol_version = None + self.latency_time = None + self.players_online = None + self.players_max = None + self.players_list = None + + # Dispatcher signal name + self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" + + # Callback for stopping periodic update. + self._stop_periodic_update = None + + def start_periodic_update(self) -> None: + """Start periodic execution of update method.""" + self._stop_periodic_update = async_track_time_interval( + self._hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + ) + + def stop_periodic_update(self) -> None: + """Stop periodic execution of update method.""" + self._stop_periodic_update() + + async def async_check_connection(self) -> None: + """Check server connection using a 'ping' request and store result.""" + try: + await self._hass.async_add_executor_job( + self._mc_status.ping, self._MAX_RETRIES_PING + ) + self.online = True + except OSError as error: + _LOGGER.debug( + "Error occurred while trying to ping the server - OSError: %s", error + ) + self.online = False + + async def async_update(self, now: datetime = None) -> None: + """Get server data from 3rd party library and update properties.""" + # Check connection status. + server_online_old = self.online + await self.async_check_connection() + server_online = self.online + + # Inform user once about connection state changes if necessary. + if server_online_old and not server_online: + _LOGGER.warning("Connection to server lost") + elif not server_online_old and server_online: + _LOGGER.info("Connection to server (re-)established") + + # Update the server properties if server is online. + if server_online: + await self._async_status_request() + + # Notify sensors about new data. + async_dispatcher_send(self._hass, self.signal_name) + + async def _async_status_request(self) -> None: + """Request server status and update properties.""" + try: + status_response = await self._hass.async_add_executor_job( + self._mc_status.status, self._MAX_RETRIES_STATUS + ) + + # Got answer to request, update properties. + self.description = status_response.description["text"] + self.version = status_response.version.name + self.protocol_version = status_response.version.protocol + self.players_online = status_response.players.online + self.players_max = status_response.players.max + self.latency_time = status_response.latency + self.players_list = [] + if status_response.players.sample is not None: + for player in status_response.players.sample: + self.players_list.append(player.name) + + # Inform user once about successful update if necessary. + if self._last_status_request_failed: + _LOGGER.info("Updating the server properties succeeded again") + self._last_status_request_failed = False + except OSError as error: + # No answer to request, set all properties to unknown. + self.description = None + self.version = None + self.protocol_version = None + self.players_online = None + self.players_max = None + self.latency_time = None + self.players_list = None + + # Inform user once about failed update if necessary. + if not self._last_status_request_failed: + _LOGGER.warning( + "Updating the server properties failed - OSError: %s", error, + ) + self._last_status_request_failed = True + + +class MinecraftServerEntity(Entity): + """Representation of a Minecraft Server base entity.""" + + def __init__( + self, server: MinecraftServer, type_name: str, icon: str, device_class: str + ) -> None: + """Initialize base entity.""" + self._server = server + self._name = f"{server.name} {type_name}" + self._icon = icon + self._unique_id = f"{self._server.unique_id}-{type_name}" + self._device_info = { + "identifiers": {(DOMAIN, self._server.unique_id)}, + "name": self._server.name, + "manufacturer": MANUFACTURER, + "model": f"Minecraft Server ({self._server.version})", + "sw_version": self._server.protocol_version, + } + self._device_class = device_class + self._device_state_attributes = None + self._disconnect_dispatcher = None + + @property + def name(self) -> str: + """Return name.""" + return self._name + + @property + def unique_id(self) -> str: + """Return unique ID.""" + return self._unique_id + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information.""" + return self._device_info + + @property + def device_class(self) -> str: + """Return device class.""" + return self._device_class + + @property + def icon(self) -> str: + """Return icon.""" + return self._icon + + @property + def should_poll(self) -> bool: + """Disable polling.""" + return False + + async def async_update(self) -> None: + """Fetch data from the server.""" + raise NotImplementedError() + + async def async_added_to_hass(self) -> None: + """Connect dispatcher to signal from server.""" + self._disconnect_dispatcher = async_dispatcher_connect( + self.hass, self._server.signal_name, self._update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher before removal.""" + self._disconnect_dispatcher() + + @callback + def _update_callback(self) -> None: + """Triggers update of properties after receiving signal from server.""" + self.async_schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py new file mode 100644 index 00000000000000..cde2a4149007d8 --- /dev/null +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -0,0 +1,47 @@ +"""The Minecraft Server binary sensor platform.""" + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import MinecraftServer, MinecraftServerEntity +from .const import DOMAIN, ICON_STATUS, NAME_STATUS + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Minecraft Server binary sensor platform.""" + server = hass.data[DOMAIN][config_entry.unique_id] + + # Create entities list. + entities = [MinecraftServerStatusBinarySensor(server)] + + # Add binary sensor entities. + async_add_entities(entities, True) + + +class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorDevice): + """Representation of a Minecraft Server status binary sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize status binary sensor.""" + super().__init__( + server=server, + type_name=NAME_STATUS, + icon=ICON_STATUS, + device_class=DEVICE_CLASS_CONNECTIVITY, + ) + self._is_on = False + + @property + def is_on(self) -> bool: + """Return binary state.""" + return self._is_on + + async def async_update(self) -> None: + """Update status.""" + self._is_on = self._server.online diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py new file mode 100644 index 00000000000000..8c6049a2c1b5ca --- /dev/null +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Minecraft Server integration.""" +from functools import partial +import ipaddress + +import getmac +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from . import MinecraftServer +from .const import ( # pylint: disable=unused-import + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) + + +class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Minecraft Server.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + # User inputs. + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + unique_id = "" + + # Check if 'host' is a valid IP address and if so, get the MAC address. + ip_address = None + mac_address = None + try: + ip_address = ipaddress.ip_address(host) + except ValueError: + # Host is not a valid IP address. + pass + else: + # Host is a valid IP address. + if ip_address.version == 4: + # Address type is IPv4. + params = {"ip": host} + else: + # Address type is IPv6. + params = {"ip6": host} + mac_address = await self.hass.async_add_executor_job( + partial(getmac.get_mac_address, **params) + ) + + # Validate IP address via valid MAC address. + if ip_address is not None and mac_address is None: + errors["base"] = "invalid_ip" + # Validate port configuration (limit to user and dynamic port range). + elif (port < 1024) or (port > 65535): + errors["base"] = "invalid_port" + # Validate host and port via ping request to server. + else: + # Build unique_id. + if ip_address is not None: + # Since IP addresses can change and therefore are not allowed in a + # unique_id, fall back to the MAC address. + unique_id = f"{mac_address}-{port}" + else: + # Use host name in unique_id (host names should not change). + unique_id = f"{host}-{port}" + + # Abort in case the host was already configured before. + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Create server instance with configuration data and try pinging the server. + server = MinecraftServer(self.hass, unique_id, user_input) + await server.async_check_connection() + if not server.online: + # Host or port invalid or server not reachable. + errors["base"] = "cannot_connect" + else: + # Configuration data are available and no error was detected, create configuration entry. + return self.async_create_entry( + title=f"{host}:{port}", data=user_input + ) + + # Show configuration form (default form in case of no user_input, + # form filled with user_input and eventually with errors otherwise). + return self._show_config_form(user_input, errors) + + def _show_config_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) + ): vol.All(str, vol.Lower), + vol.Optional( + CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT) + ): int, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py new file mode 100644 index 00000000000000..d86faf23a81168 --- /dev/null +++ b/homeassistant/components/minecraft_server/const.py @@ -0,0 +1,36 @@ +"""Constants for the Minecraft Server integration.""" + +ATTR_PLAYERS_LIST = "players_list" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Minecraft Server" +DEFAULT_PORT = 25565 + +DOMAIN = "minecraft_server" + +ICON_LATENCY_TIME = "mdi:signal" +ICON_PLAYERS_MAX = "mdi:account-multiple" +ICON_PLAYERS_ONLINE = "mdi:account-multiple" +ICON_PROTOCOL_VERSION = "mdi:numeric" +ICON_STATUS = "mdi:lan" +ICON_VERSION = "mdi:numeric" + +KEY_SERVERS = "servers" + +MANUFACTURER = "Mojang AB" + +NAME_LATENCY_TIME = "Latency Time" +NAME_PLAYERS_MAX = "Players Max" +NAME_PLAYERS_ONLINE = "Players Online" +NAME_PROTOCOL_VERSION = "Protocol Version" +NAME_STATUS = "Status" +NAME_VERSION = "Version" + +SCAN_INTERVAL = 60 + +SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" + +UNIT_PLAYERS_MAX = "players" +UNIT_PLAYERS_ONLINE = "players" +UNIT_PROTOCOL_VERSION = None +UNIT_VERSION = None diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json new file mode 100644 index 00000000000000..1dda76dee772ef --- /dev/null +++ b/homeassistant/components/minecraft_server/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "minecraft_server", + "name": "Minecraft Server", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/minecraft_server", + "requirements": ["getmac==0.8.1", "mcstatus==2.3.0"], + "dependencies": [], + "codeowners": ["@elmurato"], + "quality_scale": "silver" +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py new file mode 100644 index 00000000000000..20f9e98e5303c1 --- /dev/null +++ b/homeassistant/components/minecraft_server/sensor.py @@ -0,0 +1,177 @@ +"""The Minecraft Server sensor platform.""" + +import logging +from typing import Any, Dict + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_MILLISECONDS +from homeassistant.helpers.typing import HomeAssistantType + +from . import MinecraftServer, MinecraftServerEntity +from .const import ( + ATTR_PLAYERS_LIST, + DOMAIN, + ICON_LATENCY_TIME, + ICON_PLAYERS_MAX, + ICON_PLAYERS_ONLINE, + ICON_PROTOCOL_VERSION, + ICON_VERSION, + NAME_LATENCY_TIME, + NAME_PLAYERS_MAX, + NAME_PLAYERS_ONLINE, + NAME_PROTOCOL_VERSION, + NAME_VERSION, + UNIT_PLAYERS_MAX, + UNIT_PLAYERS_ONLINE, + UNIT_PROTOCOL_VERSION, + UNIT_VERSION, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Minecraft Server sensor platform.""" + server = hass.data[DOMAIN][config_entry.unique_id] + + # Create entities list. + entities = [ + MinecraftServerVersionSensor(server), + MinecraftServerProtocolVersionSensor(server), + MinecraftServerLatencyTimeSensor(server), + MinecraftServerPlayersOnlineSensor(server), + MinecraftServerPlayersMaxSensor(server), + ] + + # Add sensor entities. + async_add_entities(entities, True) + + +class MinecraftServerSensorEntity(MinecraftServerEntity): + """Representation of a Minecraft Server sensor base entity.""" + + def __init__( + self, + server: MinecraftServer, + type_name: str, + icon: str = None, + unit: str = None, + device_class: str = None, + ) -> None: + """Initialize sensor base entity.""" + super().__init__(server, type_name, icon, device_class) + self._state = None + self._unit = unit + + @property + def available(self) -> bool: + """Return sensor availability.""" + return self._server.online + + @property + def state(self) -> Any: + """Return sensor state.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return sensor measurement unit.""" + return self._unit + + +class MinecraftServerVersionSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server version sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize version sensor.""" + super().__init__( + server=server, type_name=NAME_VERSION, icon=ICON_VERSION, unit=UNIT_VERSION + ) + + async def async_update(self) -> None: + """Update version.""" + self._state = self._server.version + + +class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server protocol version sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize protocol version sensor.""" + super().__init__( + server=server, + type_name=NAME_PROTOCOL_VERSION, + icon=ICON_PROTOCOL_VERSION, + unit=UNIT_PROTOCOL_VERSION, + ) + + async def async_update(self) -> None: + """Update protocol version.""" + self._state = self._server.protocol_version + + +class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server latency time sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize latency time sensor.""" + super().__init__( + server=server, + type_name=NAME_LATENCY_TIME, + icon=ICON_LATENCY_TIME, + unit=TIME_MILLISECONDS, + ) + + async def async_update(self) -> None: + """Update latency time.""" + self._state = self._server.latency_time + + +class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server online players sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize online players sensor.""" + super().__init__( + server=server, + type_name=NAME_PLAYERS_ONLINE, + icon=ICON_PLAYERS_ONLINE, + unit=UNIT_PLAYERS_ONLINE, + ) + + async def async_update(self) -> None: + """Update online players state and device state attributes.""" + self._state = self._server.players_online + + device_state_attributes = None + players_list = self._server.players_list + + if players_list is not None: + if len(players_list) != 0: + device_state_attributes = {ATTR_PLAYERS_LIST: self._server.players_list} + + self._device_state_attributes = device_state_attributes + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return players list in device state attributes.""" + return self._device_state_attributes + + +class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server maximum number of players sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize maximum number of players sensor.""" + super().__init__( + server=server, + type_name=NAME_PLAYERS_MAX, + icon=ICON_PLAYERS_MAX, + unit=UNIT_PLAYERS_MAX, + ) + + async def async_update(self) -> None: + """Update maximum number of players.""" + self._state = self._server.players_max diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json new file mode 100644 index 00000000000000..7743d940be6714 --- /dev/null +++ b/homeassistant/components/minecraft_server/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "title": "Minecraft Server", + "step": { + "user": { + "title": "Link your Minecraft Server", + "description": "Set up your Minecraft Server instance to allow monitoring.", + "data": { + "name": "Name", + "host": "Host", + "port": "Port" + } + } + }, + "error": { + "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", + "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server.", + "invalid_ip": "IP address is invalid (MAC address could not be determined). Please correct it and try again." + }, + "abort": { + "already_configured": "Host is already configured." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py index 6fc4b34229882c..08fdecf364d16a 100644 --- a/homeassistant/components/mobile_app/config_flow.py +++ b/homeassistant/components/mobile_app/config_flow.py @@ -18,7 +18,7 @@ class MobileAppFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" placeholders = { - "apps_url": "https://www.home-assistant.io/components/mobile_app/#apps" + "apps_url": "https://www.home-assistant.io/integrations/mobile_app/#apps" } return self.async_abort( diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 720cf7106e766c..f43f1c88396d30 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -52,6 +52,8 @@ ATTR_WEBHOOK_ENCRYPTED_DATA = "encrypted_data" ATTR_WEBHOOK_TYPE = "type" +ERR_ENCRYPTION_ALREADY_ENABLED = "encryption_already_enabled" +ERR_ENCRYPTION_NOT_AVAILABLE = "encryption_not_available" ERR_ENCRYPTION_REQUIRED = "encryption_required" ERR_SENSOR_NOT_REGISTERED = "not_registered" ERR_SENSOR_DUPLICATE_UNIQUE_ID = "duplicate_unique_id" diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 27cb9934b18ef8..5200c6b0c124f6 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity import Entity from .const import ( + ATTR_DEVICE_NAME, ATTR_SENSOR_ATTRIBUTES, ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, @@ -38,6 +39,7 @@ def __init__(self, config: dict, device: DeviceEntry, entry: ConfigEntry): ) self._entity_type = config[ATTR_SENSOR_TYPE] self.unsub_dispatcher = None + self._name = f"{entry.data[ATTR_DEVICE_NAME]} {config[ATTR_SENSOR_NAME]}" async def async_added_to_hass(self): """Register callbacks.""" @@ -58,7 +60,7 @@ def should_poll(self) -> bool: @property def name(self): """Return the name of the mobile app sensor.""" - return self._config[ATTR_SENSOR_NAME] + return self._name @property def device_class(self): diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 3a477d899250d9..c47f38986a10cc 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -1,8 +1,10 @@ """Webhook handlers for mobile_app.""" from functools import wraps import logging +import secrets -from aiohttp.web import HTTPBadRequest, Request, Response +from aiohttp.web import HTTPBadRequest, Request, Response, json_response +from nacl.secret import SecretBox import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -71,6 +73,8 @@ DATA_DELETED_IDS, DATA_STORE, DOMAIN, + ERR_ENCRYPTION_ALREADY_ENABLED, + ERR_ENCRYPTION_NOT_AVAILABLE, ERR_ENCRYPTION_REQUIRED, ERR_SENSOR_DUPLICATE_UNIQUE_ID, ERR_SENSOR_NOT_REGISTERED, @@ -84,6 +88,7 @@ registration_context, safe_registration, savable_state, + supports_encryption, webhook_response, ) @@ -307,6 +312,34 @@ async def webhook_update_registration(hass, config_entry, data): ) +@WEBHOOK_COMMANDS.register("enable_encryption") +async def webhook_enable_encryption(hass, config_entry, data): + """Handle a encryption enable webhook.""" + if config_entry.data[ATTR_SUPPORTS_ENCRYPTION]: + _LOGGER.warning( + "Refusing to enable encryption for %s because it is already enabled!", + config_entry.data[ATTR_DEVICE_NAME], + ) + return error_response( + ERR_ENCRYPTION_ALREADY_ENABLED, "Encryption already enabled" + ) + + if not supports_encryption(): + _LOGGER.warning( + "Unable to enable encryption for %s because libsodium is unavailable!", + config_entry.data[ATTR_DEVICE_NAME], + ) + return error_response(ERR_ENCRYPTION_NOT_AVAILABLE, "Encryption is unavailable") + + secret = secrets.token_hex(SecretBox.KEY_SIZE) + + data = {**config_entry.data, ATTR_SUPPORTS_ENCRYPTION: True, CONF_SECRET: secret} + + hass.config_entries.async_update_entry(config_entry, data=data) + + return json_response({"secret": secret}) + + @WEBHOOK_COMMANDS.register("register_sensor") @validate_schema( { diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 823703ac4c9f67..218d3d3baa9356 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -159,10 +159,10 @@ def start_modbus(event): def write_register(service): """Write Modbus registers.""" - unit = int(float(service.data.get(ATTR_UNIT))) - address = int(float(service.data.get(ATTR_ADDRESS))) - value = service.data.get(ATTR_VALUE) - client_name = service.data.get(ATTR_HUB) + unit = int(float(service.data[ATTR_UNIT])) + address = int(float(service.data[ATTR_ADDRESS])) + value = service.data[ATTR_VALUE] + client_name = service.data[ATTR_HUB] if isinstance(value, list): hub_collect[client_name].write_registers( unit, address, [int(float(i)) for i in value] @@ -172,10 +172,10 @@ def write_register(service): def write_coil(service): """Write Modbus coil.""" - unit = service.data.get(ATTR_UNIT) - address = service.data.get(ATTR_ADDRESS) - state = service.data.get(ATTR_STATE) - client_name = service.data.get(ATTR_HUB) + unit = service.data[ATTR_UNIT] + address = service.data[ATTR_ADDRESS] + state = service.data[ATTR_STATE] + client_name = service.data[ATTR_HUB] hub_collect[client_name].write_coil(unit, address, state) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) @@ -213,6 +213,12 @@ def read_coils(self, unit, address, count): kwargs = {"unit": unit} if unit else {} return self._client.read_coils(address, count, **kwargs) + def read_discrete_inputs(self, unit, address, count): + """Read discrete inputs.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_discrete_inputs(address, count, **kwargs) + def read_input_registers(self, unit, address, count): """Read input registers.""" with self._lock: diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 9a431d24b0c528..8ea6e2dbfa612e 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -1,7 +1,9 @@ -"""Support for Modbus Coil sensors.""" +"""Support for Modbus Coil and Discrete Input sensors.""" import logging from typing import Optional +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -16,53 +18,76 @@ _LOGGER = logging.getLogger(__name__) -CONF_COIL = "coil" -CONF_COILS = "coils" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COILS): [ - { - vol.Required(CONF_COIL): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_SLAVE): cv.positive_int, - } - ] - } +CONF_DEPRECATED_COIL = "coil" +CONF_DEPRECATED_COILS = "coils" + +CONF_INPUTS = "inputs" +CONF_INPUT_TYPE = "input_type" +CONF_ADDRESS = "address" + +DEFAULT_INPUT_TYPE_COIL = "coil" +DEFAULT_INPUT_TYPE_DISCRETE = "discrete_input" + +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_DEPRECATED_COILS, CONF_INPUTS), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_INPUTS): [ + vol.All( + cv.deprecated(CONF_DEPRECATED_COIL, CONF_ADDRESS), + vol.Schema( + { + vol.Required(CONF_ADDRESS): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, + vol.Optional(CONF_SLAVE): cv.positive_int, + vol.Optional( + CONF_INPUT_TYPE, default=DEFAULT_INPUT_TYPE_COIL + ): vol.In( + [DEFAULT_INPUT_TYPE_COIL, DEFAULT_INPUT_TYPE_DISCRETE] + ), + } + ), + ) + ] + } + ), ) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus binary sensors.""" sensors = [] - for coil in config.get(CONF_COILS): - hub = hass.data[MODBUS_DOMAIN][coil.get(CONF_HUB)] + for entry in config[CONF_INPUTS]: + hub = hass.data[MODBUS_DOMAIN][entry[CONF_HUB]] sensors.append( - ModbusCoilSensor( + ModbusBinarySensor( hub, - coil.get(CONF_NAME), - coil.get(CONF_SLAVE), - coil.get(CONF_COIL), - coil.get(CONF_DEVICE_CLASS), + entry[CONF_NAME], + entry.get(CONF_SLAVE), + entry[CONF_ADDRESS], + entry.get(CONF_DEVICE_CLASS), + entry[CONF_INPUT_TYPE], ) ) add_entities(sensors) -class ModbusCoilSensor(BinarySensorDevice): - """Modbus coil sensor.""" +class ModbusBinarySensor(BinarySensorDevice): + """Modbus binary sensor.""" - def __init__(self, hub, name, slave, coil, device_class): - """Initialize the Modbus coil sensor.""" + def __init__(self, hub, name, slave, address, device_class, input_type): + """Initialize the Modbus binary sensor.""" self._hub = hub self._name = name self._slave = int(slave) if slave else None - self._coil = int(coil) + self._address = int(address) self._device_class = device_class + self._input_type = input_type self._value = None + self._available = True @property def name(self): @@ -79,15 +104,38 @@ def device_class(self) -> Optional[str]: """Return the device class of the sensor.""" return self._device_class + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def update(self): """Update the state of the sensor.""" - result = self._hub.read_coils(self._slave, self._coil, 1) try: - self._value = result.bits[0] - except AttributeError: - _LOGGER.error( - "No response from hub %s, slave %s, coil %s", - self._hub.name, - self._slave, - self._coil, - ) + if self._input_type == DEFAULT_INPUT_TYPE_COIL: + result = self._hub.read_coils(self._slave, self._address, 1) + else: + result = self._hub.read_discrete_inputs(self._slave, self._address, 1) + except ConnectionException: + self._set_unavailable() + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._set_unavailable() + return + + self._value = result.bits[0] + self._available = True + + def _set_unavailable(self): + """Set unavailable state and log it as an error.""" + if not self._available: + return + + _LOGGER.error( + "No response from hub %s, slave %s, address %s", + self._hub.name, + self._slave, + self._address, + ) + self._available = False diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 99ea686543de95..c042384941850a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -1,7 +1,10 @@ """Support for Generic Modbus Thermostats.""" import logging import struct +from typing import Optional +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice @@ -24,6 +27,7 @@ CONF_TARGET_TEMP = "target_temp_register" CONF_CURRENT_TEMP = "current_temp_register" +CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" CONF_DATA_TYPE = "data_type" CONF_COUNT = "data_count" CONF_PRECISION = "precision" @@ -39,6 +43,9 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE HVAC_MODES = [HVAC_MODE_AUTO] +DEFAULT_REGISTER_TYPE_HOLDING = "holding" +DEFAULT_REGISTER_TYPE_INPUT = "input" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_CURRENT_TEMP): cv.positive_int, @@ -46,6 +53,9 @@ vol.Required(CONF_SLAVE): cv.positive_int, vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_COUNT, default=2): cv.positive_int, + vol.Optional( + CONF_CURRENT_TEMP_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING + ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]), vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In( [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT] ), @@ -53,8 +63,8 @@ vol.Optional(CONF_PRECISION, default=1): cv.positive_int, vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP, default=5): cv.positive_int, - vol.Optional(CONF_MIN_TEMP, default=35): cv.positive_int, + vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, + vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_UNIT, default="C"): cv.string, } @@ -63,20 +73,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus Thermostat Platform.""" - name = config.get(CONF_NAME) - modbus_slave = config.get(CONF_SLAVE) - target_temp_register = config.get(CONF_TARGET_TEMP) - current_temp_register = config.get(CONF_CURRENT_TEMP) - data_type = config.get(CONF_DATA_TYPE) - count = config.get(CONF_COUNT) - precision = config.get(CONF_PRECISION) - scale = config.get(CONF_SCALE) - offset = config.get(CONF_OFFSET) - unit = config.get(CONF_UNIT) - max_temp = config.get(CONF_MAX_TEMP) - min_temp = config.get(CONF_MIN_TEMP) - temp_step = config.get(CONF_STEP) - hub_name = config.get(CONF_HUB) + name = config[CONF_NAME] + modbus_slave = config[CONF_SLAVE] + target_temp_register = config[CONF_TARGET_TEMP] + current_temp_register = config[CONF_CURRENT_TEMP] + current_temp_register_type = config[CONF_CURRENT_TEMP_REGISTER_TYPE] + data_type = config[CONF_DATA_TYPE] + count = config[CONF_COUNT] + precision = config[CONF_PRECISION] + scale = config[CONF_SCALE] + offset = config[CONF_OFFSET] + unit = config[CONF_UNIT] + max_temp = config[CONF_MAX_TEMP] + min_temp = config[CONF_MIN_TEMP] + temp_step = config[CONF_STEP] + hub_name = config[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] add_entities( @@ -87,6 +98,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): modbus_slave, target_temp_register, current_temp_register, + current_temp_register_type, data_type, count, precision, @@ -112,6 +124,7 @@ def __init__( modbus_slave, target_temp_register, current_temp_register, + current_temp_register_type, data_type, count, precision, @@ -128,6 +141,7 @@ def __init__( self._slave = modbus_slave self._target_temperature_register = target_temp_register self._current_temperature_register = current_temp_register + self._current_temperature_register_type = current_temp_register_type self._target_temperature = None self._current_temperature = None self._data_type = data_type @@ -140,6 +154,7 @@ def __init__( self._min_temp = min_temp self._temp_step = temp_step self._structure = ">f" + self._available = True data_types = { DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, @@ -156,9 +171,11 @@ def supported_features(self): def update(self): """Update Target & Current Temperature.""" - self._target_temperature = self.read_register(self._target_temperature_register) - self._current_temperature = self.read_register( - self._current_temperature_register + self._target_temperature = self._read_register( + DEFAULT_REGISTER_TYPE_HOLDING, self._target_temperature_register + ) + self._current_temperature = self._read_register( + self._current_temperature_register_type, self._current_temperature_register ) @property @@ -215,20 +232,32 @@ def set_temperature(self, **kwargs): return byte_string = struct.pack(self._structure, target_temperature) register_value = struct.unpack(">h", byte_string[0:2])[0] + self._write_register(self._target_temperature_register, register_value) - try: - self.write_register(self._target_temperature_register, register_value) - except AttributeError as ex: - _LOGGER.error(ex) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available - def read_register(self, register): - """Read holding register using the Modbus hub slave.""" + def _read_register(self, register_type, register) -> Optional[float]: + """Read register using the Modbus hub slave.""" try: - result = self._hub.read_holding_registers( - self._slave, register, self._count - ) - except AttributeError as ex: - _LOGGER.error(ex) + if register_type == DEFAULT_REGISTER_TYPE_INPUT: + result = self._hub.read_input_registers( + self._slave, register, self._count + ) + else: + result = self._hub.read_holding_registers( + self._slave, register, self._count + ) + except ConnectionException: + self._set_unavailable(register) + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._set_unavailable(register) + return + byte_string = b"".join( [x.to_bytes(2, byteorder="big") for x in result.registers] ) @@ -237,8 +266,29 @@ def read_register(self, register): (self._scale * val) + self._offset, f".{self._precision}f" ) register_value = float(register_value) + self._available = True + return register_value - def write_register(self, register, value): - """Write register using the Modbus hub slave.""" - self._hub.write_registers(self._slave, register, [value, 0]) + def _write_register(self, register, value): + """Write holding register using the Modbus hub slave.""" + try: + self._hub.write_registers(self._slave, register, [value, 0]) + except ConnectionException: + self._set_unavailable(register) + return + + self._available = True + + def _set_unavailable(self, register): + """Set unavailable state and log it as an error.""" + if not self._available: + return + + _LOGGER.error( + "No response from hub %s, slave %s, register %s", + self._hub.name, + self._slave, + register, + ) + self._available = False diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 484382983aca0c..716cb5299b7ec9 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -3,6 +3,8 @@ import struct from typing import Any, Optional, Union +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse import voluptuous as vol from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA @@ -35,8 +37,8 @@ DATA_TYPE_INT = "int" DATA_TYPE_UINT = "uint" -REGISTER_TYPE_HOLDING = "holding" -REGISTER_TYPE_INPUT = "input" +DEFAULT_REGISTER_TYPE_HOLDING = "holding" +DEFAULT_REGISTER_TYPE_INPUT = "input" def number(value: Any) -> Union[int, float]: @@ -72,9 +74,9 @@ def number(value: Any) -> Union[int, float]: vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_OFFSET, default=0): number, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In( - [REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT] - ), + vol.Optional( + CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING + ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]), vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean, vol.Optional(CONF_SCALE, default=1): number, vol.Optional(CONF_SLAVE): cv.positive_int, @@ -93,17 +95,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data_types[DATA_TYPE_UINT] = {1: "H", 2: "I", 4: "Q"} data_types[DATA_TYPE_FLOAT] = {1: "e", 2: "f", 4: "d"} - for register in config.get(CONF_REGISTERS): + for register in config[CONF_REGISTERS]: structure = ">i" - if register.get(CONF_DATA_TYPE) != DATA_TYPE_CUSTOM: + if register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: try: structure = ">{}".format( - data_types[register.get(CONF_DATA_TYPE)][register.get(CONF_COUNT)] + data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]] ) except KeyError: _LOGGER.error( "Unable to detect data type for %s sensor, try a custom type", - register.get(CONF_NAME), + register[CONF_NAME], ) continue else: @@ -112,35 +114,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: size = struct.calcsize(structure) except struct.error as err: - _LOGGER.error( - "Error in sensor %s structure: %s", register.get(CONF_NAME), err - ) + _LOGGER.error("Error in sensor %s structure: %s", register[CONF_NAME], err) continue - if register.get(CONF_COUNT) * 2 != size: + if register[CONF_COUNT] * 2 != size: _LOGGER.error( "Structure size (%d bytes) mismatch registers count (%d words)", size, - register.get(CONF_COUNT), + register[CONF_COUNT], ) continue - hub_name = register.get(CONF_HUB) + hub_name = register[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] sensors.append( ModbusRegisterSensor( hub, - register.get(CONF_NAME), + register[CONF_NAME], register.get(CONF_SLAVE), - register.get(CONF_REGISTER), - register.get(CONF_REGISTER_TYPE), + register[CONF_REGISTER], + register[CONF_REGISTER_TYPE], register.get(CONF_UNIT_OF_MEASUREMENT), - register.get(CONF_COUNT), - register.get(CONF_REVERSE_ORDER), - register.get(CONF_SCALE), - register.get(CONF_OFFSET), + register[CONF_COUNT], + register[CONF_REVERSE_ORDER], + register[CONF_SCALE], + register[CONF_OFFSET], structure, - register.get(CONF_PRECISION), + register[CONF_PRECISION], register.get(CONF_DEVICE_CLASS), ) ) @@ -184,6 +184,7 @@ def __init__( self._structure = structure self._device_class = device_class self._value = None + self._available = True async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -212,30 +213,34 @@ def device_class(self) -> Optional[str]: """Return the device class of the sensor.""" return self._device_class + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def update(self): """Update the state of the sensor.""" - if self._register_type == REGISTER_TYPE_INPUT: - result = self._hub.read_input_registers( - self._slave, self._register, self._count - ) - else: - result = self._hub.read_holding_registers( - self._slave, self._register, self._count - ) - val = 0 - try: - registers = result.registers - if self._reverse_order: - registers.reverse() - except AttributeError: - _LOGGER.error( - "No response from hub %s, slave %s, register %s", - self._hub.name, - self._slave, - self._register, - ) + if self._register_type == DEFAULT_REGISTER_TYPE_INPUT: + result = self._hub.read_input_registers( + self._slave, self._register, self._count + ) + else: + result = self._hub.read_holding_registers( + self._slave, self._register, self._count + ) + except ConnectionException: + self._set_unavailable() + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._set_unavailable() return + + registers = result.registers + if self._reverse_order: + registers.reverse() + byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) val = struct.unpack(self._structure, byte_string)[0] val = self._scale * val + self._offset @@ -245,3 +250,18 @@ def update(self): self._value += "." + "0" * self._precision else: self._value = f"{val:.{self._precision}f}" + + self._available = True + + def _set_unavailable(self): + """Set unavailable state and log it as an error.""" + if not self._available: + return + + _LOGGER.error( + "No response from hub %s, slave %s, address %s", + self._hub.name, + self._slave, + self._register, + ) + self._available = False diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 2158528814f9c3..8c11209570b8ab 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -4,9 +4,11 @@ write_coil: address: {description: Address of the register to write to., example: 0} state: {description: State to write., example: false} unit: {description: Address of the modbus unit., example: 21} + hub: {description: Optional Modbus hub name. A hub with the name 'default' is used if not specified., example: "hub1"} write_register: description: Write to a modbus holding register. fields: address: {description: Address of the holding register to write to., example: 0} unit: {description: Address of the modbus unit., example: 21} value: {description: Value (single value or array) to write., example: "0 or [4,0]"} + hub: {description: Optional Modbus hub name. A hub with the name 'default' is used if not specified., example: "hub1"} diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 0ed33dedb578ff..d4f52622538805 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -1,6 +1,9 @@ """Support for Modbus switches.""" import logging +from typing import Optional +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA @@ -29,8 +32,8 @@ CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" -REGISTER_TYPE_HOLDING = "holding" -REGISTER_TYPE_INPUT = "input" +DEFAULT_REGISTER_TYPE_HOLDING = "holding" +DEFAULT_REGISTER_TYPE_INPUT = "input" REGISTERS_SCHEMA = vol.Schema( { @@ -39,8 +42,8 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_REGISTER): cv.positive_int, vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In( - [REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT] + vol.Optional(CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING): vol.In( + [DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT] ), vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional(CONF_STATE_OFF): cv.positive_int, @@ -74,30 +77,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Read configuration and create Modbus devices.""" switches = [] if CONF_COILS in config: - for coil in config.get(CONF_COILS): - hub_name = coil.get(CONF_HUB) + for coil in config[CONF_COILS]: + hub_name = coil[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] switches.append( ModbusCoilSwitch( - hub, coil.get(CONF_NAME), coil.get(CONF_SLAVE), coil.get(CONF_COIL) + hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CONF_COIL] ) ) if CONF_REGISTERS in config: - for register in config.get(CONF_REGISTERS): - hub_name = register.get(CONF_HUB) + for register in config[CONF_REGISTERS]: + hub_name = register[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] switches.append( ModbusRegisterSwitch( hub, - register.get(CONF_NAME), + register[CONF_NAME], register.get(CONF_SLAVE), - register.get(CONF_REGISTER), - register.get(CONF_COMMAND_ON), - register.get(CONF_COMMAND_OFF), - register.get(CONF_VERIFY_STATE), + register[CONF_REGISTER], + register[CONF_COMMAND_ON], + register[CONF_COMMAND_OFF], + register[CONF_VERIFY_STATE], register.get(CONF_VERIFY_REGISTER), - register.get(CONF_REGISTER_TYPE), + register[CONF_REGISTER_TYPE], register.get(CONF_STATE_ON), register.get(CONF_STATE_OFF), ) @@ -116,6 +119,7 @@ def __init__(self, hub, name, slave, coil): self._slave = int(slave) if slave else None self._coil = int(coil) self._is_on = None + self._available = True async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -134,26 +138,62 @@ def name(self): """Return the name of the switch.""" return self._name + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def turn_on(self, **kwargs): """Set switch on.""" - self._hub.write_coil(self._slave, self._coil, True) + self._write_coil(self._coil, True) def turn_off(self, **kwargs): """Set switch off.""" - self._hub.write_coil(self._slave, self._coil, False) + self._write_coil(self._coil, False) def update(self): """Update the state of the switch.""" - result = self._hub.read_coils(self._slave, self._coil, 1) + self._is_on = self._read_coil(self._coil) + + def _read_coil(self, coil) -> Optional[bool]: + """Read coil using the Modbus hub slave.""" try: - self._is_on = bool(result.bits[0]) - except AttributeError: - _LOGGER.error( - "No response from hub %s, slave %s, coil %s", - self._hub.name, - self._slave, - self._coil, - ) + result = self._hub.read_coils(self._slave, coil, 1) + except ConnectionException: + self._set_unavailable() + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._set_unavailable() + return + + value = bool(result.bits[0]) + self._available = True + + return value + + def _write_coil(self, coil, value): + """Write coil using the Modbus hub slave.""" + try: + self._hub.write_coil(self._slave, coil, value) + except ConnectionException: + self._set_unavailable() + return + + self._available = True + + def _set_unavailable(self): + """Set unavailable state and log it as an error.""" + if not self._available: + return + + _LOGGER.error( + "No response from hub %s, slave %s, coil %s", + self._hub.name, + self._slave, + self._coil, + ) + self._available = False class ModbusRegisterSwitch(ModbusCoilSwitch): @@ -184,6 +224,7 @@ def __init__( self._verify_state = verify_state self._verify_register = verify_register if verify_register else self._register self._register_type = register_type + self._available = True if state_on is not None: self._state_on = state_on @@ -199,46 +240,86 @@ def __init__( def turn_on(self, **kwargs): """Set switch on.""" - self._hub.write_register(self._slave, self._register, self._command_on) - if not self._verify_state: - self._is_on = True + + # Only holding register is writable + if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING: + self._write_register(self._command_on) + if not self._verify_state: + self._is_on = True def turn_off(self, **kwargs): """Set switch off.""" - self._hub.write_register(self._slave, self._register, self._command_off) - if not self._verify_state: - self._is_on = False + + # Only holding register is writable + if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING: + self._write_register(self._command_off) + if not self._verify_state: + self._is_on = False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available def update(self): """Update the state of the switch.""" if not self._verify_state: return - value = 0 - if self._register_type == REGISTER_TYPE_INPUT: - result = self._hub.read_input_registers(self._slave, self._register, 1) - else: - result = self._hub.read_holding_registers(self._slave, self._register, 1) - - try: - value = int(result.registers[0]) - except AttributeError: - _LOGGER.error( - "No response from hub %s, slave %s, register %s", - self._hub.name, - self._slave, - self._verify_register, - ) - + value = self._read_register() if value == self._state_on: self._is_on = True elif value == self._state_off: self._is_on = False - else: + elif value is not None: _LOGGER.error( "Unexpected response from hub %s, slave %s register %s, got 0x%2x", self._hub.name, self._slave, - self._verify_register, + self._register, value, ) + + def _read_register(self) -> Optional[int]: + try: + if self._register_type == DEFAULT_REGISTER_TYPE_INPUT: + result = self._hub.read_input_registers(self._slave, self._register, 1) + else: + result = self._hub.read_holding_registers( + self._slave, self._register, 1 + ) + except ConnectionException: + self._set_unavailable() + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._set_unavailable() + return + + value = int(result.registers[0]) + self._available = True + + return value + + def _write_register(self, value): + """Write holding register using the Modbus hub slave.""" + try: + self._hub.write_register(self._slave, self._register, value) + except ConnectionException: + self._set_unavailable() + return + + self._available = True + + def _set_unavailable(self): + """Set unavailable state and log it as an error.""" + if not self._available: + return + + _LOGGER.error( + "No response from hub %s, slave %s, register %s", + self._hub.name, + self._slave, + self._register, + ) + self._available = False diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json index 47dc4d344bcee4..b8cce4bd808de7 100644 --- a/homeassistant/components/mqtt/.translations/ca.json +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primer bot\u00f3", + "button_2": "Segon bot\u00f3", + "button_3": "Tercer bot\u00f3", + "button_4": "Quart bot\u00f3", + "button_5": "Cinqu\u00e8 bot\u00f3", + "button_6": "Sis\u00e8 bot\u00f3", + "turn_off": "Desactiva", + "turn_on": "Activa" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" clicat dues vegades", + "button_long_press": "\"{subtype}\" premut cont\u00ednuament", + "button_long_release": "\"{subtype}\" alliberat despr\u00e9s d'una estona premut", + "button_quadruple_press": "\"{subtype}\" clicat quatre vegades", + "button_quintuple_press": "\"{subtype}\" clicat cinc vegades", + "button_short_press": "\"{subtype}\" premut", + "button_short_release": "\"{subtype}\" alliberat", + "button_triple_press": "\"{subtype}\" clicat tres vegades" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/da.json b/homeassistant/components/mqtt/.translations/da.json index 93ea57d49ea9a1..e018ab7aa1480f 100644 --- a/homeassistant/components/mqtt/.translations/da.json +++ b/homeassistant/components/mqtt/.translations/da.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "F\u00f8rste knap", + "button_2": "Anden knap", + "button_3": "Tredje knap", + "button_4": "Fjerde knap", + "button_5": "Femte knap", + "button_6": "Sjette knap", + "turn_off": "Sluk", + "turn_on": "T\u00e6nd" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" dobbeltklikket", + "button_long_press": "\"{subtype}\" trykket p\u00e5 konstant", + "button_long_release": "\"{subtype}\" sluppet efter langt tryk", + "button_quadruple_press": "\"{subtype}\" firedobbelt-klikket", + "button_quintuple_press": "\"{subtype}\" femdobbelt-klikket", + "button_short_press": "\"{subtype}\" trykket p\u00e5", + "button_short_release": "\"{subtype}\" sluppet", + "button_triple_press": "\"{subtype}\" tredobbeltklikket" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/de.json b/homeassistant/components/mqtt/.translations/de.json index d95c43cc618779..87c6a989f52117 100644 --- a/homeassistant/components/mqtt/.translations/de.json +++ b/homeassistant/components/mqtt/.translations/de.json @@ -22,10 +22,32 @@ "data": { "discovery": "Suche aktivieren" }, - "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem MQTT-Broker herstellt, der vom Add-on hass.io {addon} bereitgestellt wird?", + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem MQTT-Broker herstellt, der vom Hass.io Add-on {addon} bereitgestellt wird?", "title": "MQTT Broker per Hass.io add-on" } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Erste Taste", + "button_2": "Zweite Taste", + "button_3": "Dritte Taste", + "button_4": "Vierte Taste", + "button_5": "F\u00fcnfte Taste", + "button_6": "Sechste Taste", + "turn_off": "Ausschalten", + "turn_on": "Einschalten" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" doppelt angeklickt", + "button_long_press": "\"{subtype}\" kontinuierlich gedr\u00fcckt", + "button_long_release": "\"{subtype}\" nach langem Dr\u00fccken freigegeben", + "button_quadruple_press": "\"{subtype}\" Vierfach geklickt", + "button_quintuple_press": "\"{subtype}\" f\u00fcnffach geklickt", + "button_short_press": "\"{subtype}\" gedr\u00fcckt", + "button_short_release": "\"{subtype}\" freigegeben", + "button_triple_press": "\"{subtype}\" dreifach geklickt" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/en.json b/homeassistant/components/mqtt/.translations/en.json index ad18951a9d7d6a..55baf3b7f0eeb6 100644 --- a/homeassistant/components/mqtt/.translations/en.json +++ b/homeassistant/components/mqtt/.translations/en.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" double clicked", + "button_long_press": "\"{subtype}\" continuously pressed", + "button_long_release": "\"{subtype}\" released after long press", + "button_quadruple_press": "\"{subtype}\" quadruple clicked", + "button_quintuple_press": "\"{subtype}\" quintuple clicked", + "button_short_press": "\"{subtype}\" pressed", + "button_short_release": "\"{subtype}\" released", + "button_triple_press": "\"{subtype}\" triple clicked" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index e0c94ac621a6f1..a705a885494d29 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "button_5": "Quinto bot\u00f3n", + "button_6": "Sexto bot\u00f3n", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" doble pulsaci\u00f3n", + "button_long_press": "\"{subtype}\" pulsado continuamente", + "button_long_release": "\"{subtype}\" soltado despu\u00e9s de pulsaci\u00f3n larga", + "button_quadruple_press": "\"{subtype}\" cu\u00e1druple pulsaci\u00f3n", + "button_quintuple_press": "\"{subtype}\" quintuple pulsaci\u00f3n", + "button_short_press": "\"{subtype}\" pulsado", + "button_short_release": "\"{subtype}\" soltado", + "button_triple_press": "\"{subtype}\" triple pulsaci\u00f3n" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/it.json b/homeassistant/components/mqtt/.translations/it.json index cf2b3ddf7d5bc1..45f7f8dcdb58b7 100644 --- a/homeassistant/components/mqtt/.translations/it.json +++ b/homeassistant/components/mqtt/.translations/it.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primo pulsante", + "button_2": "Secondo pulsante", + "button_3": "Terzo pulsante", + "button_4": "Quarto pulsante", + "button_5": "Quinto pulsante", + "button_6": "Sesto pulsante", + "turn_off": "Spegni", + "turn_on": "Accendi" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" cliccato due volte", + "button_long_press": "\"{subtype}\" premuto continuamente", + "button_long_release": "\"{subtype}\" rilasciato dopo una lunga pressione", + "button_quadruple_press": "\"{subtype}\" cliccato quattro volte", + "button_quintuple_press": "\"{subtype}\" cliccato cinque volte", + "button_short_press": "\"{subtype}\" premuto", + "button_short_release": "\"{subtype}\" rilasciato", + "button_triple_press": "\"{subtype}\" cliccato tre volte" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index 307a6aaadebcfb..8a0243013d9123 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\uccab \ubc88\uc9f8 \ubc84\ud2bc", + "button_2": "\ub450 \ubc88\uc9f8 \ubc84\ud2bc", + "button_3": "\uc138 \ubc88\uc9f8 \ubc84\ud2bc", + "button_4": "\ub124 \ubc88\uc9f8 \ubc84\ud2bc", + "button_5": "\ub2e4\uc12f \ubc88\uc9f8 \ubc84\ud2bc", + "button_6": "\uc5ec\uc12f \ubc88\uc9f8 \ubc84\ud2bc", + "turn_off": "\ub044\uae30", + "turn_on": "\ucf1c\uae30" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \uc774 \ub450 \ubc88 \ub20c\ub9b4 \ub54c", + "button_long_press": "\"{subtype}\" \uc774 \uacc4\uc18d \ub20c\ub824\uc9c8 \ub54c", + "button_long_release": "\"{subtype}\" \uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c", + "button_quadruple_press": "\"{subtype}\" \uc774 \ub124 \ubc88 \ub20c\ub9b4 \ub54c", + "button_quintuple_press": "\"{subtype}\" \uc774 \ub2e4\uc12f \ubc88 \ub20c\ub9b4 \ub54c", + "button_short_press": "\"{subtype}\" \uc774 \ub20c\ub9b4 \ub54c", + "button_short_release": "\"{subtype}\" \uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c", + "button_triple_press": "\"{subtype}\" \uc774 \uc138 \ubc88 \ub20c\ub9b4 \ub54c" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json index 9dcd9c58a3a421..9467ab8a9a73e3 100644 --- a/homeassistant/components/mqtt/.translations/lb.json +++ b/homeassistant/components/mqtt/.translations/lb.json @@ -27,5 +27,17 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u00c9ischte Kn\u00e4ppchen", + "button_2": "Zweete Kn\u00e4ppchen", + "button_3": "Dr\u00ebtte Kn\u00e4ppchen", + "button_4": "V\u00e9ierte Kn\u00e4ppchen", + "button_5": "F\u00ebnnefte Kn\u00e4ppchen", + "button_6": "Sechste Kn\u00e4ppchen", + "turn_off": "Ausschalten", + "turn_on": "Uschalten" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/no.json b/homeassistant/components/mqtt/.translations/no.json index 8dcc0bded9f479..27a77a25226758 100644 --- a/homeassistant/components/mqtt/.translations/no.json +++ b/homeassistant/components/mqtt/.translations/no.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "F\u00f8rste knapp", + "button_2": "Andre knapp", + "button_3": "Tredje knapp", + "button_4": "Fjerde knapp", + "button_5": "Femte knapp", + "button_6": "Sjette knapp", + "turn_off": "Skru av", + "turn_on": "Sl\u00e5 p\u00e5" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" dobbeltklikket", + "button_long_press": "{subtype}\" trykket kontinuerlig", + "button_long_release": "\"{subtype}\" utgitt etter lang trykk", + "button_quadruple_press": "\"{subtype}\" firedoblet klikket", + "button_quintuple_press": "\"{subtype}\" quintuple klikket", + "button_short_press": "{subtype}\u00bb trykket", + "button_short_release": "\"{subtype}\" utgitt", + "button_triple_press": "\"{subtype}\" trippel klikket" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json index 24cdeb0f12e4d0..86561f89d2b8d5 100644 --- a/homeassistant/components/mqtt/.translations/pl.json +++ b/homeassistant/components/mqtt/.translations/pl.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "pierwszy przycisk", + "button_2": "drugi przycisk", + "button_3": "trzeci przycisk", + "button_4": "czwarty przycisk", + "button_5": "pi\u0105ty przycisk", + "button_6": "sz\u00f3sty przycisk", + "turn_off": "nast\u0105pi wy\u0142\u0105czenie", + "turn_on": "nast\u0105pi w\u0142\u0105czenie" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", + "button_long_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "button_long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "button_quadruple_press": "\"{subtype}\" zostanie czterokrotnie naci\u015bni\u0119ty", + "button_quintuple_press": "\"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", + "button_short_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty", + "button_short_release": "\"{subtype}\" zostanie zwolniony", + "button_triple_press": "\"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ro.json b/homeassistant/components/mqtt/.translations/ro.json index bcd150e3063757..0bbf39315d9c7c 100644 --- a/homeassistant/components/mqtt/.translations/ro.json +++ b/homeassistant/components/mqtt/.translations/ro.json @@ -22,7 +22,7 @@ "data": { "discovery": "Activa\u021bi descoperirea" }, - "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a v\u0103 conecta la brokerul MQTT furnizat de addon-ul {addon} ?", + "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a se conecta la brokerul MQTT furnizat de addon-ul {addon} ?", "title": "MQTT Broker, prin intermediul Hass.io add-on" } }, diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index 925b8cf5ab4fec..3559fcc6b2b428 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_5": "\u041f\u044f\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_6": "\u0428\u0435\u0441\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", + "button_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "button_long_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "button_quadruple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430", + "button_quintuple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437", + "button_short_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430", + "button_short_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430", + "button_triple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/sv.json b/homeassistant/components/mqtt/.translations/sv.json index 70e3720038d6fe..c54ae6e3e16ee5 100644 --- a/homeassistant/components/mqtt/.translations/sv.json +++ b/homeassistant/components/mqtt/.translations/sv.json @@ -22,7 +22,7 @@ "data": { "discovery": "Aktivera uppt\u00e4ckt" }, - "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till MQTT Broker som tillhandah\u00e5lls av hass.io-till\u00e4gget {addon} ?", + "description": "Vill du konfigurera Home Assistant att ansluta till den MQTT-broker som tillhandah\u00e5lls av Hass.io-till\u00e4gget \"{addon}\"?", "title": "MQTT Broker via Hass.io till\u00e4gg" } }, diff --git a/homeassistant/components/mqtt/.translations/zh-Hans.json b/homeassistant/components/mqtt/.translations/zh-Hans.json index f30e1bf10b4a17..c12004236bdd52 100644 --- a/homeassistant/components/mqtt/.translations/zh-Hans.json +++ b/homeassistant/components/mqtt/.translations/zh-Hans.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u7b2c\u4e00\u4e2a\u6309\u94ae", + "button_2": "\u7b2c\u4e8c\u4e2a\u6309\u94ae", + "button_3": "\u7b2c\u4e09\u4e2a\u6309\u94ae", + "button_4": "\u7b2c\u56db\u4e2a\u6309\u94ae", + "button_5": "\u7b2c\u4e94\u4e2a\u6309\u94ae", + "button_6": "\u7b2c\u516d\u4e2a\u6309\u94ae", + "turn_off": "\u5173\u95ed", + "turn_on": "\u6253\u5f00" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u53cc\u51fb", + "button_long_press": "\"{subtype}\" \u6301\u7eed\u6309\u4e0b", + "button_long_release": "\"{subtype}\" \u957f\u6309\u540e\u91ca\u653e", + "button_quadruple_press": "\"{subtype}\" \u56db\u8fde\u51fb", + "button_quintuple_press": "\"{subtype}\" \u4e94\u8fde\u51fb", + "button_short_press": "\"{subtype}\" \u6309\u4e0b", + "button_short_release": "\"{subtype}\" \u91ca\u653e", + "button_triple_press": "\"{subtype}\" \u4e09\u8fde\u51fb" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/zh-Hant.json b/homeassistant/components/mqtt/.translations/zh-Hant.json index 09f2f44a902287..3c57e7b6bb0473 100644 --- a/homeassistant/components/mqtt/.translations/zh-Hant.json +++ b/homeassistant/components/mqtt/.translations/zh-Hant.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", + "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "button_5": "\u7b2c\u4e94\u500b\u6309\u9215", + "button_6": "\u7b2c\u516d\u500b\u6309\u9215", + "turn_off": "\u95dc\u9589", + "turn_on": "\u958b\u555f" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u96d9\u64ca", + "button_long_press": "\"{subtype}\" \u6301\u7e8c\u6309\u4e0b", + "button_long_release": "\"{subtype}\" \u9577\u6309\u5f8c\u91cb\u653e", + "button_quadruple_press": "\"{subtype}\" \u56db\u9023\u64ca", + "button_quintuple_press": "\"{subtype}\" \u4e94\u9023\u64ca", + "button_short_press": "\"{subtype}\" \u6309\u4e0b", + "button_short_release": "\"{subtype}\" \u91cb\u653e", + "button_triple_press": "\"{subtype}\" \u4e09\u9023\u64ca" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a9d5ac93ebc5ad..4014c2162dd252 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -36,7 +36,8 @@ HomeAssistantError, Unauthorized, ) -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import config_validation as cv, event, template +from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceDataType @@ -48,6 +49,7 @@ from . import config_flow, discovery, server # noqa: F401 pylint: disable=unused-import from .const import ( ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_TOPIC, CONF_BROKER, CONF_DISCOVERY, CONF_STATE_TOPIC, @@ -68,6 +70,7 @@ DATA_MQTT_HASS_CONFIG = "mqtt_hass_config" SERVICE_PUBLISH = "publish" +SERVICE_DUMP = "dump" CONF_EMBEDDED = "embedded" @@ -509,6 +512,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: hass.data[DATA_MQTT_HASS_CONFIG] = config websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_remove_device) if conf is None: # If we have a config entry, setup is done by that config entry. @@ -567,7 +571,7 @@ async def async_setup_entry(hass, entry): conf = CONFIG_SCHEMA({DOMAIN: entry.data})[DOMAIN] elif any(key in conf for key in entry.data): _LOGGER.warning( - "Data in your config entry is going to override your " + "Data in your configuration entry is going to override your " "configuration.yaml: %s", entry.data, ) @@ -651,7 +655,7 @@ async def async_setup_entry(hass, entry): if result == CONNECTION_FAILED_RECOVERABLE: raise ConfigEntryNotReady - async def async_stop_mqtt(event: Event): + async def async_stop_mqtt(_event: Event): """Stop MQTT component.""" await hass.data[DATA_MQTT].async_disconnect() @@ -683,6 +687,40 @@ async def async_publish_service(call: ServiceCall): DOMAIN, SERVICE_PUBLISH, async_publish_service, schema=MQTT_PUBLISH_SCHEMA ) + async def async_dump_service(call: ServiceCall): + """Handle MQTT dump service calls.""" + messages = [] + + @callback + def collect_msg(msg): + messages.append((msg.topic, msg.payload.replace("\n", ""))) + + unsub = await async_subscribe(hass, call.data["topic"], collect_msg) + + def write_dump(): + with open(hass.config.path("mqtt_dump.txt"), "wt") as fp: + for msg in messages: + fp.write(",".join(msg) + "\n") + + async def finish_dump(_): + """Write dump to file.""" + unsub() + await hass.async_add_executor_job(write_dump) + + event.async_call_later(hass, call.data["duration"], finish_dump) + + hass.services.async_register( + DOMAIN, + SERVICE_DUMP, + async_dump_service, + schema=vol.Schema( + { + vol.Required("topic"): valid_subscribe_topic, + vol.Optional("duration", default=5): int, + } + ), + ) + if conf.get(CONF_DISCOVERY): await _async_setup_discovery( hass, conf, hass.data[DATA_MQTT_HASS_CONFIG], entry @@ -774,27 +812,21 @@ def __init__( async def async_publish( self, topic: str, payload: PublishPayloadType, qos: int, retain: bool ) -> None: - """Publish a MQTT message. - - This method must be run in the event loop and returns a coroutine. - """ + """Publish a MQTT message.""" async with self._paho_lock: _LOGGER.debug("Transmitting message on %s: %s", topic, payload) - await self.hass.async_add_job( + await self.hass.async_add_executor_job( self._mqttc.publish, topic, payload, qos, retain ) async def async_connect(self) -> str: - """Connect to the host. Does process messages yet. - - This method is a coroutine. - """ + """Connect to the host. Does process messages yet.""" # pylint: disable=import-outside-toplevel import paho.mqtt.client as mqtt result: int = None try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( self._mqttc.connect, self.broker, self.port, self.keepalive ) except OSError as err: @@ -808,19 +840,15 @@ async def async_connect(self) -> str: self._mqttc.loop_start() return CONNECTION_SUCCESS - @callback - def async_disconnect(self): - """Stop the MQTT client. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_disconnect(self): + """Stop the MQTT client.""" def stop(): """Stop the MQTT client.""" self._mqttc.disconnect() self._mqttc.loop_stop() - return self.hass.async_add_job(stop) + await self.hass.async_add_executor_job(stop) async def async_subscribe( self, @@ -865,7 +893,9 @@ async def _async_unsubscribe(self, topic: str) -> None: """ async with self._paho_lock: result: int = None - result, _ = await self.hass.async_add_job(self._mqttc.unsubscribe, topic) + result, _ = await self.hass.async_add_executor_job( + self._mqttc.unsubscribe, topic + ) _raise_on_error(result) async def _async_perform_subscription(self, topic: str, qos: int) -> None: @@ -874,7 +904,9 @@ async def _async_perform_subscription(self, topic: str, qos: int) -> None: async with self._paho_lock: result: int = None - result, _ = await self.hass.async_add_job(self._mqttc.subscribe, topic, qos) + result, _ = await self.hass.async_add_executor_job( + self._mqttc.subscribe, topic, qos + ) _raise_on_error(result) def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: @@ -1010,10 +1042,7 @@ def __init__(self, config: dict) -> None: self._attributes_config = config async def async_added_to_hass(self) -> None: - """Subscribe MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + """Subscribe MQTT events.""" await super().async_added_to_hass() await self._attributes_subscribe_topics() @@ -1080,10 +1109,7 @@ def __init__(self, config: dict) -> None: self._avail_config = config async def async_added_to_hass(self) -> None: - """Subscribe MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + """Subscribe MQTT events.""" await super().async_added_to_hass() await self._availability_subscribe_topics() @@ -1133,43 +1159,83 @@ def available(self) -> bool: class MqttDiscoveryUpdate(Entity): """Mixin used to handle updated discovery message.""" - def __init__(self, discovery_hash, discovery_update=None) -> None: + def __init__(self, discovery_data, discovery_update=None) -> None: """Initialize the discovery update mixin.""" - self._discovery_hash = discovery_hash + self._discovery_data = discovery_data self._discovery_update = discovery_update self._remove_signal = None async def async_added_to_hass(self) -> None: """Subscribe to discovery updates.""" await super().async_added_to_hass() + discovery_hash = ( + self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None + ) @callback def discovery_callback(payload): """Handle discovery update.""" _LOGGER.info( - "Got update for entity with hash: %s '%s'", - self._discovery_hash, - payload, + "Got update for entity with hash: %s '%s'", discovery_hash, payload, ) if not payload: # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) self.hass.async_create_task(self.async_remove()) - clear_discovery_hash(self.hass, self._discovery_hash) + clear_discovery_hash(self.hass, discovery_hash) self._remove_signal() elif self._discovery_update: # Non-empty payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) - payload.pop(ATTR_DISCOVERY_HASH) self.hass.async_create_task(self._discovery_update(payload)) - if self._discovery_hash: + if discovery_hash: self._remove_signal = async_dispatcher_connect( self.hass, - MQTT_DISCOVERY_UPDATED.format(self._discovery_hash), + MQTT_DISCOVERY_UPDATED.format(discovery_hash), discovery_callback, ) + async def async_removed_from_registry(self) -> None: + """Clear retained discovery topic in broker.""" + discovery_topic = self._discovery_data[ATTR_DISCOVERY_TOPIC] + publish( + self.hass, discovery_topic, "", retain=True, + ) + + async def async_will_remove_from_hass(self) -> None: + """Stop listening to signal.""" + if self._remove_signal: + self._remove_signal() + + +def device_info_from_config(config): + """Return a device description for device registry.""" + if not config: + return None + + info = { + "identifiers": {(DOMAIN, id_) for id_ in config[CONF_IDENTIFIERS]}, + "connections": {tuple(x) for x in config[CONF_CONNECTIONS]}, + } + + if CONF_MANUFACTURER in config: + info["manufacturer"] = config[CONF_MANUFACTURER] + + if CONF_MODEL in config: + info["model"] = config[CONF_MODEL] + + if CONF_NAME in config: + info["name"] = config[CONF_NAME] + + if CONF_SW_VERSION in config: + info["sw_version"] = config[CONF_SW_VERSION] + + if CONF_VIA_DEVICE in config: + info["via_device"] = (DOMAIN, config[CONF_VIA_DEVICE]) + + return info + class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" @@ -1193,32 +1259,36 @@ async def device_info_discovery_update(self, config: dict): @property def device_info(self): """Return a device description for device registry.""" - if not self._device_config: - return None - - info = { - "identifiers": { - (DOMAIN, id_) for id_ in self._device_config[CONF_IDENTIFIERS] - }, - "connections": {tuple(x) for x in self._device_config[CONF_CONNECTIONS]}, - } - - if CONF_MANUFACTURER in self._device_config: - info["manufacturer"] = self._device_config[CONF_MANUFACTURER] - - if CONF_MODEL in self._device_config: - info["model"] = self._device_config[CONF_MODEL] - - if CONF_NAME in self._device_config: - info["name"] = self._device_config[CONF_NAME] + return device_info_from_config(self._device_config) - if CONF_SW_VERSION in self._device_config: - info["sw_version"] = self._device_config[CONF_SW_VERSION] - if CONF_VIA_DEVICE in self._device_config: - info["via_device"] = (DOMAIN, self._device_config[CONF_VIA_DEVICE]) +@websocket_api.websocket_command( + {vol.Required("type"): "mqtt/device/remove", vol.Required("device_id"): str} +) +@websocket_api.async_response +async def websocket_remove_device(hass, connection, msg): + """Delete device.""" + device_id = msg["device_id"] + dev_registry = await get_dev_reg(hass) + + device = dev_registry.async_get(device_id) + if not device: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Device not found" + ) + return + + for config_entry in device.config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry) + # Only delete the device if it belongs to an MQTT device entry + if config_entry.domain == DOMAIN: + dev_registry.async_remove_device(device_id) + connection.send_message(websocket_api.result_message(msg["id"])) + return - return info + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Non MQTT device" + ) @websocket_api.async_response diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 6f9b172010256f..6cfab66c3f15ff 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -3,6 +3,7 @@ ABBREVIATIONS = { "act_t": "action_topic", "act_tpl": "action_template", + "atype": "automation_type", "aux_cmd_t": "aux_command_topic", "aux_stat_tpl": "aux_state_template", "aux_stat_t": "aux_state_topic", @@ -80,6 +81,7 @@ "osc_cmd_t": "oscillation_command_topic", "osc_stat_t": "oscillation_state_topic", "osc_val_tpl": "oscillation_value_template", + "pl": "payload", "pl_arm_away": "payload_arm_away", "pl_arm_home": "payload_arm_home", "pl_arm_nite": "payload_arm_night", @@ -132,14 +134,17 @@ "spds": "speeds", "src_type": "source_type", "stat_clsd": "state_closed", + "stat_closing": "state_closing", "stat_off": "state_off", "stat_on": "state_on", "stat_open": "state_open", + "stat_opening": "state_opening", "stat_locked": "state_locked", "stat_unlocked": "state_unlocked", "stat_t": "state_topic", "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", + "stype": "subtype", "sup_feat": "supported_features", "swing_mode_cmd_t": "swing_mode_command_topic", "swing_mode_stat_tpl": "swing_mode_state_template", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 43d0bb570a8e5a..043fa62f6effec 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -98,15 +98,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT alarm control panel.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -115,10 +114,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm(config, config_entry, discovery_hash)]) + async_add_entities([MqttAlarm(config, config_entry, discovery_data)]) class MqttAlarm( @@ -130,7 +129,7 @@ class MqttAlarm( ): """Representation of a MQTT alarm status.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Init the MQTT Alarm Control Panel.""" self._state = None self._config = config @@ -141,7 +140,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -207,6 +206,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index fe47729561d4c6..d268c12aa87729 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -79,15 +79,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT binary sensor.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -96,10 +95,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT binary sensor.""" - async_add_entities([MqttBinarySensor(config, config_entry, discovery_hash)]) + async_add_entities([MqttBinarySensor(config, config_entry, discovery_data)]) class MqttBinarySensor( @@ -111,7 +110,7 @@ class MqttBinarySensor( ): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT binary sensor.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -124,7 +123,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -229,6 +228,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @callback def value_is_expired(self, *_): diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 831c47c3621a55..9bbb1503196eb2 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,6 +1,4 @@ """Camera that loads a picture from an MQTT topic.""" - -import asyncio import logging import voluptuous as vol @@ -49,15 +47,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT camera.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -66,16 +63,16 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Camera.""" - async_add_entities([MqttCamera(config, config_entry, discovery_hash)]) + async_add_entities([MqttCamera(config, config_entry, discovery_data)]) class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera): """representation of a MQTT camera.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT Camera.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -87,7 +84,7 @@ def __init__(self, config, config_entry, discovery_hash): device_config = config.get(CONF_DEVICE) Camera.__init__(self) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -129,9 +126,9 @@ async def async_will_remove_from_hass(self): self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state ) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) - @asyncio.coroutine - def async_camera_image(self): + async def async_camera_image(self): """Return image response.""" return self._last_image diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 91a36a310cbd98..46404de0c8a776 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -243,15 +243,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT climate device.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - hass, config, async_add_entities, config_entry, discovery_hash + hass, config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -260,10 +259,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - hass, config, async_add_entities, config_entry=None, discovery_hash=None + hass, config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT climate devices.""" - async_add_entities([MqttClimate(hass, config, config_entry, discovery_hash)]) + async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) class MqttClimate( @@ -275,7 +274,7 @@ class MqttClimate( ): """Representation of an MQTT climate device.""" - def __init__(self, hass, config, config_entry, discovery_hash): + def __init__(self, hass, config, config_entry, discovery_data): """Initialize the climate device.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -303,7 +302,7 @@ def __init__(self, hass, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -552,6 +551,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 3234bebbfc1299..6044ec2af6e5ff 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,6 +4,7 @@ DEFAULT_DISCOVERY = False ATTR_DISCOVERY_HASH = "discovery_hash" +ATTR_DISCOVERY_TOPIC = "discovery_topic" CONF_STATE_TOPIC = "state_topic" PROTOCOL_311 = "3.1.1" DEFAULT_QOS = 0 diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index e6cfab90c26923..a7a396781928e0 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -25,7 +25,9 @@ CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, STATE_CLOSED, + STATE_CLOSING, STATE_OPEN, + STATE_OPENING, STATE_UNKNOWN, ) from homeassistant.core import callback @@ -64,7 +66,9 @@ CONF_POSITION_CLOSED = "position_closed" CONF_POSITION_OPEN = "position_open" CONF_STATE_CLOSED = "state_closed" +CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" +CONF_STATE_OPENING = "state_opening" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_INVERT_STATE = "tilt_invert_state" CONF_TILT_MAX = "tilt_max" @@ -131,7 +135,9 @@ def validate_options(value): vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_SET_POSITION_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, + vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional( CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION @@ -172,15 +178,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -189,10 +194,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Cover.""" - async_add_entities([MqttCover(config, config_entry, discovery_hash)]) + async_add_entities([MqttCover(config, config_entry, discovery_data)]) class MqttCover( @@ -204,7 +209,7 @@ class MqttCover( ): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the cover.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._position = None @@ -222,7 +227,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -289,12 +294,20 @@ def state_message_received(msg): payload = template.async_render_with_possible_json_value(payload) if payload == self._config[CONF_STATE_OPEN]: - self._state = False + self._state = STATE_OPEN + elif payload == self._config[CONF_STATE_OPENING]: + self._state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSED]: - self._state = True + self._state = STATE_CLOSED + elif payload == self._config[CONF_STATE_CLOSING]: + self._state = STATE_CLOSING else: - _LOGGER.warning("Payload is not True or False: %s", payload) + _LOGGER.warning( + "Payload is not supported (e.g. open, closed, opening, closing): %s", + payload, + ) return + self.async_write_ha_state() @callback @@ -309,7 +322,11 @@ def position_message_received(msg): float(payload), COVER_PAYLOAD ) self._position = percentage_payload - self._state = percentage_payload == DEFAULT_POSITION_CLOSED + self._state = ( + STATE_CLOSED + if percentage_payload == DEFAULT_POSITION_CLOSED + else STATE_OPEN + ) else: _LOGGER.warning("Payload is not integer within range: %s", payload) return @@ -352,6 +369,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): @@ -370,8 +388,21 @@ def name(self): @property def is_closed(self): - """Return if the cover is closed.""" - return self._state + """Return true if the cover is closed or None if the status is unknown.""" + if self._state is None: + return None + + return self._state == STATE_CLOSED + + @property + def is_opening(self): + """Return true if the cover is actively opening.""" + return self._state == STATE_OPENING + + @property + def is_closing(self): + """Return true if the cover is actively closing.""" + return self._state == STATE_CLOSING @property def current_cover_position(self): @@ -423,7 +454,7 @@ async def async_open_cover(self, **kwargs): ) if self._optimistic: # Optimistically assume that cover has changed state. - self._state = False + self._state = STATE_OPEN if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( self._config[CONF_POSITION_OPEN], COVER_PAYLOAD @@ -444,7 +475,7 @@ async def async_close_cover(self, **kwargs): ) if self._optimistic: # Optimistically assume that cover has changed state. - self._state = True + self._state = STATE_CLOSED if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD @@ -538,7 +569,11 @@ async def async_set_cover_position(self, **kwargs): self._config[CONF_RETAIN], ) if self._optimistic: - self._state = percentage_position == self._config[CONF_POSITION_CLOSED] + self._state = ( + STATE_CLOSED + if percentage_position == self._config[CONF_POSITION_CLOSED] + else STATE_OPEN + ) self._position = percentage_position self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py new file mode 100644 index 00000000000000..4fcfd8f66f216c --- /dev/null +++ b/homeassistant/components/mqtt/device_automation.py @@ -0,0 +1,51 @@ +"""Provides device automations for MQTT.""" +import logging + +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ATTR_DISCOVERY_HASH, device_trigger +from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash + +_LOGGER = logging.getLogger(__name__) + +AUTOMATION_TYPE_TRIGGER = "trigger" +AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] +AUTOMATION_TYPES_SCHEMA = vol.In(AUTOMATION_TYPES) +CONF_AUTOMATION_TYPE = "automation_type" + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + {vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup_entry(hass, config_entry): + """Set up MQTT device automation dynamically through MQTT discovery.""" + + async def async_device_removed(event): + """Handle the removal of a device.""" + if event.data["action"] != "remove": + return + await device_trigger.async_device_removed(hass, event.data["device_id"]) + + async def async_discover(discovery_payload): + """Discover and add an MQTT device automation.""" + discovery_data = discovery_payload.discovery_data + try: + config = PLATFORM_SCHEMA(discovery_payload) + if config[CONF_AUTOMATION_TYPE] == AUTOMATION_TYPE_TRIGGER: + await device_trigger.async_setup_trigger( + hass, config, config_entry, discovery_data + ) + except Exception: + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) + raise + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format("device_automation", "mqtt"), async_discover + ) + hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed) diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py new file mode 100644 index 00000000000000..92bef0578c9c50 --- /dev/null +++ b/homeassistant/components/mqtt/device_trigger.py @@ -0,0 +1,294 @@ +"""Provides device automations for MQTT.""" +import logging +from typing import Callable, List + +import attr +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.components.automation import AutomationActionType +import homeassistant.components.automation.mqtt as automation_mqtt +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import ( + ATTR_DISCOVERY_HASH, + CONF_CONNECTIONS, + CONF_DEVICE, + CONF_IDENTIFIERS, + CONF_PAYLOAD, + CONF_QOS, + DOMAIN, +) +from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTOMATION_TYPE = "automation_type" +CONF_DISCOVERY_ID = "discovery_id" +CONF_SUBTYPE = "subtype" +CONF_TOPIC = "topic" +DEFAULT_ENCODING = "utf-8" +DEVICE = "device" + +MQTT_TRIGGER_BASE = { + # Trigger when MQTT message is received + CONF_PLATFORM: DEVICE, + CONF_DOMAIN: DOMAIN, +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): DEVICE, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DISCOVERY_ID): str, + vol.Required(CONF_TYPE): cv.string, + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_AUTOMATION_TYPE): str, + vol.Required(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD, default=None): vol.Any(None, cv.string), + vol.Required(CONF_TYPE): cv.string, + vol.Required(CONF_SUBTYPE): cv.string, + }, + mqtt.validate_device_has_at_least_one_identifier, +) + +DEVICE_TRIGGERS = "mqtt_device_triggers" + + +@attr.s(slots=True) +class TriggerInstance: + """Attached trigger settings.""" + + action = attr.ib(type=AutomationActionType) + automation_info = attr.ib(type=dict) + trigger = attr.ib(type="Trigger") + remove = attr.ib(type=CALLBACK_TYPE, default=None) + + async def async_attach_trigger(self): + """Attach MQTT trigger.""" + mqtt_config = { + automation_mqtt.CONF_TOPIC: self.trigger.topic, + automation_mqtt.CONF_ENCODING: DEFAULT_ENCODING, + automation_mqtt.CONF_QOS: self.trigger.qos, + } + if self.trigger.payload: + mqtt_config[CONF_PAYLOAD] = self.trigger.payload + + if self.remove: + self.remove() + self.remove = await automation_mqtt.async_attach_trigger( + self.trigger.hass, mqtt_config, self.action, self.automation_info, + ) + + +@attr.s(slots=True) +class Trigger: + """Device trigger settings.""" + + device_id = attr.ib(type=str) + discovery_hash = attr.ib(type=tuple) + hass = attr.ib(type=HomeAssistantType) + payload = attr.ib(type=str) + qos = attr.ib(type=int) + remove_signal = attr.ib(type=Callable[[], None]) + subtype = attr.ib(type=str) + topic = attr.ib(type=str) + type = attr.ib(type=str) + trigger_instances = attr.ib(type=[TriggerInstance], default=attr.Factory(list)) + + async def add_trigger(self, action, automation_info): + """Add MQTT trigger.""" + instance = TriggerInstance(action, automation_info, self) + self.trigger_instances.append(instance) + + if self.topic is not None: + # If we know about the trigger, subscribe to MQTT topic + await instance.async_attach_trigger() + + @callback + def async_remove() -> None: + """Remove trigger.""" + if instance not in self.trigger_instances: + raise HomeAssistantError("Can't remove trigger twice") + + if instance.remove: + instance.remove() + self.trigger_instances.remove(instance) + + return async_remove + + async def update_trigger(self, config, discovery_hash, remove_signal): + """Update MQTT device trigger.""" + self.discovery_hash = discovery_hash + self.remove_signal = remove_signal + self.type = config[CONF_TYPE] + self.subtype = config[CONF_SUBTYPE] + self.topic = config[CONF_TOPIC] + self.payload = config[CONF_PAYLOAD] + self.qos = config[CONF_QOS] + + # Unsubscribe+subscribe if this trigger is in use + for trig in self.trigger_instances: + await trig.async_attach_trigger() + + def detach_trigger(self): + """Remove MQTT device trigger.""" + # Mark trigger as unknown + self.topic = None + + # Unsubscribe if this trigger is in use + for trig in self.trigger_instances: + if trig.remove: + trig.remove() + trig.remove = None + + +async def _update_device(hass, config_entry, config): + """Update device registry.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + config_entry_id = config_entry.entry_id + device_info = mqtt.device_info_from_config(config[CONF_DEVICE]) + + if config_entry_id is not None and device_info is not None: + device_info["config_entry_id"] = config_entry_id + device_registry.async_get_or_create(**device_info) + + +async def async_setup_trigger(hass, config, config_entry, discovery_data): + """Set up the MQTT device trigger.""" + config = TRIGGER_DISCOVERY_SCHEMA(config) + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + discovery_id = discovery_hash[1] + remove_signal = None + + async def discovery_update(payload): + """Handle discovery update.""" + _LOGGER.info( + "Got update for trigger with hash: %s '%s'", discovery_hash, payload + ) + if not payload: + # Empty payload: Remove trigger + _LOGGER.info("Removing trigger: %s", discovery_hash) + if discovery_id in hass.data[DEVICE_TRIGGERS]: + device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id] + device_trigger.detach_trigger() + clear_discovery_hash(hass, discovery_hash) + remove_signal() + else: + # Non-empty payload: Update trigger + _LOGGER.info("Updating trigger: %s", discovery_hash) + config = TRIGGER_DISCOVERY_SCHEMA(payload) + await _update_device(hass, config_entry, config) + device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id] + await device_trigger.update_trigger(config, discovery_hash, remove_signal) + + remove_signal = async_dispatcher_connect( + hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), discovery_update + ) + + await _update_device(hass, config_entry, config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_device( + {(DOMAIN, id_) for id_ in config[CONF_DEVICE][CONF_IDENTIFIERS]}, + {tuple(x) for x in config[CONF_DEVICE][CONF_CONNECTIONS]}, + ) + + if device is None: + return + + if DEVICE_TRIGGERS not in hass.data: + hass.data[DEVICE_TRIGGERS] = {} + if discovery_id not in hass.data[DEVICE_TRIGGERS]: + hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( + hass=hass, + device_id=device.id, + discovery_hash=discovery_hash, + type=config[CONF_TYPE], + subtype=config[CONF_SUBTYPE], + topic=config[CONF_TOPIC], + payload=config[CONF_PAYLOAD], + qos=config[CONF_QOS], + remove_signal=remove_signal, + ) + else: + await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger( + config, discovery_hash, remove_signal + ) + + +async def async_device_removed(hass: HomeAssistant, device_id: str): + """Handle the removal of a device.""" + triggers = await async_get_triggers(hass, device_id) + for trig in triggers: + device_trigger = hass.data[DEVICE_TRIGGERS].pop(trig[CONF_DISCOVERY_ID]) + if device_trigger: + device_trigger.detach_trigger() + clear_discovery_hash(hass, device_trigger.discovery_hash) + device_trigger.remove_signal() + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for MQTT devices.""" + triggers = [] + + if DEVICE_TRIGGERS not in hass.data: + return triggers + + for discovery_id, trig in hass.data[DEVICE_TRIGGERS].items(): + if trig.device_id != device_id or trig.topic is None: + continue + + trigger = { + **MQTT_TRIGGER_BASE, + "device_id": device_id, + "type": trig.type, + "subtype": trig.subtype, + "discovery_id": discovery_id, + } + triggers.append(trigger) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if DEVICE_TRIGGERS not in hass.data: + hass.data[DEVICE_TRIGGERS] = {} + config = TRIGGER_SCHEMA(config) + device_id = config[CONF_DEVICE_ID] + discovery_id = config[CONF_DISCOVERY_ID] + + if discovery_id not in hass.data[DEVICE_TRIGGERS]: + hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( + hass=hass, + device_id=device_id, + discovery_hash=None, + remove_signal=None, + type=config[CONF_TYPE], + subtype=config[CONF_SUBTYPE], + topic=None, + payload=None, + qos=None, + ) + return await hass.data[DEVICE_TRIGGERS][discovery_id].add_trigger( + action, automation_info + ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index f393c3157932a5..c54ab395c94407 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -11,7 +11,7 @@ from homeassistant.helpers.typing import HomeAssistantType from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS -from .const import ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC +from .const import ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_TOPIC, CONF_STATE_TOPIC _LOGGER = logging.getLogger(__name__) @@ -26,6 +26,7 @@ "camera", "climate", "cover", + "device_automation", "fan", "light", "lock", @@ -40,6 +41,7 @@ "camera", "climate", "cover", + "device_automation", "fan", "light", "lock", @@ -135,6 +137,11 @@ async def async_device_message_received(msg): if payload: # Attach MQTT topic to the payload, used for debug prints setattr(payload, "__configuration_source__", f"MQTT (topic: '{topic}')") + discovery_data = { + ATTR_DISCOVERY_HASH: discovery_hash, + ATTR_DISCOVERY_TOPIC: topic, + } + setattr(payload, "discovery_data", discovery_data) if CONF_PLATFORM in payload and "schema" not in payload: platform = payload[CONF_PLATFORM] @@ -171,8 +178,6 @@ async def async_device_message_received(msg): topic, ) - payload[ATTR_DISCOVERY_HASH] = discovery_hash - if ALREADY_DISCOVERED not in hass.data: hass.data[ALREADY_DISCOVERED] = {} if discovery_hash in hass.data[ALREADY_DISCOVERED]: @@ -197,9 +202,15 @@ async def async_device_message_received(msg): config_entries_key = "{}.{}".format(component, "mqtt") async with hass.data[DATA_CONFIG_ENTRY_LOCK]: if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: - await hass.config_entries.async_forward_entry_setup( - config_entry, component - ) + if component == "device_automation": + # Local import to avoid circular dependencies + from . import device_automation + + await device_automation.async_setup_entry(hass, config_entry) + else: + await hass.config_entries.async_forward_entry_setup( + config_entry, component + ) hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) async_dispatcher_send( diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 07cb711ebd0ce7..b50bdf9734bb0c 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -118,15 +118,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT fan.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -135,13 +134,12 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT fan.""" - async_add_entities([MqttFan(config, config_entry, discovery_hash)]) + async_add_entities([MqttFan(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttFan( MqttAttributes, MqttAvailability, @@ -151,7 +149,7 @@ class MqttFan( ): """A MQTT fan component.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT fan.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False @@ -174,7 +172,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -318,6 +316,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index a72008c059f10a..d48b4ae47623d8 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,9 +1,4 @@ -""" -Support for MQTT lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt/ -""" +"""Support for MQTT lights.""" import logging import voluptuous as vol @@ -52,15 +47,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT light.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -69,7 +63,7 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up a MQTT Light.""" setup_entity = { @@ -78,5 +72,5 @@ async def _async_setup_entity( "template": async_setup_entity_template, } await setup_entity[config[CONF_SCHEMA]]( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index ff57db7c8c1d50..a9ea21b4b0a824 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -1,9 +1,4 @@ -""" -Support for MQTT lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt/ -""" +"""Support for MQTT lights.""" import logging import voluptuous as vol @@ -151,15 +146,14 @@ async def async_setup_entity_basic( - config, async_add_entities, config_entry, discovery_hash=None + config, async_add_entities, config_entry, discovery_data=None ): """Set up a MQTT Light.""" config.setdefault(CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) - async_add_entities([MqttLight(config, config_entry, discovery_hash)]) + async_add_entities([MqttLight(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttLight( MqttAttributes, MqttAvailability, @@ -170,7 +164,7 @@ class MqttLight( ): """Representation of a MQTT light.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize MQTT light.""" self._state = False self._sub_state = None @@ -200,7 +194,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -541,6 +535,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def brightness(self): diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index c4de1edbc3c4fe..60ecf80fb6341b 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -1,9 +1,4 @@ -""" -Support for MQTT JSON lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt_json/ -""" +"""Support for MQTT JSON lights.""" import json import logging @@ -124,13 +119,12 @@ async def async_setup_entity_json( - config: ConfigType, async_add_entities, config_entry, discovery_hash + config: ConfigType, async_add_entities, config_entry, discovery_data ): """Set up a MQTT JSON Light.""" - async_add_entities([MqttLightJson(config, config_entry, discovery_hash)]) + async_add_entities([MqttLightJson(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttLightJson( MqttAttributes, MqttAvailability, @@ -141,7 +135,7 @@ class MqttLightJson( ): """Representation of a MQTT JSON light.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize MQTT JSON light.""" self._state = False self._sub_state = None @@ -164,7 +158,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -352,6 +346,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def brightness(self): diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index dd69a8e87d68d5..853e7f4411f532 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -1,9 +1,4 @@ -""" -Support for MQTT Template lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt_template/ -""" +"""Support for MQTT Template lights.""" import logging import voluptuous as vol @@ -98,13 +93,12 @@ async def async_setup_entity_template( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ): """Set up a MQTT Template light.""" - async_add_entities([MqttTemplate(config, config_entry, discovery_hash)]) + async_add_entities([MqttTemplate(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttTemplate( MqttAttributes, MqttAvailability, @@ -115,7 +109,7 @@ class MqttTemplate( ): """Representation of a MQTT Template light.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize a MQTT Template light.""" self._state = False self._sub_state = None @@ -139,7 +133,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -329,6 +323,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def brightness(self): diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 6910e955288dd7..89f005b74694e1 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -80,15 +80,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT lock.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -97,10 +96,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Lock platform.""" - async_add_entities([MqttLock(config, config_entry, discovery_hash)]) + async_add_entities([MqttLock(config, config_entry, discovery_data)]) class MqttLock( @@ -112,7 +111,7 @@ class MqttLock( ): """Representation of a lock that can be toggled using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the lock.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False @@ -126,7 +125,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -192,6 +191,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 967a434c9d51a7..07910697d21fc3 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -76,15 +76,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover_sensor(discovery_payload): """Discover and add a discovered MQTT sensor.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -93,10 +92,10 @@ async def async_discover_sensor(discovery_payload): async def _async_setup_entity( - config: ConfigType, async_add_entities, config_entry=None, discovery_hash=None + config: ConfigType, async_add_entities, config_entry=None, discovery_data=None ): """Set up MQTT sensor.""" - async_add_entities([MqttSensor(config, config_entry, discovery_hash)]) + async_add_entities([MqttSensor(config, config_entry, discovery_data)]) class MqttSensor( @@ -104,7 +103,7 @@ class MqttSensor( ): """Representation of a sensor that can be updated using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the sensor.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -123,7 +122,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -208,6 +207,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @callback def value_is_expired(self, *_): diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 3ed2fb71b14d2b..61ba5c392b193c 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -1,5 +1,4 @@ """Support for a local MQTT broker.""" -import asyncio import logging import tempfile @@ -29,8 +28,7 @@ ) -@asyncio.coroutine -def async_start(hass, password, server_config): +async def async_start(hass, password, server_config): """Initialize MQTT Server. This method is a coroutine. @@ -47,17 +45,16 @@ def async_start(hass, password, server_config): server_config = gen_server_config broker = Broker(server_config, hass.loop) - yield from broker.start() + await broker.start() except BrokerException: _LOGGER.exception("Error initializing MQTT server") return False, None finally: passwd.close() - @asyncio.coroutine - def async_shutdown_mqtt_server(event): + async def async_shutdown_mqtt_server(event): """Shut down the MQTT server.""" - yield from broker.shutdown() + await broker.shutdown() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown_mqtt_server) diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index e338e21802a02a..2af3c22fe50cda 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -24,3 +24,14 @@ publish: description: If message should have the retain flag set. example: true default: false + +dump: + description: Dump messages on a topic selector to the 'mqtt_dump.txt' file in your config folder. + fields: + topic: + description: topic to listen to + example: "OpenZWave/#" + duration: + description: how long we should listen for messages in seconds + example: 5 + default: 5 diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8bacfa530bdb42..f0a38bcbc55606 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -27,5 +27,27 @@ "error": { "cannot_connect": "Unable to connect to the broker." } + }, + "device_automation": { + "trigger_type": { + "button_short_press": "\"{subtype}\" pressed", + "button_short_release": "\"{subtype}\" released", + "button_long_press": "\"{subtype}\" continuously pressed", + "button_long_release": "\"{subtype}\" released after long press", + "button_double_press": "\"{subtype}\" double clicked", + "button_triple_press": "\"{subtype}\" triple clicked", + "button_quadruple_press": "\"{subtype}\" quadruple clicked", + "button_quintuple_press": "\"{subtype}\" quintuple clicked" + }, + "trigger_subtype": { + "turn_on": "Turn on", + "turn_off": "Turn off", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button" + } } } diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 3c35434be86db9..32066c67b7aca2 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -76,15 +76,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT switch.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -93,13 +92,12 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT switch.""" - async_add_entities([MqttSwitch(config, config_entry, discovery_hash)]) + async_add_entities([MqttSwitch(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttSwitch( MqttAttributes, MqttAvailability, @@ -110,7 +108,7 @@ class MqttSwitch( ): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT switch.""" self._state = False self._sub_state = None @@ -127,7 +125,7 @@ def __init__(self, config, config_entry, discovery_hash): MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -204,6 +202,7 @@ async def async_will_remove_from_hass(self): ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 84f564e5c7ea16..b16ec7aaf74eee 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -1,9 +1,4 @@ -""" -Support for MQTT vacuums. - -For more details about this platform, please refer to the documentation at -https://www.home-assistant.io/components/vacuum.mqtt/ -""" +"""Support for MQTT vacuums.""" import logging import voluptuous as vol @@ -44,15 +39,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT vacuum.""" + discovery_data = discovery_payload.discovery_data 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 + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -61,10 +55,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash=None + config, async_add_entities, config_entry, discovery_data=None ): """Set up the MQTT vacuum.""" setup_entity = {LEGACY: async_setup_entity_legacy, STATE: async_setup_entity_state} await setup_entity[config[CONF_SCHEMA]]( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 6c08b18bc9c399..eff7cc1b039888 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -162,13 +162,12 @@ async def async_setup_entity_legacy( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ): """Set up a MQTT Vacuum Legacy.""" - async_add_entities([MqttVacuum(config, config_entry, discovery_hash)]) + async_add_entities([MqttVacuum(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttVacuum( MqttAttributes, MqttAvailability, @@ -270,6 +269,7 @@ async def async_will_remove_from_hass(self): 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) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 9dd5053d019277..f9bcc7e845efc2 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -157,13 +157,12 @@ async def async_setup_entity_state( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ): """Set up a State MQTT Vacuum.""" - async_add_entities([MqttStateVacuum(config, config_entry, discovery_hash)]) + async_add_entities([MqttStateVacuum(config, config_entry, discovery_data)]) -# pylint: disable=too-many-ancestors class MqttStateVacuum( MqttAttributes, MqttAvailability, @@ -235,6 +234,7 @@ async def async_will_remove_from_hass(self): 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) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index da1db0e02aa80b..2ceca024a6f1ea 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -131,7 +131,7 @@ def icon(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES def update(self): """Get the latest data and update the state.""" diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 19eb8e9e92c2e6..b25cf977d83bfe 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -11,6 +11,7 @@ Light, ) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import callback import homeassistant.util.color as color_util from homeassistant.util.color import rgb_hex_to_rgb_list @@ -150,11 +151,13 @@ async def async_turn_off(self, **kwargs): self._values[value_type] = STATE_OFF self.async_schedule_update_ha_state() + @callback def _async_update_light(self): """Update the controller with values from light child.""" value_type = self.gateway.const.SetReq.V_LIGHT self._state = self._values[value_type] == STATE_ON + @callback def _async_update_dimmer(self): """Update the controller with values from dimmer child.""" value_type = self.gateway.const.SetReq.V_DIMMER @@ -163,6 +166,7 @@ def _async_update_dimmer(self): if self._brightness == 0: self._state = False + @callback def _async_update_rgb_or_w(self): """Update the controller with values from RGB or RGBW child.""" value = self._values[self.value_type] @@ -224,8 +228,6 @@ async def async_update(self): class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" - # pylint: disable=too-many-ancestors - @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index ddad451d20fded..2b8cf208c148b3 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -3,6 +3,7 @@ from homeassistant.components.sensor import DOMAIN from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, + MASS_KILOGRAMS, POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -20,7 +21,7 @@ "V_WIND": [None, "mdi:weather-windy"], "V_GUST": [None, "mdi:weather-windy"], "V_DIRECTION": ["°", "mdi:compass"], - "V_WEIGHT": ["kg", "mdi:weight-kilogram"], + "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram"], "V_DISTANCE": ["m", "mdi:ruler"], "V_IMPEDANCE": ["ohm", None], "V_WATT": [POWER_WATT, None], diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py index ff0063a380ed7d..3da77d6d943b21 100644 --- a/homeassistant/components/mystrom/binary_sensor.py +++ b/homeassistant/components/mystrom/binary_sensor.py @@ -4,6 +4,7 @@ from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.components.http import HomeAssistantView from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY +from homeassistant.core import callback _LOGGER = logging.getLogger(__name__) @@ -81,6 +82,7 @@ def is_on(self): """Return true if the binary sensor is on.""" return self._state + @callback def async_on_update(self, value): """Receive an update.""" self._state = value diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 3a045e0391dc63..ca766810a3d62d 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -5,6 +5,7 @@ from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv DEFAULT_NAME = "myStrom Switch" @@ -30,7 +31,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): MyStromPlug(host).get_status() except exceptions.MyStromConnectionError: _LOGGER.error("No route to device: %s", host) - return + raise PlatformNotReady() add_entities([MyStromSwitch(name, host)]) @@ -46,7 +47,7 @@ def __init__(self, name, resource): self._resource = resource self.data = {} self.plug = MyStromPlug(self._resource) - self.update() + self._available = True @property def name(self): @@ -63,6 +64,11 @@ def current_power_w(self): """Return the current power consumption in W.""" return round(self.data["power"], 2) + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + def turn_on(self, **kwargs): """Turn the switch on.""" from pymystrom import exceptions @@ -87,6 +93,8 @@ def update(self): try: self.data = self.plug.get_status() + self._available = True except exceptions.MyStromConnectionError: self.data = {"power": 0, "relay": False} + self._available = False _LOGGER.error("No route to device: %s", self._resource) diff --git a/homeassistant/components/neato/.translations/hu.json b/homeassistant/components/neato/.translations/hu.json new file mode 100644 index 00000000000000..50fa4b5866f2bc --- /dev/null +++ b/homeassistant/components/neato/.translations/hu.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "M\u00e1r konfigur\u00e1lva van", + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, + "create_entry": { + "default": "L\u00e1sd: [Neato dokument\u00e1ci\u00f3] ( {docs_url} )." + }, + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok", + "unexpected_error": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "vendor": "Sz\u00e1ll\u00edt\u00f3" + }, + "description": "L\u00e1sd: [Neato dokument\u00e1ci\u00f3] ( {docs_url} ).", + "title": "Neato Fi\u00f3kinform\u00e1ci\u00f3" + } + }, + "title": "Neato" + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/.translations/pl.json b/homeassistant/components/neato/.translations/pl.json index caea115b7d5101..e6b55b12c5360a 100644 --- a/homeassistant/components/neato/.translations/pl.json +++ b/homeassistant/components/neato/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane", + "already_configured": "Konto jest ju\u017c skonfigurowane.", "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" }, "create_entry": { diff --git a/homeassistant/components/neato/.translations/sv.json b/homeassistant/components/neato/.translations/sv.json new file mode 100644 index 00000000000000..64edf9e93cee2e --- /dev/null +++ b/homeassistant/components/neato/.translations/sv.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Redan konfigurerad", + "invalid_credentials": "Ogiltiga autentiseringsuppgifter" + }, + "create_entry": { + "default": "Se [Neato-dokumentation]({docs_url})." + }, + "error": { + "invalid_credentials": "Ogiltiga autentiseringsuppgifter", + "unexpected_error": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn", + "vendor": "Leverant\u00f6r" + }, + "description": "Se [Neato-dokumentation] ({docs_url}).", + "title": "Neato-kontoinfo" + } + }, + "title": "Neato" + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 56fba9047e7a9a..88a2085b33968f 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -12,7 +12,7 @@ # pylint: disable=unused-import from .const import CONF_VENDOR, NEATO_DOMAIN, VALID_VENDORS -DOCS_URL = "https://www.home-assistant.io/components/neato" +DOCS_URL = "https://www.home-assistant.io/integrations/neato" DEFAULT_VENDOR = "neato" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 92231bd460cbca..c6025abe0b52c2 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -2,7 +2,7 @@ "domain": "nederlandse_spoorwegen", "name": "Nederlandse Spoorwegen (NS)", "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", - "requirements": ["nsapi==3.0.0"], + "requirements": ["nsapi==3.0.3"], "dependencies": [], "codeowners": ["@YarmoM"] } diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 5477aaf0e2baf4..45413d4a15aae9 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -20,6 +20,7 @@ CONF_FROM = "from" CONF_TO = "to" CONF_VIA = "via" +CONF_TIME = "time" ICON = "mdi:train" @@ -31,6 +32,7 @@ vol.Required(CONF_FROM): cv.string, vol.Required(CONF_TO): cv.string, vol.Optional(CONF_VIA): cv.string, + vol.Optional(CONF_TIME): cv.time, } ) @@ -51,7 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): requests.exceptions.ConnectionError, requests.exceptions.HTTPError, ) as error: - _LOGGER.error("Couldn't fetch stations, API password correct?: %s", error) + _LOGGER.error("Couldn't fetch stations, API key correct?: %s", error) return sensors = [] @@ -68,6 +70,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): departure.get(CONF_FROM), departure.get(CONF_TO), departure.get(CONF_VIA), + departure.get(CONF_TIME), ) ) if sensors: @@ -88,13 +91,14 @@ def valid_stations(stations, given_stations): class NSDepartureSensor(Entity): """Implementation of a NS Departure Sensor.""" - def __init__(self, nsapi, name, departure, heading, via): + def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" self._nsapi = nsapi self._name = name self._departure = departure self._via = via self._heading = heading + self._time = time self._state = None self._trips = None @@ -127,21 +131,18 @@ def device_state_attributes(self): # Static attributes attributes = { "going": self._trips[0].going, - "departure_time_planned": self._trips[0].departure_time_planned.strftime( - "%H:%M" - ), + "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": None, - "arrival_time_planned": self._trips[0].arrival_time_planned.strftime( - "%H:%M" - ), + "departure_platform_actual": self._trips[0].departure_platform_actual, + "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_platform": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": None, + "arrival_platform_planned": self._trips[0].arrival_platform_planned, + "arrival_platform_actual": self._trips[0].arrival_platform_actual, "next": None, + "punctuality": None, "status": self._trips[0].status.lower(), "transfers": self._trips[0].nr_transfers, "route": route, @@ -149,46 +150,90 @@ def device_state_attributes(self): ATTR_ATTRIBUTION: ATTRIBUTION, } - # Departure attributes + # Planned departure attributes + if self._trips[0].departure_time_planned is not None: + attributes["departure_time_planned"] = self._trips[ + 0 + ].departure_time_planned.strftime("%H:%M") + + # Actual departure attributes if self._trips[0].departure_time_actual is not None: attributes["departure_time_actual"] = self._trips[ 0 ].departure_time_actual.strftime("%H:%M") + + # Delay departure attributes + if ( + attributes["departure_time_planned"] + and attributes["departure_time_actual"] + and attributes["departure_time_planned"] + != attributes["departure_time_actual"] + ): attributes["departure_delay"] = True - attributes["departure_platform_actual"] = self._trips[ + + # Planned arrival attributes + if self._trips[0].arrival_time_planned is not None: + attributes["arrival_time_planned"] = self._trips[ 0 - ].departure_platform_actual + ].arrival_time_planned.strftime("%H:%M") - # Arrival attributes + # Actual arrival attributes if self._trips[0].arrival_time_actual is not None: attributes["arrival_time_actual"] = self._trips[ 0 ].arrival_time_actual.strftime("%H:%M") + + # Delay arrival attributes + if ( + attributes["arrival_time_planned"] + and attributes["arrival_time_actual"] + and attributes["arrival_time_planned"] != attributes["arrival_time_actual"] + ): attributes["arrival_delay"] = True - attributes["arrival_platform_actual"] = self._trips[ - 0 - ].arrival_platform_actual + + # Punctuality attributes + if self._trips[0].punctuality is not None: + attributes["punctuality"] = self._trips[0].punctuality # Next attributes - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime("%H:%M") - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime("%H:%M") + if len(self._trips) > 1: + if self._trips[1].departure_time_actual is not None: + attributes["next"] = self._trips[1].departure_time_actual.strftime( + "%H:%M" + ) + elif self._trips[1].departure_time_planned is not None: + attributes["next"] = self._trips[1].departure_time_planned.strftime( + "%H:%M" + ) return attributes @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the trip information.""" + + # If looking for a specific trip time, update around that trip time only. + if self._time and ( + (datetime.now() + timedelta(minutes=30)).time() < self._time + or (datetime.now() - timedelta(minutes=30)).time() > self._time + ): + self._state = None + self._trips = None + return + + # Set the search parameter to search from a specific trip time or to just search for next trip. + if self._time: + trip_time = ( + datetime.today() + .replace(hour=self._time.hour, minute=self._time.minute) + .strftime("%d-%m-%Y %H:%M") + ) + else: + trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + try: self._trips = self._nsapi.get_trips( - datetime.now().strftime("%d-%m-%Y %H:%M"), - self._departure, - self._via, - self._heading, - True, - 0, - 2, + trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: if self._trips[0].departure_time_actual is None: diff --git a/homeassistant/components/nest/.translations/zh-Hans.json b/homeassistant/components/nest/.translations/zh-Hans.json index 0b5cbc989fd263..0825fdfdc79936 100644 --- a/homeassistant/components/nest/.translations/zh-Hans.json +++ b/homeassistant/components/nest/.translations/zh-Hans.json @@ -8,14 +8,14 @@ }, "error": { "internal_error": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u5185\u90e8\u9519\u8bef", - "invalid_code": "\u65e0\u6548\u4ee3\u7801", - "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6", - "unknown": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef" + "invalid_code": "\u9a8c\u8bc1\u7801\u65e0\u6548", + "timeout": "\u9a8c\u8bc1\u7801\u8d85\u65f6", + "unknown": "\u9a8c\u8bc1\u7801\u672a\u77e5\u9519\u8bef" }, "step": { "init": { "data": { - "flow_impl": "\u63d0\u4f9b\u8005" + "flow_impl": "\u8ba4\u8bc1\u63d0\u4f9b\u8005" }, "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Nest \u8fdb\u884c\u6388\u6743\u3002", "title": "\u6388\u6743\u63d0\u4f9b\u8005" diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py index 05170a54ed1b7a..a029fcfe7d65fc 100644 --- a/homeassistant/components/nest/binary_sensor.py +++ b/homeassistant/components/nest/binary_sensor.py @@ -71,7 +71,7 @@ async def async_setup_entry(hass, entry, async_add_entities): wstr = ( variable + " is no a longer supported " "monitored_conditions. See " - "https://home-assistant.io/components/binary_sensor.nest/ " + "https://www.home-assistant.io/integrations/binary_sensor.nest/ " "for valid options." ) _LOGGER.error(wstr) diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 6b1a198abbbdeb..d52df4c6586210 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -90,14 +90,14 @@ async def async_setup_entry(hass, entry, async_add_entities): if variable in DEPRECATED_WEATHER_VARS: wstr = ( "Nest no longer provides weather data like %s. See " - "https://home-assistant.io/components/#weather " + "https://www.home-assistant.io/integrations/#weather " "for a list of other weather integrations to use." % variable ) else: wstr = ( variable + " is no a longer supported " "monitored_conditions. See " - "https://home-assistant.io/components/" + "https://www.home-assistant.io/integrations/" "binary_sensor.nest/ for valid options." ) _LOGGER.error(wstr) diff --git a/homeassistant/components/netatmo/.translations/ca.json b/homeassistant/components/netatmo/.translations/ca.json index 6961db6f520ee3..63de8699f35632 100644 --- a/homeassistant/components/netatmo/.translations/ca.json +++ b/homeassistant/components/netatmo/.translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte Netatmo.", - "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3." + "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", + "missing_configuration": "El component Netatmo no est\u00e0 configurat. Mira'n la documentaci\u00f3." }, "create_entry": { "default": "Autenticaci\u00f3 exitosa amb Netatmo." diff --git a/homeassistant/components/netatmo/.translations/cs.json b/homeassistant/components/netatmo/.translations/cs.json new file mode 100644 index 00000000000000..bab99c321249c6 --- /dev/null +++ b/homeassistant/components/netatmo/.translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/.translations/hu.json b/homeassistant/components/netatmo/.translations/hu.json new file mode 100644 index 00000000000000..9994e527f0124b --- /dev/null +++ b/homeassistant/components/netatmo/.translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_setup": "Csak egy Netatmo-fi\u00f3kot \u00e1ll\u00edthatsz be.", + "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n." + }, + "create_entry": { + "default": "A Netatmo sikeresen hiteles\u00edtett." + }, + "step": { + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9si m\u00f3dszert" + } + }, + "title": "Netatmo" + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/.translations/nl.json b/homeassistant/components/netatmo/.translations/nl.json index d9062850f2ae0e..5f5fe375117521 100644 --- a/homeassistant/components/netatmo/.translations/nl.json +++ b/homeassistant/components/netatmo/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n Netatmo account configureren.", "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", "missing_configuration": "De Netatmo-component is niet geconfigureerd. Gelieve de documentatie volgen." }, diff --git a/homeassistant/components/netatmo/.translations/sl.json b/homeassistant/components/netatmo/.translations/sl.json new file mode 100644 index 00000000000000..5288c84e44bf84 --- /dev/null +++ b/homeassistant/components/netatmo/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Konfigurirate lahko samo en ra\u010dun Netatmo.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "missing_configuration": "Komponenta Netatmo ni konfigurirana. Prosimo, upo\u0161tevajte dokumentacijo." + }, + "create_entry": { + "default": "Uspe\u0161no overjeno z Netatmo." + }, + "step": { + "pick_implementation": { + "title": "Izberite na\u010din preverjanja pristnosti" + } + }, + "title": "Netatmo" + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/.translations/sv.json b/homeassistant/components/netatmo/.translations/sv.json new file mode 100644 index 00000000000000..2047bce5b17248 --- /dev/null +++ b/homeassistant/components/netatmo/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan endast konfigurera ett Netatmo-konto.", + "authorize_url_timeout": "Timeout vid generering av en auktoriserings-URL.", + "missing_configuration": "Netatmo-komponenten har inte konfigurerats. F\u00f6lj dokumentationen." + }, + "create_entry": { + "default": "Autentiserad med Netatmo." + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + } + }, + "title": "Netatmo" + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index ace12d3838cada..bd79f597b5b41c 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -5,7 +5,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DISCOVERY, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv @@ -14,12 +19,19 @@ _LOGGER = logging.getLogger(__name__) +CONF_SECRET_KEY = "secret_key" +CONF_WEBHOOKS = "webhooks" + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, + cv.deprecated(CONF_SECRET_KEY): cv.match_all, + cv.deprecated(CONF_USERNAME): cv.match_all, + cv.deprecated(CONF_WEBHOOKS): cv.match_all, + cv.deprecated(CONF_DISCOVERY): cv.match_all, } ) }, diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index 6d0de6dcceb8b2..5f419bda2c2516 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -24,7 +24,10 @@ } TAG_SENSOR_TYPES = {"Tag Vibration": "vibration", "Tag Open": "opening"} -SENSOR_TYPES = {"NACamera": WELCOME_SENSOR_TYPES, "NOC": PRESENCE_SENSOR_TYPES} +SENSOR_TYPES = { + "NACamera": WELCOME_SENSOR_TYPES, + "NOC": PRESENCE_SENSOR_TYPES, +} CONF_HOME = "home" CONF_CAMERAS = "cameras" @@ -61,12 +64,28 @@ def get_camera_home_id(data, camera_id): sensor_types.update(SENSOR_TYPES[camera["type"]]) # Tags are only supported with Netatmo Welcome indoor cameras - if camera["type"] == "NACamera" and data.get_modules(camera["id"]): - sensor_types.update(TAG_SENSOR_TYPES) - - for sensor_name in sensor_types: + modules = data.get_modules(camera["id"]) + if camera["type"] == "NACamera" and modules: + for module in modules: + for sensor_type in TAG_SENSOR_TYPES: + _LOGGER.debug( + "Adding camera tag %s (%s)", + module["name"], + module["id"], + ) + entities.append( + NetatmoBinarySensor( + data, + camera["id"], + home_id, + sensor_type, + module["id"], + ) + ) + + for sensor_type in sensor_types: entities.append( - NetatmoBinarySensor(data, camera["id"], home_id, sensor_name) + NetatmoBinarySensor(data, camera["id"], home_id, sensor_type) ) except pyatmo.NoDevice: _LOGGER.debug("No camera entities to add") @@ -115,6 +134,15 @@ def unique_id(self): """Return the unique ID for this sensor.""" return self._unique_id + @property + def device_class(self): + """Return the class of this sensor.""" + if self._camera_type == "NACamera": + return WELCOME_SENSOR_TYPES.get(self._sensor_type) + if self._camera_type == "NOC": + return PRESENCE_SENSOR_TYPES.get(self._sensor_type) + return TAG_SENSOR_TYPES.get(self._sensor_type) + @property def device_info(self): """Return the device info for the sensor.""" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 8f59382dd46923..dce87fb7931363 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -25,24 +25,22 @@ def logger(self) -> logging.Logger: @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - return { - "scope": ( - " ".join( - [ - "read_station", - "read_camera", - "access_camera", - "write_camera", - "read_presence", - "access_presence", - "read_homecoach", - "read_smokedetector", - "read_thermostat", - "write_thermostat", - ] - ) - ) - } + scopes = [ + "read_camera", + "read_homecoach", + "read_presence", + "read_smokedetector", + "read_station", + "read_thermostat", + "write_camera", + "write_thermostat", + ] + + if self.flow_impl.name != "Home Assistant Cloud": + scopes.extend(["access_camera", "access_presence"]) + scopes.sort() + + return {"scope": " ".join(scopes)} async def async_step_user(self, user_input=None): """Handle a flow start.""" diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 14ec2e61b9c7cc..6fe084cc885c6a 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==3.2.2" + "pyatmo==3.2.4" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 82c3748d19b950..9254f2f45abd29 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,14 +1,15 @@ """Support for the Netatmo Weather Service.""" from datetime import timedelta import logging -from time import time import pyatmo from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.helpers.entity import Entity @@ -53,7 +54,7 @@ "mdi:thermometer", DEVICE_CLASS_TEMPERATURE, ], - "co2": ["CO2", "ppm", "mdi:periodic-table-co2", None], + "co2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:periodic-table-co2", None], "pressure": ["Pressure", "mbar", "mdi:gauge", None], "noise": ["Noise", "dB", "mdi:volume-high", None], "humidity": ["Humidity", "%", "mdi:water-percent", DEVICE_CLASS_HUMIDITY], @@ -67,10 +68,20 @@ "max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "windangle": ["Angle", "", "mdi:compass", None], "windangle_value": ["Angle Value", "º", "mdi:compass", None], - "windstrength": ["Wind Strength", "km/h", "mdi:weather-windy", None], + "windstrength": [ + "Wind Strength", + SPEED_KILOMETERS_PER_HOUR, + "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], + "guststrength": [ + "Gust Strength", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], "reachable": ["Reachability", "", "mdi:signal", None], "rf_status": ["Radio", "", "mdi:signal", None], "rf_status_lvl": ["Radio_lvl", "", "mdi:signal", None], @@ -217,7 +228,7 @@ def update(self): if data is None: _LOGGER.info("No data found for %s (%s)", self.module_name, self._module_id) - _LOGGER.error("data: %s", self.netatmo_data.data) + _LOGGER.debug("data: %s", self.netatmo_data.data) self._state = None return @@ -519,7 +530,6 @@ def __init__(self, auth, station_data): """Initialize the data object.""" self.data = {} self.station_data = station_data - self._next_update = time() self.auth = auth def get_module_infos(self): diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index 23b1034a5b3a3a..5f18553ba621d9 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -1,5 +1,6 @@ """Support for Netgear routers.""" import logging +from pprint import pformat from pynetgear import Netgear import voluptuous as vol @@ -30,7 +31,7 @@ vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_USERNAME, default=""): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=None): vol.Any(None, cv.port), + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_APS, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -41,55 +42,43 @@ def get_scanner(hass, config): """Validate the configuration and returns a Netgear scanner.""" info = config[DOMAIN] - host = info.get(CONF_HOST) - ssl = info.get(CONF_SSL) - username = info.get(CONF_USERNAME) - password = info.get(CONF_PASSWORD) + host = info[CONF_HOST] + ssl = info[CONF_SSL] + username = info[CONF_USERNAME] + password = info[CONF_PASSWORD] port = info.get(CONF_PORT) - devices = info.get(CONF_DEVICES) - excluded_devices = info.get(CONF_EXCLUDE) - accesspoints = info.get(CONF_APS) + devices = info[CONF_DEVICES] + excluded_devices = info[CONF_EXCLUDE] + accesspoints = info[CONF_APS] - scanner = NetgearDeviceScanner( - host, ssl, username, password, port, devices, excluded_devices, accesspoints - ) + api = Netgear(password, host, username, port, ssl) + scanner = NetgearDeviceScanner(api, devices, excluded_devices, accesspoints) - return scanner if scanner.success_init else None + _LOGGER.debug("Logging in") + + results = scanner.get_attached_devices() + + if results is not None: + scanner.last_results = results + else: + _LOGGER.error("Failed to Login") + return None + + return scanner class NetgearDeviceScanner(DeviceScanner): """Queries a Netgear wireless router using the SOAP-API.""" def __init__( - self, - host, - ssl, - username, - password, - port, - devices, - excluded_devices, - accesspoints, + self, api, devices, excluded_devices, accesspoints, ): """Initialize the scanner.""" - self.tracked_devices = devices self.excluded_devices = excluded_devices self.tracked_accesspoints = accesspoints - self.last_results = [] - self._api = Netgear(password, host, username, port, ssl) - - _LOGGER.info("Logging in") - - results = self.get_attached_devices() - - self.success_init = results is not None - - if self.success_init: - self.last_results = results - else: - _LOGGER.error("Failed to Login") + self._api = api def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -110,10 +99,7 @@ def scan_devices(self): or dev.name in self.excluded_devices ) ) - - # when link_rate is None this means the router still knows about - # the device, but it is not in range. - if tracked and dev.link_rate is not None: + if tracked: devices.append(dev.mac) if ( self.tracked_accesspoints @@ -156,21 +142,20 @@ def _update_info(self): Returns boolean if scanning successful. """ - if not self.success_init: - return - - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") results = self.get_attached_devices() + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Scan result: \n%s", pformat(results)) + if results is None: _LOGGER.warning("Error scanning devices") self.last_results = results or [] def get_attached_devices(self): - """ - List attached devices with pynetgear. + """List attached devices with pynetgear. The v2 method takes more time and is more heavy on the router so we only use it if we need connected AP info. diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index 4cfffab7d73e71..c5685411045af6 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -1,6 +1,6 @@ { "domain": "netgear", - "name": "Netgear", + "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", "requirements": ["pynetgear==0.6.1"], "dependencies": [], diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index 0be2ca146b1d5b..43cf6e34480c74 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -1,6 +1,6 @@ { "domain": "netgear_lte", - "name": "Netgear LTE", + "name": "NETGEAR LTE", "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "requirements": ["eternalegypt==0.0.11"], "dependencies": [], diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py index e1a9d1a23d2114..a744937dacd5a8 100644 --- a/homeassistant/components/netgear_lte/sensor_types.py +++ b/homeassistant/components/netgear_lte/sensor_types.py @@ -1,6 +1,7 @@ """Define possible sensor types.""" from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.const import DATA_MEBIBYTES SENSOR_SMS = "sms" SENSOR_SMS_TOTAL = "sms_total" @@ -9,7 +10,7 @@ SENSOR_UNITS = { SENSOR_SMS: "unread", SENSOR_SMS_TOTAL: "messages", - SENSOR_USAGE: "MiB", + SENSOR_USAGE: DATA_MEBIBYTES, "radio_quality": "%", "rx_level": "dBm", "tx_level": "dBm", diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index a91ff511b0773a..dfa43c359529dc 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -11,6 +11,7 @@ ATTR_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -80,7 +81,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ] if station_live is not None: - sensors.append(NMBSLiveBoard(api_client, station_live)) + sensors.append( + NMBSLiveBoard(api_client, station_live, station_from, station_to) + ) add_entities(sensors, True) @@ -88,23 +91,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class NMBSLiveBoard(Entity): """Get the next train from a station's liveboard.""" - def __init__(self, api_client, live_station): + def __init__(self, api_client, live_station, station_from, station_to): """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client - self._unique_id = f"nmbs_live_{self._station}" + self._station_from = station_from + self._station_to = station_to self._attrs = {} self._state = None @property def name(self): """Return the sensor default name.""" - return "NMBS Live" + return f"NMBS Live ({self._station})" @property def unique_id(self): """Return a unique ID.""" - return self._unique_id + unique_id = f"{self._station}_{self._station_from}_{self._station_to}" + + return f"nmbs_live_{unique_id}" @property def icon(self): @@ -179,7 +185,7 @@ def name(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 8211fdc08281b9..1ea0b9aa6d552e 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -177,10 +177,9 @@ def send_message(self, message, **kwargs): """ raise NotImplementedError() - def async_send_message(self, message, **kwargs): + async def async_send_message(self, message, **kwargs): """Send a message. kwargs can contain ATTR_TITLE to specify a title. - This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(partial(self.send_message, message, **kwargs)) + await self.hass.async_add_job(partial(self.send_message, message, **kwargs)) diff --git a/homeassistant/components/notion/.translations/ca.json b/homeassistant/components/notion/.translations/ca.json index 0b6a24626be2f4..09f598ef5d1a1f 100644 --- a/homeassistant/components/notion/.translations/ca.json +++ b/homeassistant/components/notion/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquest nom d'usuari ja est\u00e0 en \u00fas." + }, "error": { "identifier_exists": "Nom d'usuari ja registrat", "invalid_credentials": "Nom d'usuari o contrasenya incorrectes", diff --git a/homeassistant/components/notion/.translations/da.json b/homeassistant/components/notion/.translations/da.json index bf17b41d777c44..784d106b94c737 100644 --- a/homeassistant/components/notion/.translations/da.json +++ b/homeassistant/components/notion/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dette brugernavn er allerede i brug." + }, "error": { "identifier_exists": "Brugernavn er allerede registreret", "invalid_credentials": "Ugyldigt brugernavn eller adgangskode", diff --git a/homeassistant/components/notion/.translations/de.json b/homeassistant/components/notion/.translations/de.json index e9c735001e9057..e11a16458c979d 100644 --- a/homeassistant/components/notion/.translations/de.json +++ b/homeassistant/components/notion/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser Benutzername wird bereits benutzt." + }, "error": { "identifier_exists": "Benutzername bereits registriert", "invalid_credentials": "Ung\u00fcltiger Benutzername oder Passwort", diff --git a/homeassistant/components/notion/.translations/en.json b/homeassistant/components/notion/.translations/en.json index b05f613a73ffa6..2476293a21650b 100644 --- a/homeassistant/components/notion/.translations/en.json +++ b/homeassistant/components/notion/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This username is already in use." + }, "error": { "identifier_exists": "Username already registered", "invalid_credentials": "Invalid username or password", diff --git a/homeassistant/components/notion/.translations/ko.json b/homeassistant/components/notion/.translations/ko.json index 76dc91cf46b0a1..52c7b6339cb077 100644 --- a/homeassistant/components/notion/.translations/ko.json +++ b/homeassistant/components/notion/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", diff --git a/homeassistant/components/notion/.translations/no.json b/homeassistant/components/notion/.translations/no.json index 2798db1cbc3cc8..16105e680c592b 100644 --- a/homeassistant/components/notion/.translations/no.json +++ b/homeassistant/components/notion/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dette brukernavnet er allerede i bruk." + }, "error": { "identifier_exists": "Brukernavn er allerede registrert", "invalid_credentials": "Ugyldig brukernavn eller passord", diff --git a/homeassistant/components/notion/.translations/pl.json b/homeassistant/components/notion/.translations/pl.json index 380d4ad151e6d6..07facb21e93d2e 100644 --- a/homeassistant/components/notion/.translations/pl.json +++ b/homeassistant/components/notion/.translations/pl.json @@ -1,7 +1,10 @@ { "config": { + "abort": { + "already_configured": "Ta nazwa u\u017cytkownika jest ju\u017c w u\u017cyciu." + }, "error": { - "identifier_exists": "Nazwa u\u017cytkownika ju\u017c zarejestrowana", + "identifier_exists": "Nazwa u\u017cytkownika jest ju\u017c zarejestrowana.", "invalid_credentials": "Nieprawid\u0142owa nazwa u\u017cytkownika lub has\u0142o", "no_devices": "Nie znaleziono urz\u0105dze\u0144 na koncie" }, @@ -9,11 +12,11 @@ "user": { "data": { "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika / adres e-mail" + "username": "Nazwa u\u017cytkownika/adres e-mail" }, "title": "Wprowad\u017a dane" } }, - "title": "Poj\u0119cie" + "title": "Notion" } } \ No newline at end of file diff --git a/homeassistant/components/notion/.translations/zh-Hant.json b/homeassistant/components/notion/.translations/zh-Hant.json index f672f519f408de..c426dfa32652dd 100644 --- a/homeassistant/components/notion/.translations/zh-Hant.json +++ b/homeassistant/components/notion/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64\u4f7f\u7528\u8005\u540d\u7a31\u5df2\u88ab\u4f7f\u7528\u3002" + }, "error": { "identifier_exists": "\u4f7f\u7528\u8005\u540d\u7a31\u5df2\u8a3b\u518a", "invalid_credentials": "\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc\u7121\u6548", diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 1e04c4a8e8effa..f387e82025380d 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -22,7 +22,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from .config_flow import configured_instances from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_DATA_UPDATE _LOGGER = logging.getLogger(__name__) @@ -84,9 +83,6 @@ async def async_setup(hass, config): conf = config[DOMAIN] - if conf[CONF_USERNAME] in configured_instances(hass): - return True - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -103,6 +99,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up Notion as a config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_USERNAME] + ) + session = aiohttp_client.async_get_clientsession(hass) try: diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 2af231d582e4d8..58c5c0d44eea62 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -5,35 +5,27 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DOMAIN +from .const import DOMAIN # pylint: disable=unused-import -@callback -def configured_instances(hass): - """Return a set of configured Notion instances.""" - return set( - entry.data[CONF_USERNAME] for entry in hass.config_entries.async_entries(DOMAIN) - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class NotionFlowHandler(config_entries.ConfigFlow): +class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Notion config flow.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - async def _show_form(self, errors=None): - """Show the form to the user.""" - data_schema = vol.Schema( + def __init__(self): + """Initialize the config flow.""" + self.data_schema = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) + async def _show_form(self, errors=None): + """Show the form to the user.""" return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors or {} + step_id="user", data_schema=self.data_schema, errors=errors or {} ) async def async_step_import(self, import_config): @@ -42,12 +34,11 @@ async def async_step_import(self, import_config): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_USERNAME] in configured_instances(self.hass): - return await self._show_form({CONF_USERNAME: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 2f81cb72ac0c57..6ce5c4e5bc7199 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -7,7 +7,7 @@ DATA_CLIENT = "client" -TOPIC_DATA_UPDATE = "data_update" +TOPIC_DATA_UPDATE = f"{DOMAIN}_data_update" TYPE_BINARY_SENSOR = "binary_sensor" TYPE_SENSOR = "sensor" diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 8825e25bfe841a..fa47c2819ba036 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -11,9 +11,11 @@ } }, "error": { - "identifier_exists": "Username already registered", "invalid_credentials": "Invalid username or password", "no_devices": "No devices found in account" + }, + "abort": { + "already_configured": "This username is already in use." } } } diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py index a04d2bd69b2134..f0d8c9013875af 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py +++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py @@ -18,13 +18,13 @@ EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback -from homeassistant.helpers import ConfigType, aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index b2dcfe10cf62ad..1c2aa268ca2853 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -2,7 +2,7 @@ "domain": "nsw_rural_fire_service_feed", "name": "NSW Rural Fire Service Incidents", "documentation": "https://www.home-assistant.io/integrations/nsw_rural_fire_service_feed", - "requirements": ["aio_geojson_nsw_rfs_incidents==0.1"], + "requirements": ["aio_geojson_nsw_rfs_incidents==0.3"], "dependencies": [], "codeowners": ["@exxamalte"] } diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 7fda26b290041d..943dbc02fbf193 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -77,26 +77,28 @@ class NukiLock(LockDevice): def __init__(self, nuki_lock): """Initialize the lock.""" self._nuki_lock = nuki_lock - self._locked = nuki_lock.is_locked - self._name = nuki_lock.name - self._battery_critical = nuki_lock.battery_critical self._available = nuki_lock.state not in ERROR_STATES @property def name(self): """Return the name of the lock.""" - return self._name + return self._nuki_lock.name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._nuki_lock.nuki_id @property def is_locked(self): """Return true if lock is locked.""" - return self._locked + return self._nuki_lock.is_locked @property def device_state_attributes(self): """Return the device specific state attributes.""" data = { - ATTR_BATTERY_CRITICAL: self._battery_critical, + ATTR_BATTERY_CRITICAL: self._nuki_lock.battery_critical, ATTR_NUKI_ID: self._nuki_lock.nuki_id, } return data @@ -119,17 +121,13 @@ def update(self): except RequestException: _LOGGER.warning("Network issues detect with %s", self.name) self._available = False - return + continue # If in error state, we force an update and repoll data self._available = self._nuki_lock.state not in ERROR_STATES if self._available: break - self._name = self._nuki_lock.name - self._locked = self._nuki_lock.is_locked - self._battery_critical = self._nuki_lock.battery_critical - def lock(self, **kwargs): """Lock the device.""" self._nuki_lock.lock() diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index bdf0eaafc99706..96db220f5eaecb 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -18,6 +18,7 @@ POWER_WATT, STATE_UNKNOWN, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -53,13 +54,13 @@ "ups.load": ["Load", "%", "mdi:gauge"], "ups.load.high": ["Overload Setting", "%", "mdi:gauge"], "ups.id": ["System identifier", "", "mdi:information-outline"], - "ups.delay.start": ["Load Restart Delay", "s", "mdi:timer"], - "ups.delay.reboot": ["UPS Reboot Delay", "s", "mdi:timer"], - "ups.delay.shutdown": ["UPS Shutdown Delay", "s", "mdi:timer"], - "ups.timer.start": ["Load Start Timer", "s", "mdi:timer"], - "ups.timer.reboot": ["Load Reboot Timer", "s", "mdi:timer"], - "ups.timer.shutdown": ["Load Shutdown Timer", "s", "mdi:timer"], - "ups.test.interval": ["Self-Test Interval", "s", "mdi:timer"], + "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer"], + "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer"], + "ups.delay.shutdown": ["UPS Shutdown Delay", TIME_SECONDS, "mdi:timer"], + "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer"], + "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer"], + "ups.timer.shutdown": ["Load Shutdown Timer", TIME_SECONDS, "mdi:timer"], + "ups.test.interval": ["Self-Test Interval", TIME_SECONDS, "mdi:timer"], "ups.test.result": ["Self-Test Result", "", "mdi:information-outline"], "ups.test.date": ["Self-Test Date", "", "mdi:calendar"], "ups.display.language": ["Language", "", "mdi:information-outline"], @@ -89,9 +90,13 @@ "battery.current": ["Battery Current", "A", "mdi:flash"], "battery.current.total": ["Total Battery Current", "A", "mdi:flash"], "battery.temperature": ["Battery Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "battery.runtime": ["Battery Runtime", "s", "mdi:timer"], - "battery.runtime.low": ["Low Battery Runtime", "s", "mdi:timer"], - "battery.runtime.restart": ["Minimum Battery Runtime to Start", "s", "mdi:timer"], + "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer"], + "battery.runtime.low": ["Low Battery Runtime", TIME_SECONDS, "mdi:timer"], + "battery.runtime.restart": [ + "Minimum Battery Runtime to Start", + TIME_SECONDS, + "mdi:timer", + ], "battery.alarm.threshold": [ "Battery Alarm Threshold", "", @@ -189,8 +194,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data.update(no_throttle=True) except data.pynuterror as err: _LOGGER.error( - "Failure while testing NUT status retrieval. Cannot continue setup: %s", - err, + "Failure while testing NUT status retrieval. Cannot continue setup: %s", err ) raise PlatformNotReady diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 5bb4cb46ee0798..2bb77c2d95b51d 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.10.1"] + "requirements": ["pynws==0.10.4"] } diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 3556c88a6da932..89d2c1c01da4e0 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -1,6 +1,11 @@ """Monitor the NZBGet API.""" import logging +from homeassistant.const import ( + DATA_MEGABYTES, + DATA_RATE_MEGABYTES_PER_SECOND, + TIME_MINUTES, +) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -12,16 +17,20 @@ DEFAULT_NAME = "NZBGet" SENSOR_TYPES = { - "article_cache": ["ArticleCacheMB", "Article Cache", "MB"], - "average_download_rate": ["AverageDownloadRate", "Average Speed", "MB/s"], + "article_cache": ["ArticleCacheMB", "Article Cache", DATA_MEGABYTES], + "average_download_rate": [ + "AverageDownloadRate", + "Average Speed", + DATA_RATE_MEGABYTES_PER_SECOND, + ], "download_paused": ["DownloadPaused", "Download Paused", None], - "download_rate": ["DownloadRate", "Speed", "MB/s"], - "download_size": ["DownloadedSizeMB", "Size", "MB"], - "free_disk_space": ["FreeDiskSpaceMB", "Disk Free", "MB"], + "download_rate": ["DownloadRate", "Speed", DATA_RATE_MEGABYTES_PER_SECOND], + "download_size": ["DownloadedSizeMB", "Size", DATA_MEGABYTES], + "free_disk_space": ["FreeDiskSpaceMB", "Disk Free", DATA_MEGABYTES], "post_job_count": ["PostJobCount", "Post Processing Jobs", "Jobs"], "post_paused": ["PostPaused", "Post Processing Paused", None], - "remaining_size": ["RemainingSizeMB", "Queue Size", "MB"], - "uptime": ["UpTimeSec", "Uptime", "min"], + "remaining_size": ["RemainingSizeMB", "Queue Size", DATA_MEGABYTES], + "uptime": ["UpTimeSec", "Uptime", TIME_MINUTES], } diff --git a/homeassistant/components/nzbget/services.yaml b/homeassistant/components/nzbget/services.yaml index 84e4af15b5d735..88a6267860e5d9 100644 --- a/homeassistant/components/nzbget/services.yaml +++ b/homeassistant/components/nzbget/services.yaml @@ -10,5 +10,5 @@ set_speed: description: Set download speed limit fields: speed: - description: Speed limit in KB/s. 0 is unlimited. - example: 1000 \ No newline at end of file + description: Speed limit in kB/s. 0 is unlimited. + example: 1000 diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index f73e525efe3821..06a8ae44f1ffef 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -19,6 +19,7 @@ CONF_SSL, CONTENT_TYPE_JSON, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -71,8 +72,14 @@ def ensure_valid_path(value): "Temperatures": ["printer", "temperature", "*", TEMP_CELSIUS], "Current State": ["printer", "state", "text", None, "mdi:printer-3d"], "Job Percentage": ["job", "progress", "completion", "%", "mdi:file-percent"], - "Time Remaining": ["job", "progress", "printTimeLeft", "seconds", "mdi:clock-end"], - "Time Elapsed": ["job", "progress", "printTime", "seconds", "mdi:clock-start"], + "Time Remaining": [ + "job", + "progress", + "printTimeLeft", + TIME_SECONDS, + "mdi:clock-end", + ], + "Time Elapsed": ["job", "progress", "printTime", TIME_SECONDS, "mdi:clock-start"], } SENSOR_SCHEMA = vol.Schema( diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index d63a543f227c91..98e7c320a60984 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/octoprint", "requirements": [], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index d21aac9ff650bc..98d878fc2eafe9 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "If you do not want to have your printer on
" " at all times, and you would like to monitor
" "temperatures, please add
" - "bed and/or number_of_tools to your config
" + "bed and/or number_of_tools to your configuration
" "and restart.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 936bf9f751ba7e..6a7f282ac87dac 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -19,6 +19,7 @@ DEFAULT_MOUNT_DIR = "/sys/bus/w1/devices/" DEVICE_SENSORS = { + # Family : { SensorType: owfs path } "10": {"temperature": "temperature"}, "12": {"temperature": "TAI8570/temperature", "pressure": "TAI8570/pressure"}, "22": {"temperature": "temperature"}, @@ -27,6 +28,9 @@ "humidity": "humidity", "pressure": "B1-R1-A/pressure", "illuminance": "S3-R1-A/illuminance", + "voltage_VAD": "VAD", + "voltage_VDD": "VDD", + "current": "IAD", }, "28": {"temperature": "temperature"}, "3B": {"temperature": "temperature"}, @@ -54,6 +58,7 @@ } SENSOR_TYPES = { + # SensorType: [ Measured unit, Unit ] "temperature": ["temperature", TEMP_CELSIUS], "humidity": ["humidity", "%"], "humidity_raw": ["humidity", "%"], @@ -70,6 +75,10 @@ "counter_a": ["counter", "count"], "counter_b": ["counter", "count"], "HobbyBoard": ["none", "none"], + "voltage": ["voltage", "V"], + "voltage_VAD": ["voltage", "V"], + "voltage_VDD": ["voltage", "V"], + "current": ["current", "A"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -95,11 +104,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): base_dir = config[CONF_MOUNT_DIR] owport = config[CONF_PORT] owhost = config.get(CONF_HOST) + if owhost: + _LOGGER.debug("Initializing using %s:%s", owhost, owport) + else: + _LOGGER.debug("Initializing using %s", base_dir) + devs = [] device_names = {} - if "names" in config: - if isinstance(config["names"], dict): - device_names = config["names"] + if CONF_NAMES in config: + if isinstance(config[CONF_NAMES], dict): + device_names = config[CONF_NAMES] # We have an owserver on a remote(or local) host/port if owhost: @@ -112,7 +126,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) devices = [] for device in devices: - _LOGGER.debug("found device: %s", device) + _LOGGER.debug("Found device: %s", device) family = owproxy.read(f"{device}family").decode() dev_type = "std" if "EF" in family: @@ -200,6 +214,7 @@ def __init__(self, name, device_file, sensor_type): self._device_file = device_file self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._state = None + self._value_raw = None def _read_value_raw(self): """Read the value as it is returned by the sensor.""" @@ -224,6 +239,16 @@ def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return {"device_file": self._device_file, "raw_value": self._value_raw} + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._device_file + class OneWireProxy(OneWire): """Implementation of a One wire Sensor through owserver.""" @@ -249,6 +274,7 @@ def update(self): _LOGGER.error("Owserver failure in read(), got: %s", exc) if value_read: value = round(float(value_read), 1) + self._value_raw = float(value_read) self._state = value @@ -267,6 +293,7 @@ def update(self): if equals_pos != -1: value_string = lines[1][equals_pos + 2 :] value = round(float(value_string) / 1000.0, 1) + self._value_raw = float(value_string) self._state = value @@ -280,6 +307,7 @@ def update(self): value_read = self._read_value_raw() if len(value_read) == 1: value = round(float(value_read[0]), 1) + self._value_raw = float(value_read[0]) except ValueError: _LOGGER.warning("Invalid value read from %s", self._device_file) except FileNotFoundError: diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 3f244530dca679..acedf229bdb2ef 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,13 +1,9 @@ -""" -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/ -""" +"""Support for ONVIF Cameras with FFmpeg as decoder.""" import asyncio import datetime as dt import logging import os +from typing import Optional from aiohttp.client_exceptions import ClientConnectionError, ServerDisconnectedError from haffmpeg.camera import CameraMjpeg @@ -141,6 +137,7 @@ def __init__(self, hass, config): self._profile_index = config.get(CONF_PROFILE) self._ptz_service = None self._input = None + self._mac = None _LOGGER.debug( "Setting up the ONVIF camera device @ '%s:%s'", self._host, self._port @@ -165,6 +162,7 @@ async def async_initialize(self): _LOGGER.debug("Updating service addresses") await self._camera.update_xaddrs() + await self.async_obtain_mac_address() await self.async_check_date_and_time() await self.async_obtain_input_uri() self.setup_ptz() @@ -183,6 +181,14 @@ async def async_initialize(self): err, ) + async def async_obtain_mac_address(self): + """Obtain the MAC address of the camera to use as the unique ID.""" + devicemgmt = self._camera.create_devicemgmt_service() + network_interfaces = await devicemgmt.GetNetworkInterfaces() + for interface in network_interfaces: + if interface.Enabled: + self._mac = interface.Info.HwAddress + async def async_check_date_and_time(self): """Warns if camera and system date not synced.""" _LOGGER.debug("Setting up the ONVIF device management service") @@ -403,3 +409,8 @@ async def stream_source(self): def name(self): """Return the name of this camera.""" return self._name + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._mac diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 3d13db3ead3839..40ab3a8a7ed010 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.17.4", "opencv-python-headless==4.1.2.30"], + "requirements": ["numpy==1.18.1", "opencv-python-headless==4.1.2.30"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 0ac655cd4483f9..e0f21f6946db1b 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -11,6 +11,7 @@ CONF_MONITORED_VARIABLES, ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -19,7 +20,7 @@ SENSOR_TYPES = { "status": ["Charging Status", None], - "charge_time": ["Charge Time Elapsed", "minutes"], + "charge_time": ["Charge Time Elapsed", TIME_MINUTES], "ambient_temp": ["Ambient Temperature", TEMP_CELSIUS], "ir_temp": ["IR Temperature", TEMP_CELSIUS], "rtc_temp": ["RTC Temperature", TEMP_CELSIUS], diff --git a/homeassistant/components/opentherm_gw/.translations/hu.json b/homeassistant/components/opentherm_gw/.translations/hu.json new file mode 100644 index 00000000000000..8a0780581fde12 --- /dev/null +++ b/homeassistant/components/opentherm_gw/.translations/hu.json @@ -0,0 +1,33 @@ +{ + "config": { + "error": { + "already_configured": "Az \u00e1tj\u00e1r\u00f3 m\u00e1r konfigur\u00e1lva van", + "id_exists": "Az \u00e1tj\u00e1r\u00f3 azonos\u00edt\u00f3ja m\u00e1r l\u00e9tezik", + "serial_error": "Hiba t\u00f6rt\u00e9nt az eszk\u00f6zh\u00f6z val\u00f3 csatlakoz\u00e1skor", + "timeout": "A csatlakoz\u00e1si k\u00eds\u00e9rletre sz\u00e1nt id\u0151 lej\u00e1rt" + }, + "step": { + "init": { + "data": { + "device": "El\u00e9r\u00e9si \u00fat vagy URL", + "floor_temperature": "Padl\u00f3 kl\u00edma h\u0151m\u00e9rs\u00e9klete", + "id": "ID", + "name": "N\u00e9v", + "precision": "Kl\u00edma h\u0151m\u00e9rs\u00e9klet pontoss\u00e1ga" + }, + "title": "OpenTherm \u00e1tj\u00e1r\u00f3" + } + }, + "title": "OpenTherm \u00e1tj\u00e1r\u00f3" + }, + "options": { + "step": { + "init": { + "data": { + "floor_temperature": "Padl\u00f3 h\u0151m\u00e9rs\u00e9klete", + "precision": "Pontoss\u00e1g" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/.translations/pl.json b/homeassistant/components/opentherm_gw/.translations/pl.json index fe8ccfc8975f98..88791781e3f50e 100644 --- a/homeassistant/components/opentherm_gw/.translations/pl.json +++ b/homeassistant/components/opentherm_gw/.translations/pl.json @@ -1,8 +1,8 @@ { "config": { "error": { - "already_configured": "Bramka jest ju\u017c skonfigurowana", - "id_exists": "Identyfikator bramki ju\u017c istnieje", + "already_configured": "Bramka jest ju\u017c skonfigurowana.", + "id_exists": "Identyfikator bramki ju\u017c istnieje.", "serial_error": "B\u0142\u0105d po\u0142\u0105czenia z urz\u0105dzeniem", "timeout": "Przekroczono limit czasu pr\u00f3by po\u0142\u0105czenia." }, diff --git a/homeassistant/components/opentherm_gw/.translations/sv.json b/homeassistant/components/opentherm_gw/.translations/sv.json new file mode 100644 index 00000000000000..89ce4d75674b5d --- /dev/null +++ b/homeassistant/components/opentherm_gw/.translations/sv.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "already_configured": "Gateway redan konfigurerad", + "id_exists": "Gateway-id finns redan", + "serial_error": "Fel vid anslutning till enheten", + "timeout": "Anslutningsf\u00f6rs\u00f6ket avbr\u00f6ts" + }, + "step": { + "init": { + "data": { + "device": "S\u00f6kv\u00e4g eller URL", + "floor_temperature": "Golvtemperatur", + "id": "ID", + "name": "Namn", + "precision": "Klimatemperaturprecision" + }, + "title": "OpenTherm Gateway" + } + }, + "title": "OpenTherm Gateway" + }, + "options": { + "step": { + "init": { + "data": { + "floor_temperature": "Golvetemperatur", + "precision": "Precision" + }, + "description": "Alternativ f\u00f6r OpenTherm Gateway" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index bd9b372de33200..580f9f7b1a4e3a 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -1,7 +1,12 @@ """Constants for the opentherm_gw integration.""" import pyotgw.vars as gw_vars -from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + TIME_HOURS, + TIME_MINUTES, +) ATTR_GW_ID = "gateway_id" ATTR_LEVEL = "level" @@ -31,9 +36,8 @@ SERVICE_SET_SB_TEMP = "set_setback_temperature" UNIT_BAR = "bar" -UNIT_HOUR = "h" UNIT_KW = "kW" -UNIT_L_MIN = "L/min" +UNIT_L_MIN = f"L/{TIME_MINUTES}" UNIT_PERCENT = "%" BINARY_SENSOR_INFO = { @@ -237,10 +241,10 @@ gw_vars.DATA_CH_PUMP_STARTS: [None, None, "Central Heating Pump Starts {}"], gw_vars.DATA_DHW_PUMP_STARTS: [None, None, "Hot Water Pump Starts {}"], gw_vars.DATA_DHW_BURNER_STARTS: [None, None, "Hot Water Burner Starts {}"], - gw_vars.DATA_TOTAL_BURNER_HOURS: [None, UNIT_HOUR, "Total Burner Hours {}"], - gw_vars.DATA_CH_PUMP_HOURS: [None, UNIT_HOUR, "Central Heating Pump Hours {}"], - gw_vars.DATA_DHW_PUMP_HOURS: [None, UNIT_HOUR, "Hot Water Pump Hours {}"], - gw_vars.DATA_DHW_BURNER_HOURS: [None, UNIT_HOUR, "Hot Water Burner Hours {}"], + gw_vars.DATA_TOTAL_BURNER_HOURS: [None, TIME_HOURS, "Total Burner Hours {}"], + gw_vars.DATA_CH_PUMP_HOURS: [None, TIME_HOURS, "Central Heating Pump Hours {}"], + gw_vars.DATA_DHW_PUMP_HOURS: [None, TIME_HOURS, "Hot Water Pump Hours {}"], + gw_vars.DATA_DHW_BURNER_HOURS: [None, TIME_HOURS, "Hot Water Burner Hours {}"], gw_vars.DATA_MASTER_OT_VERSION: [None, None, "Thermostat OpenTherm Version {}"], gw_vars.DATA_SLAVE_OT_VERSION: [None, None, "Boiler OpenTherm Version {}"], gw_vars.DATA_MASTER_PRODUCT_TYPE: [None, None, "Thermostat Product Type {}"], diff --git a/homeassistant/components/openuv/.translations/de.json b/homeassistant/components/openuv/.translations/de.json index 7f8121dd96b76a..cc8ee92df4b731 100644 --- a/homeassistant/components/openuv/.translations/de.json +++ b/homeassistant/components/openuv/.translations/de.json @@ -12,7 +12,7 @@ "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" }, - "title": "Gebe deine Informationen ein" + "title": "Gib deine Informationen ein" } }, "title": "OpenUV" diff --git a/homeassistant/components/openuv/.translations/pl.json b/homeassistant/components/openuv/.translations/pl.json index ee3875c2903c42..ff6d1b210557d7 100644 --- a/homeassistant/components/openuv/.translations/pl.json +++ b/homeassistant/components/openuv/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Wsp\u00f3\u0142rz\u0119dne s\u0105 ju\u017c zarejestrowane", + "identifier_exists": "Wsp\u00f3\u0142rz\u0119dne s\u0105 ju\u017c zarejestrowane.", "invalid_api_key": "Nieprawid\u0142owy klucz API" }, "step": { diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 2df62bcc09f2a3..a375cfa10d7621 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,6 +1,7 @@ """Support for OpenUV sensors.""" import logging +from homeassistant.const import TIME_MINUTES from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import as_local, parse_datetime @@ -50,32 +51,32 @@ TYPE_SAFE_EXPOSURE_TIME_1: ( "Skin Type 1 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_2: ( "Skin Type 2 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_3: ( "Skin Type 3 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_4: ( "Skin Type 4 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_5: ( "Skin Type 5 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_6: ( "Skin Type 6 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), } diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 23f88f59aada40..5908ccfff061cd 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -12,6 +12,7 @@ CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -33,7 +34,7 @@ SENSOR_TYPES = { "weather": ["Condition", None], "temperature": ["Temperature", None], - "wind_speed": ["Wind speed", "m/s"], + "wind_speed": ["Wind speed", SPEED_METERS_PER_SECOND], "wind_bearing": ["Wind bearing", "°"], "humidity": ["Humidity", "%"], "pressure": ["Pressure", "mbar"], diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py new file mode 100644 index 00000000000000..608bca0f03b2a0 --- /dev/null +++ b/homeassistant/components/opnsense/__init__.py @@ -0,0 +1,77 @@ +"""Support for OPNSense Routers.""" +import logging + +from pyopnsense import diagnostics +from pyopnsense.exceptions import APIException +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +_LOGGER = logging.getLogger(__name__) + +CONF_API_SECRET = "api_secret" +CONF_TRACKER_INTERFACE = "tracker_interfaces" + +DOMAIN = "opnsense" + +OPNSENSE_DATA = DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_SECRET): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, + vol.Optional(CONF_TRACKER_INTERFACE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the opnsense component.""" + + conf = config[DOMAIN] + url = conf[CONF_URL] + api_key = conf[CONF_API_KEY] + api_secret = conf[CONF_API_SECRET] + verify_ssl = conf[CONF_VERIFY_SSL] + tracker_interfaces = conf[CONF_TRACKER_INTERFACE] + + interfaces_client = diagnostics.InterfaceClient( + api_key, api_secret, url, verify_ssl + ) + try: + interfaces_client.get_arp() + except APIException: + _LOGGER.exception("Failure while connecting to OPNsense API endpoint.") + return False + + if tracker_interfaces: + # Verify that specified tracker interfaces are valid + netinsight_client = diagnostics.NetworkInsightClient( + api_key, api_secret, url, verify_ssl + ) + interfaces = list(netinsight_client.get_interfaces().values()) + for interface in tracker_interfaces: + if interface not in interfaces: + _LOGGER.error( + "Specified OPNsense tracker interface %s is not found", interface + ) + return False + + hass.data[OPNSENSE_DATA] = { + "interfaces": interfaces_client, + CONF_TRACKER_INTERFACE: tracker_interfaces, + } + + load_platform(hass, "device_tracker", DOMAIN, tracker_interfaces, config) + return True diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py new file mode 100644 index 00000000000000..c64e0b0679a759 --- /dev/null +++ b/homeassistant/components/opnsense/device_tracker.py @@ -0,0 +1,66 @@ +"""Device tracker support for OPNSense routers.""" +import logging + +from homeassistant.components.device_tracker import DeviceScanner +from homeassistant.components.opnsense import CONF_TRACKER_INTERFACE, OPNSENSE_DATA + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_scanner(hass, config, discovery_info=None): + """Configure the OPNSense device_tracker.""" + interface_client = hass.data[OPNSENSE_DATA]["interfaces"] + scanner = OPNSenseDeviceScanner( + interface_client, hass.data[OPNSENSE_DATA][CONF_TRACKER_INTERFACE] + ) + return scanner + + +class OPNSenseDeviceScanner(DeviceScanner): + """This class queries a router running OPNsense.""" + + def __init__(self, client, interfaces): + """Initialize the scanner.""" + self.last_results = {} + self.client = client + self.interfaces = interfaces + + def _get_mac_addrs(self, devices): + """Create dict with mac address keys from list of devices.""" + out_devices = {} + for device in devices: + if not self.interfaces: + out_devices[device["mac"]] = device + elif device["intf_description"] in self.interfaces: + out_devices[device["mac"]] = device + return out_devices + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self.update_info() + return list(self.last_results) + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + if device not in self.last_results: + return None + hostname = self.last_results[device].get("hostname") or None + return hostname + + def update_info(self): + """Ensure the information from the OPNSense router is up to date. + + Return boolean if scanning successful. + """ + + devices = self.client.get_arp() + self.last_results = self._get_mac_addrs(devices) + + def get_extra_attributes(self, device): + """Return the extra attrs of the given device.""" + if device not in self.last_results: + return None + mfg = self.last_results[device].get("manufacturer") + if mfg: + return {"manufacturer": mfg} + return {} diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json new file mode 100644 index 00000000000000..858316801029fe --- /dev/null +++ b/homeassistant/components/opnsense/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "opnsense", + "name": "OPNSense", + "documentation": "https://www.home-assistant.io/integrations/opnsense", + "requirements": [ + "pyopnsense==0.2.0" + ], + "dependencies": [], + "codeowners": ["@mtreinish"] +} diff --git a/homeassistant/components/owlet/__init__.py b/homeassistant/components/owlet/__init__.py deleted file mode 100644 index 3882ba4bf7de5d..00000000000000 --- a/homeassistant/components/owlet/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Support for Owlet baby monitors.""" -import logging - -from pyowlet.PyOwlet import PyOwlet -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform - -from .const import ( - SENSOR_BASE_STATION, - SENSOR_HEART_RATE, - SENSOR_MOVEMENT, - SENSOR_OXYGEN_LEVEL, -) - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "owlet" - -SENSOR_TYPES = [ - SENSOR_OXYGEN_LEVEL, - SENSOR_HEART_RATE, - SENSOR_BASE_STATION, - SENSOR_MOVEMENT, -] - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Set up owlet component.""" - - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - name = config[DOMAIN].get(CONF_NAME) - - try: - device = PyOwlet(username, password) - except KeyError: - _LOGGER.error( - "Owlet authentication failed. Please verify your credentials are correct" - ) - return False - - device.update_properties() - - if not name: - name = f"{device.baby_name}'s Owlet" - - hass.data[DOMAIN] = OwletDevice(device, name, SENSOR_TYPES) - - load_platform(hass, "sensor", DOMAIN, {}, config) - load_platform(hass, "binary_sensor", DOMAIN, {}, config) - - return True - - -class OwletDevice: - """Represents a configured Owlet device.""" - - def __init__(self, device, name, monitor): - """Initialize device.""" - self.name = name - self.monitor = monitor - self.device = device diff --git a/homeassistant/components/owlet/binary_sensor.py b/homeassistant/components/owlet/binary_sensor.py deleted file mode 100644 index 48faa00cd9a0f6..00000000000000 --- a/homeassistant/components/owlet/binary_sensor.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Support for Owlet binary sensors.""" -from datetime import timedelta - -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.util import dt as dt_util - -from . import DOMAIN as OWLET_DOMAIN -from .const import SENSOR_BASE_STATION, SENSOR_MOVEMENT - -SCAN_INTERVAL = timedelta(seconds=120) - -BINARY_CONDITIONS = { - SENSOR_BASE_STATION: {"name": "Base Station", "device_class": "power"}, - SENSOR_MOVEMENT: {"name": "Movement", "device_class": "motion"}, -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up owlet binary sensor.""" - if discovery_info is None: - return - - device = hass.data[OWLET_DOMAIN] - - entities = [] - for condition in BINARY_CONDITIONS: - if condition in device.monitor: - entities.append(OwletBinarySensor(device, condition)) - - add_entities(entities, True) - - -class OwletBinarySensor(BinarySensorDevice): - """Representation of owlet binary sensor.""" - - def __init__(self, device, condition): - """Init owlet binary sensor.""" - self._device = device - self._condition = condition - self._state = None - self._base_on = False - self._prop_expiration = None - self._is_charging = None - - @property - def name(self): - """Return sensor name.""" - return "{} {}".format( - self._device.name, BINARY_CONDITIONS[self._condition]["name"] - ) - - @property - def is_on(self): - """Return current state of sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class.""" - return BINARY_CONDITIONS[self._condition]["device_class"] - - def update(self): - """Update state of sensor.""" - self._base_on = self._device.device.base_station_on - self._prop_expiration = self._device.device.prop_expire_time - self._is_charging = self._device.device.charge_status > 0 - - # handle expired values - if self._prop_expiration < dt_util.now().timestamp(): - self._state = False - return - - if self._condition == "movement": - if not self._base_on or self._is_charging: - return False - - self._state = getattr(self._device.device, self._condition) diff --git a/homeassistant/components/owlet/const.py b/homeassistant/components/owlet/const.py deleted file mode 100644 index f145100dbc41bd..00000000000000 --- a/homeassistant/components/owlet/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants for Owlet component.""" -SENSOR_OXYGEN_LEVEL = "oxygen_level" -SENSOR_HEART_RATE = "heart_rate" - -SENSOR_BASE_STATION = "base_station_on" -SENSOR_MOVEMENT = "movement" diff --git a/homeassistant/components/owlet/manifest.json b/homeassistant/components/owlet/manifest.json deleted file mode 100644 index 632115a93cbb1c..00000000000000 --- a/homeassistant/components/owlet/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "owlet", - "name": "Owlet", - "documentation": "https://www.home-assistant.io/integrations/owlet", - "requirements": ["pyowlet==1.0.3"], - "dependencies": [], - "codeowners": ["@oblogic7"] -} diff --git a/homeassistant/components/owlet/sensor.py b/homeassistant/components/owlet/sensor.py deleted file mode 100644 index af88db475e532b..00000000000000 --- a/homeassistant/components/owlet/sensor.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Support for Owlet sensors.""" -from datetime import timedelta - -from homeassistant.helpers.entity import Entity -from homeassistant.util import dt as dt_util - -from . import DOMAIN as OWLET_DOMAIN -from .const import SENSOR_HEART_RATE, SENSOR_OXYGEN_LEVEL - -SCAN_INTERVAL = timedelta(seconds=120) - -SENSOR_CONDITIONS = { - SENSOR_OXYGEN_LEVEL: {"name": "Oxygen Level", "device_class": None}, - SENSOR_HEART_RATE: {"name": "Heart Rate", "device_class": None}, -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up owlet binary sensor.""" - if discovery_info is None: - return - - device = hass.data[OWLET_DOMAIN] - - entities = [] - for condition in SENSOR_CONDITIONS: - if condition in device.monitor: - entities.append(OwletSensor(device, condition)) - - add_entities(entities, True) - - -class OwletSensor(Entity): - """Representation of Owlet sensor.""" - - def __init__(self, device, condition): - """Init owlet binary sensor.""" - self._device = device - self._condition = condition - self._state = None - self._prop_expiration = None - self.is_charging = None - self.battery_level = None - self.sock_off = None - self.sock_connection = None - self._movement = None - - @property - def name(self): - """Return sensor name.""" - return "{} {}".format( - self._device.name, SENSOR_CONDITIONS[self._condition]["name"] - ) - - @property - def state(self): - """Return current state of sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class.""" - return SENSOR_CONDITIONS[self._condition]["device_class"] - - @property - def device_state_attributes(self): - """Return state attributes.""" - attributes = { - "battery_charging": self.is_charging, - "battery_level": self.battery_level, - "sock_off": self.sock_off, - "sock_connection": self.sock_connection, - } - - return attributes - - def update(self): - """Update state of sensor.""" - self.is_charging = self._device.device.charge_status - self.battery_level = self._device.device.batt_level - self.sock_off = self._device.device.sock_off - self.sock_connection = self._device.device.sock_connection - self._movement = self._device.device.movement - self._prop_expiration = self._device.device.prop_expire_time - - value = getattr(self._device.device, self._condition) - - if self._condition == "batt_level": - self._state = min(100, value) - return - - if ( - not self._device.device.base_station_on - or self._device.device.charge_status > 0 - or self._prop_expiration < dt_util.now().timestamp() - or self._movement - ): - value = None - - self._state = value diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 00fa023d6c1f41..ed94ef0fa14e3e 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -4,7 +4,7 @@ from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.components.device_tracker.const import ( ATTR_SOURCE_TYPE, - ENTITY_ID_FORMAT, + DOMAIN, SOURCE_TYPE_GPS, ) from homeassistant.const import ( @@ -68,7 +68,7 @@ def __init__(self, dev_id, data=None): """Set up OwnTracks entity.""" self._dev_id = dev_id self._data = data or {} - self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + self.entity_id = f"{DOMAIN}.{dev_id}" @property def unique_id(self): diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 417903c46e0f01..322765ac082628 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -119,8 +119,8 @@ def turn_on(self): elif mode == 2: _LOGGER.warning( "The pianobar client is not configured to log in. " - "Please create a config file for it as described at " - "https://home-assistant.io/components/media_player.pandora/" + "Please create a configuration file for it as described at " + "https://www.home-assistant.io/integrations/pandora/" ) # pass through the email/password prompts to quit cleanly self._pianobar.sendcontrol("m") @@ -384,6 +384,6 @@ def _pianobar_exists(): _LOGGER.warning( "The Pandora integration depends on the Pianobar client, which " "cannot be found. Please install using instructions at " - "https://home-assistant.io/components/media_player.pandora/" + "https://www.home-assistant.io/integrations/media_player.pandora/" ) return False diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index 36266feaa6e945..5cd1f8266297e4 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -1,8 +1,4 @@ -"""Pencom relay control. - -For more details about this component, please refer to the documentation at -http://home-assistant.io/components/switch.pencom -""" +"""Pencom relay control.""" import logging from pencompy.pencompy import Pencompy diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index dabcc046f7ab47..9cd3e882c48630 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -88,7 +88,11 @@ async def async_create_person(hass, name, *, user_id=None, device_trackers=None): """Create a new person.""" await hass.data[DOMAIN][1].async_create_item( - {ATTR_NAME: name, ATTR_USER_ID: user_id, "device_trackers": device_trackers} + { + ATTR_NAME: name, + ATTR_USER_ID: user_id, + CONF_DEVICE_TRACKERS: device_trackers or [], + } ) @@ -103,14 +107,14 @@ async def async_add_user_device_tracker( if person.get(ATTR_USER_ID) != user_id: continue - device_trackers = person["device_trackers"] + device_trackers = person[CONF_DEVICE_TRACKERS] if device_tracker_entity_id in device_trackers: return await coll.async_update_item( person[collection.CONF_ID], - {"device_trackers": device_trackers + [device_tracker_entity_id]}, + {CONF_DEVICE_TRACKERS: device_trackers + [device_tracker_entity_id]}, ) break @@ -161,6 +165,23 @@ def __init__( super().__init__(store, logger, id_manager) self.yaml_collection = yaml_collection + async def _async_load_data(self) -> Optional[dict]: + """Load the data. + + A past bug caused onboarding to create invalid person objects. + This patches it up. + """ + data = await super()._async_load_data() + + if data is None: + return data + + for person in data["items"]: + if person[CONF_DEVICE_TRACKERS] is None: + person[CONF_DEVICE_TRACKERS] = [] + + return data + async def async_load(self) -> None: """Load the Storage collection.""" await super().async_load() @@ -179,14 +200,16 @@ async def _entity_registry_updated(self, event) -> None: return for person in list(self.data.values()): - if entity_id not in person["device_trackers"]: + if entity_id not in person[CONF_DEVICE_TRACKERS]: continue await self.async_update_item( person[collection.CONF_ID], { - "device_trackers": [ - devt for devt in person["device_trackers"] if devt != entity_id + CONF_DEVICE_TRACKERS: [ + devt + for devt in person[CONF_DEVICE_TRACKERS] + if devt != entity_id ] }, ) @@ -315,7 +338,9 @@ async def async_reload_yaml(call: ServiceCall): conf = await entity_component.async_prepare_reload(skip_reset=True) if conf is None: return - await yaml_collection.async_load(await filter_yaml_data(hass, conf[DOMAIN])) + await yaml_collection.async_load( + await filter_yaml_data(hass, conf.get(DOMAIN, [])) + ) service.async_register_admin_service( hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml @@ -406,7 +431,7 @@ async def async_update_config(self, config): self._unsub_track_device() self._unsub_track_device = None - trackers = self._config.get(CONF_DEVICE_TRACKERS) + trackers = self._config[CONF_DEVICE_TRACKERS] if trackers: _LOGGER.debug("Subscribe to device trackers for %s", self.entity_id) @@ -426,7 +451,7 @@ def _async_handle_tracker_update(self, entity, old_state, new_state): def _update_state(self): """Update the state.""" latest_non_gps_home = latest_not_home = latest_gps = latest = None - for entity_id in self._config.get(CONF_DEVICE_TRACKERS, []): + for entity_id in self._config[CONF_DEVICE_TRACKERS]: state = self.hass.states.get(entity_id) if not state or state.state in IGNORE_STATES: diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index ed6144af47e9ff..fb06d06cfb4bf3 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -91,7 +91,7 @@ async def async_setup(hass, config): """Set up the pi_hole integration.""" def get_data(): - """Retrive component data.""" + """Retrieve component data.""" return hass.data[DOMAIN] def ensure_api_token(call_data): @@ -115,7 +115,7 @@ def ensure_api_token(call_data): return call_data - service_disable_schema = vol.Schema( # pylint: disable=invalid-name + service_disable_schema = vol.Schema( vol.All( { vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All( diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index 0ae62b318650b6..ca4eea32bd68df 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -1,4 +1,4 @@ -"""Constants for the pi_hole intergration.""" +"""Constants for the pi_hole integration.""" from datetime import timedelta DOMAIN = "pi_hole" diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index c4d88f6061c72f..c0effda7a55b6d 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -21,7 +21,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(const.CONF_HOSTS): {cv.string: cv.string}, + vol.Required(const.CONF_HOSTS): {cv.slug: cv.string}, vol.Optional(CONF_PING_COUNT, default=1): cv.positive_int, } ) diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 3e71b54c9fab09..b834a8e6829444 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -13,6 +13,7 @@ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( CONF_HOST, @@ -36,6 +37,7 @@ SUPPORT_PIONEER = ( SUPPORT_PAUSE | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF diff --git a/homeassistant/components/plex/.translations/ca.json b/homeassistant/components/plex/.translations/ca.json index 63cf65b8d6c1cf..d562d62b602a6d 100644 --- a/homeassistant/components/plex/.translations/ca.json +++ b/homeassistant/components/plex/.translations/ca.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignora els nous usuaris gestionats/compartits", + "monitored_users": "Usuaris monitoritzats", "show_all_controls": "Mostra tots els controls", "use_episode_art": "Utilitza imatges de l'episodi" }, diff --git a/homeassistant/components/plex/.translations/da.json b/homeassistant/components/plex/.translations/da.json index 18dbbb840c3f37..9b80373727d704 100644 --- a/homeassistant/components/plex/.translations/da.json +++ b/homeassistant/components/plex/.translations/da.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorer nye administrerede/delte brugere", + "monitored_users": "Monitorerede brugere", "show_all_controls": "Vis alle kontrolelementer", "use_episode_art": "Brug episodekunst" }, diff --git a/homeassistant/components/plex/.translations/de.json b/homeassistant/components/plex/.translations/de.json index aa8c5e08dd6a45..ea8f4b60de4312 100644 --- a/homeassistant/components/plex/.translations/de.json +++ b/homeassistant/components/plex/.translations/de.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorieren neuer verwalteter/freigegebener Benutzer", + "monitored_users": "\u00dcberwachte Benutzer", "show_all_controls": "Alle Steuerelemente anzeigen", "use_episode_art": "Episode-Bilder verwenden" }, diff --git a/homeassistant/components/plex/.translations/en.json b/homeassistant/components/plex/.translations/en.json index 31211182f4717e..4567171af779c3 100644 --- a/homeassistant/components/plex/.translations/en.json +++ b/homeassistant/components/plex/.translations/en.json @@ -4,7 +4,7 @@ "all_configured": "All linked servers already configured", "already_configured": "This Plex server is already configured", "already_in_progress": "Plex is being configured", - "discovery_no_file": "No legacy config file found", + "discovery_no_file": "No legacy configuration file found", "invalid_import": "Imported configuration is invalid", "non-interactive": "Non-interactive import", "token_request_timeout": "Timed out obtaining token", @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignore new managed/shared users", + "monitored_users": "Monitored users", "show_all_controls": "Show all controls", "use_episode_art": "Use episode art" }, diff --git a/homeassistant/components/plex/.translations/es.json b/homeassistant/components/plex/.translations/es.json index 53dd322828853a..24127a7332c653 100644 --- a/homeassistant/components/plex/.translations/es.json +++ b/homeassistant/components/plex/.translations/es.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorar nuevos usuarios administrados/compartidos", + "monitored_users": "Usuarios monitorizados", "show_all_controls": "Mostrar todos los controles", "use_episode_art": "Usar el arte de episodios" }, diff --git a/homeassistant/components/plex/.translations/hu.json b/homeassistant/components/plex/.translations/hu.json new file mode 100644 index 00000000000000..4712fb37b551a6 --- /dev/null +++ b/homeassistant/components/plex/.translations/hu.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "all_configured": "Az \u00f6sszes \u00f6sszekapcsolt szerver m\u00e1r konfigur\u00e1lva van", + "already_configured": "Ez a Plex szerver m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A Plex konfigur\u00e1l\u00e1sa folyamatban van", + "discovery_no_file": "Nem tal\u00e1lhat\u00f3 r\u00e9gi konfigur\u00e1ci\u00f3s f\u00e1jl", + "invalid_import": "Az import\u00e1lt konfigur\u00e1ci\u00f3 \u00e9rv\u00e9nytelen", + "non-interactive": "Nem interakt\u00edv import\u00e1l\u00e1s", + "token_request_timeout": "Token k\u00e9r\u00e9sre sz\u00e1nt id\u0151 lej\u00e1rt", + "unknown": "Ismeretlen okb\u00f3l nem siker\u00fclt" + }, + "error": { + "faulty_credentials": "A hiteles\u00edt\u00e9s sikertelen", + "no_servers": "Nincs szerver csatlakoztatva a fi\u00f3khoz", + "not_found": "A Plex szerver nem tal\u00e1lhat\u00f3" + }, + "step": { + "manual_setup": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "port": "Port" + } + }, + "select_server": { + "data": { + "server": "szerver" + }, + "description": "T\u00f6bb szerver el\u00e9rhet\u0151, v\u00e1lasszon egyet:", + "title": "Plex-kiszolg\u00e1l\u00f3 kiv\u00e1laszt\u00e1sa" + }, + "start_website_auth": { + "description": "Folytassa az enged\u00e9lyez\u00e9st a plex.tv webhelyen.", + "title": "Plex-kiszolg\u00e1l\u00f3 csatlakoztat\u00e1sa" + }, + "user": { + "data": { + "token": "Plex token" + }, + "description": "Folytassa az enged\u00e9lyez\u00e9st a plex.tv webhelyen, vagy manu\u00e1lisan konfigur\u00e1lja a szervert.", + "title": "Plex-kiszolg\u00e1l\u00f3 csatlakoztat\u00e1sa" + } + }, + "title": "Plex" + }, + "options": { + "step": { + "plex_mp_settings": { + "data": { + "show_all_controls": "Az \u00f6sszes vez\u00e9rl\u0151 megjelen\u00edt\u00e9se", + "use_episode_art": "Haszn\u00e1lja az epiz\u00f3d bor\u00edt\u00f3j\u00e1t" + }, + "description": "Plex media lej\u00e1tsz\u00f3k be\u00e1ll\u00edt\u00e1sai" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/it.json b/homeassistant/components/plex/.translations/it.json index 06c20660fef7d7..e5ff4e01dc0c29 100644 --- a/homeassistant/components/plex/.translations/it.json +++ b/homeassistant/components/plex/.translations/it.json @@ -4,7 +4,7 @@ "all_configured": "Tutti i server collegati sono gi\u00e0 configurati", "already_configured": "Questo server Plex \u00e8 gi\u00e0 configurato", "already_in_progress": "Plex \u00e8 in fase di configurazione", - "discovery_no_file": "Nessun file di configurazione legacy trovato", + "discovery_no_file": "Non \u00e8 stato trovato nessun file di configurazione da sostituire", "invalid_import": "La configurazione importata non \u00e8 valida", "non-interactive": "Importazione non interattiva", "token_request_timeout": "Timeout per l'ottenimento del token", @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignora nuovi utenti gestiti/condivisi", + "monitored_users": "Utenti monitorati", "show_all_controls": "Mostra tutti i controlli", "use_episode_art": "Usa la grafica dell'episodio" }, diff --git a/homeassistant/components/plex/.translations/ko.json b/homeassistant/components/plex/.translations/ko.json index cf5a7946b9ddd1..3292fab0a8e0b7 100644 --- a/homeassistant/components/plex/.translations/ko.json +++ b/homeassistant/components/plex/.translations/ko.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "\uc0c8\ub85c\uc6b4 \uad00\ub9ac/\uacf5\uc720 \uc0ac\uc6a9\uc790 \ubb34\uc2dc", + "monitored_users": "\ubaa8\ub2c8\ud130\ub9c1\ub418\ub294 \uc0ac\uc6a9\uc790", "show_all_controls": "\ubaa8\ub4e0 \ucee8\ud2b8\ub864 \ud45c\uc2dc\ud558\uae30", "use_episode_art": "\uc5d0\ud53c\uc18c\ub4dc \uc544\ud2b8 \uc0ac\uc6a9" }, diff --git a/homeassistant/components/plex/.translations/lb.json b/homeassistant/components/plex/.translations/lb.json index c6fcabc40d7ced..6ed9d372fc1d7d 100644 --- a/homeassistant/components/plex/.translations/lb.json +++ b/homeassistant/components/plex/.translations/lb.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Nei verwalt / gedeelt Benotzer ignor\u00e9ieren", + "monitored_users": "Iwwerwaachte Benotzer", "show_all_controls": "Weis all Kontrollen", "use_episode_art": "Benotz Biller vun der Episode" }, diff --git a/homeassistant/components/plex/.translations/no.json b/homeassistant/components/plex/.translations/no.json index cc6dac8a35bb03..c80ba5f2e06f8a 100644 --- a/homeassistant/components/plex/.translations/no.json +++ b/homeassistant/components/plex/.translations/no.json @@ -4,7 +4,7 @@ "all_configured": "Alle knyttet servere som allerede er konfigurert", "already_configured": "Denne Plex-serveren er allerede konfigurert", "already_in_progress": "Plex blir konfigurert", - "discovery_no_file": "Ingen eldre konfigurasjonsfil ble funnet", + "discovery_no_file": "Ingen eldre konfigurasjonsfil funnet", "invalid_import": "Den importerte konfigurasjonen er ugyldig", "non-interactive": "Ikke-interaktiv import", "token_request_timeout": "Tidsavbrudd ved innhenting av token", @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorer nye administrerte/delte brukere", + "monitored_users": "Overv\u00e5kede brukere", "show_all_controls": "Vis alle kontroller", "use_episode_art": "Bruk episode bilde" }, diff --git a/homeassistant/components/plex/.translations/pl.json b/homeassistant/components/plex/.translations/pl.json index d752899b9f0e76..6531b552000e2c 100644 --- a/homeassistant/components/plex/.translations/pl.json +++ b/homeassistant/components/plex/.translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "all_configured": "Wszystkie znalezione serwery s\u0105 ju\u017c skonfigurowane.", - "already_configured": "Serwer Plex jest ju\u017c skonfigurowany", + "already_configured": "Ten serwer Plex jest ju\u017c skonfigurowany.", "already_in_progress": "Plex jest konfigurowany", "discovery_no_file": "Nie znaleziono pliku konfiguracyjnego", "invalid_import": "Zaimportowana konfiguracja jest nieprawid\u0142owa", @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignoruj nowych zarz\u0105dzanych/wsp\u00f3\u0142dzielonych u\u017cytkownik\u00f3w", + "monitored_users": "Monitorowani u\u017cytkownicy", "show_all_controls": "Poka\u017c wszystkie elementy steruj\u0105ce", "use_episode_art": "U\u017cyj grafiki odcinka" }, diff --git a/homeassistant/components/plex/.translations/ru.json b/homeassistant/components/plex/.translations/ru.json index 334a4e353d4c6a..2da10b1e8c47ae 100644 --- a/homeassistant/components/plex/.translations/ru.json +++ b/homeassistant/components/plex/.translations/ru.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "\u0418\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0445 \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445/\u043e\u0431\u0449\u0438\u0445 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439", + "monitored_users": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", "show_all_controls": "\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0432\u0441\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f", "use_episode_art": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043e\u0431\u043b\u043e\u0436\u043a\u0438 \u044d\u043f\u0438\u0437\u043e\u0434\u043e\u0432" }, diff --git a/homeassistant/components/plex/.translations/sv.json b/homeassistant/components/plex/.translations/sv.json index 702cec128c0353..25152e9dc81270 100644 --- a/homeassistant/components/plex/.translations/sv.json +++ b/homeassistant/components/plex/.translations/sv.json @@ -1,10 +1,62 @@ { + "config": { + "abort": { + "all_configured": "Alla l\u00e4nkade servrar har redan konfigurerats", + "already_configured": "Denna Plex-server \u00e4r redan konfigurerad", + "already_in_progress": "Plex konfigureras", + "discovery_no_file": "Ingen \u00e4ldre konfigurationsfil hittades", + "invalid_import": "Importerad konfiguration \u00e4r ogiltig", + "non-interactive": "Icke-interaktiv import", + "token_request_timeout": "Timeout att erh\u00e5lla token", + "unknown": "Misslyckades av ok\u00e4nd anledning" + }, + "error": { + "faulty_credentials": "Auktoriseringen misslyckades", + "no_servers": "Inga servrar l\u00e4nkade till konto", + "no_token": "Ange en token eller v\u00e4lj manuell inst\u00e4llning", + "not_found": "Plex-server hittades inte" + }, + "step": { + "manual_setup": { + "data": { + "host": "V\u00e4rd", + "port": "Port", + "ssl": "Anv\u00e4nd SSL", + "token": "Token (om det beh\u00f6vs)", + "verify_ssl": "Verifiera SSL-certifikat" + }, + "title": "Plex-server" + }, + "select_server": { + "data": { + "server": "Server" + }, + "description": "V\u00e4lj flera servrar tillg\u00e4ngliga, v\u00e4lj en:", + "title": "V\u00e4lj Plex-server" + }, + "start_website_auth": { + "description": "Forts\u00e4tt att auktorisera p\u00e5 plex.tv.", + "title": "Anslut Plex-servern" + }, + "user": { + "data": { + "manual_setup": "Manuell inst\u00e4llning", + "token": "Plex-token" + }, + "description": "Forts\u00e4tt att auktorisera p\u00e5 plex.tv eller konfigurera en server manuellt.", + "title": "Anslut Plex-servern" + } + }, + "title": "Plex" + }, "options": { "step": { "plex_mp_settings": { "data": { - "show_all_controls": "Visa alla kontroller" - } + "show_all_controls": "Visa alla kontroller", + "use_episode_art": "Anv\u00e4nd avsnittsbild" + }, + "description": "Alternativ f\u00f6r Plex-mediaspelare" } } } diff --git a/homeassistant/components/plex/.translations/zh-Hans.json b/homeassistant/components/plex/.translations/zh-Hans.json new file mode 100644 index 00000000000000..614f83e3cc0f0d --- /dev/null +++ b/homeassistant/components/plex/.translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "options": { + "step": { + "plex_mp_settings": { + "data": { + "ignore_new_shared_users": "\u5ffd\u7565\u65b0\u589e\u7ba1\u7406/\u5171\u4eab\u4f7f\u7528\u8005", + "monitored_users": "\u53d7\u76d1\u89c6\u7684\u7528\u6237" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/zh-Hant.json b/homeassistant/components/plex/.translations/zh-Hant.json index 5c05d2104f9b0a..436333b0a79c64 100644 --- a/homeassistant/components/plex/.translations/zh-Hant.json +++ b/homeassistant/components/plex/.translations/zh-Hant.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "\u5ffd\u7565\u65b0\u589e\u7ba1\u7406/\u5206\u4eab\u4f7f\u7528\u8005", + "monitored_users": "\u5df2\u76e3\u63a7\u4f7f\u7528\u8005", "show_all_controls": "\u986f\u793a\u6240\u6709\u63a7\u5236", "use_episode_art": "\u4f7f\u7528\u5f71\u96c6\u5287\u7167" }, diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 89659769192599..c9b120f75f644d 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -19,6 +19,7 @@ CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, ) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -27,6 +28,7 @@ ) from .const import ( + CONF_IGNORE_NEW_SHARED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, CONF_SHOW_ALL_CONTROLS, @@ -50,6 +52,7 @@ { vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, + vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean, } ) @@ -127,7 +130,7 @@ async def async_setup_entry(hass, entry): server_config[CONF_URL], error, ) - return False + raise ConfigEntryNotReady except ( plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index d38d13c847e6be..19cec6dfb8b244 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,12 +14,15 @@ from homeassistant.const import CONF_SSL, CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json from .const import ( # pylint: disable=unused-import AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, CONF_CLIENT_IDENTIFIER, + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, CONF_SHOW_ALL_CONTROLS, @@ -28,6 +31,7 @@ DOMAIN, PLEX_CONFIG_FILE, PLEX_SERVER_CONFIG, + SERVERS, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, @@ -52,7 +56,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Plex config flow.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH @staticmethod @callback @@ -254,6 +258,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry): """Initialize Plex options flow.""" self.options = copy.deepcopy(config_entry.options) + self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input=None): """Manage the Plex options.""" @@ -261,6 +266,8 @@ async def async_step_init(self, user_input=None): async def async_step_plex_mp_settings(self, user_input=None): """Manage the Plex media_player options.""" + plex_server = self.hass.data[DOMAIN][SERVERS][self.server_id] + if user_input is not None: self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ CONF_USE_EPISODE_ART @@ -268,19 +275,56 @@ async def async_step_plex_mp_settings(self, user_input=None): self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] = user_input[ CONF_SHOW_ALL_CONTROLS ] + self.options[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = user_input[ + CONF_IGNORE_NEW_SHARED_USERS + ] + + account_data = { + user: {"enabled": bool(user in user_input[CONF_MONITORED_USERS])} + for user in plex_server.accounts + } + + self.options[MP_DOMAIN][CONF_MONITORED_USERS] = account_data + return self.async_create_entry(title="", data=self.options) + available_accounts = {name: name for name in plex_server.accounts} + available_accounts[plex_server.owner] += " [Owner]" + + default_accounts = plex_server.accounts + known_accounts = set(plex_server.option_monitored_users) + if known_accounts: + default_accounts = { + user + for user in plex_server.option_monitored_users + if plex_server.option_monitored_users[user]["enabled"] + } + for user in plex_server.accounts: + if user not in known_accounts: + available_accounts[user] += " [New]" + + if not plex_server.option_ignore_new_shared_users: + for new_user in plex_server.accounts - known_accounts: + default_accounts.add(new_user) + return self.async_show_form( step_id="plex_mp_settings", data_schema=vol.Schema( { vol.Required( CONF_USE_EPISODE_ART, - default=self.options[MP_DOMAIN][CONF_USE_EPISODE_ART], + default=plex_server.option_use_episode_art, ): bool, vol.Required( CONF_SHOW_ALL_CONTROLS, - default=self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS], + default=plex_server.option_show_all_controls, + ): bool, + vol.Optional( + CONF_MONITORED_USERS, default=default_accounts + ): cv.multi_select(available_accounts), + vol.Required( + CONF_IGNORE_NEW_SHARED_USERS, + default=plex_server.option_ignore_new_shared_users, ): bool, } ), diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index ad62bade1fd0f9..7d6812674ca9af 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -3,6 +3,7 @@ DOMAIN = "plex" NAME_FORMAT = "Plex ({})" +COMMON_PLAYERS = ["Plex Web"] DEFAULT_PORT = 32400 DEFAULT_SSL = False @@ -28,6 +29,8 @@ CONF_SERVER_IDENTIFIER = "server_id" CONF_USE_EPISODE_ART = "use_episode_art" CONF_SHOW_ALL_CONTROLS = "show_all_controls" +CONF_IGNORE_NEW_SHARED_USERS = "ignore_new_shared_users" +CONF_MONITORED_USERS = "monitored_users" AUTH_CALLBACK_PATH = "/auth/plex/callback" AUTH_CALLBACK_NAME = "auth:plex:callback" diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 46b797976abc95..47e5ba6104f3d6 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -21,19 +21,14 @@ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) -from homeassistant.const import ( - DEVICE_DEFAULT_NAME, - STATE_IDLE, - STATE_OFF, - STATE_PAUSED, - STATE_PLAYING, -) +from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.util import dt as dt_util from .const import ( + COMMON_PLAYERS, CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN as PLEX_DOMAIN, @@ -59,6 +54,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): server_id = config_entry.data[CONF_SERVER_IDENTIFIER] registry = await async_get_registry(hass) + @callback def async_new_media_players(new_entities): _async_add_entities( hass, registry, config_entry, async_add_entities, server_id, new_entities @@ -113,6 +109,10 @@ def __init__(self, plex_server, device, session=None): self._is_player_active = False self._machine_identifier = device.machineIdentifier self._make = "" + self._device_platform = None + self._device_product = None + self._device_title = None + self._device_version = None self._name = None self._player_state = "idle" self._previous_volume_level = 1 # Used in fake muting @@ -127,6 +127,7 @@ def __init__(self, plex_server, device, session=None): self._media_content_type = None self._media_duration = None self._media_image_url = None + self._media_summary = None self._media_title = None self._media_position = None self._media_position_updated_at = None @@ -168,6 +169,7 @@ def _clear_media_details(self): self._media_content_type = None self._media_duration = None self._media_image_url = None + self._media_summary = None self._media_title = None # Music self._media_album_artist = None @@ -187,7 +189,6 @@ def update(self): self._clear_media_details() self._available = self.device or self.session - name_base = None if self.device: try: @@ -196,7 +197,10 @@ def update(self): device_url = "127.0.0.1" if "127.0.0.1" in device_url: self.device.proxyThroughServer() - name_base = self.device.title or self.device.product + self._device_platform = self.device.platform + self._device_product = self.device.product + self._device_title = self.device.title + self._device_version = self.device.version self._device_protocol_capabilities = self.device.protocolCapabilities self._player_state = self.device.state @@ -214,11 +218,15 @@ def update(self): if session_device: self._make = session_device.device or "" self._player_state = session_device.state - name_base = name_base or session_device.title or session_device.product + self._device_platform = self._device_platform or session_device.platform + self._device_product = self._device_product or session_device.product + self._device_title = self._device_title or session_device.title + self._device_version = self._device_version or session_device.version else: _LOGGER.warning("No player associated with active session") - self._session_username = self.session.usernames[0] + if self.session.usernames: + self._session_username = self.session.usernames[0] # Calculate throttled position for proper progress display. position = int(self.session.viewOffset / 1000) @@ -236,13 +244,21 @@ def update(self): self._media_content_id = self.session.ratingKey self._media_content_rating = getattr(self.session, "contentRating", None) - self._name = self._name or NAME_FORMAT.format(name_base or DEVICE_DEFAULT_NAME) + name_parts = [self._device_product, self._device_title or self._device_platform] + if (self._device_product in COMMON_PLAYERS) and self.make: + # Add more context in name for likely duplicates + name_parts.append(self.make) + if self.username and self.username != self.plex_server.owner: + # Prepend username for shared/managed clients + name_parts.insert(0, self.username) + self._name = NAME_FORMAT.format(" - ".join(name_parts)) self._set_player_state() if self._is_player_active and self.session is not None: self._session_type = self.session.type self._media_duration = int(self.session.duration / 1000) # title (movie name, tv episode name, music song name) + self._media_summary = self.session.summary self._media_title = self.session.title # media type self._set_media_type() @@ -259,7 +275,7 @@ def _set_media_image(self): thumb_url = self.session.thumbUrl if ( self.media_content_type is MEDIA_TYPE_TVSHOW - and not self.plex_server.use_episode_art + and not self.plex_server.option_use_episode_art ): thumb_url = self.session.url(self.session.grandparentThumb) @@ -347,6 +363,11 @@ def name(self): """Return the name of the device.""" return self._name + @property + def username(self): + """Return the username of the client owner.""" + return self._session_username + @property def app_name(self): """Return the library name of playing media.""" @@ -427,6 +448,11 @@ def media_image_url(self): """Return the image URL of current playing media.""" return self._media_image_url + @property + def media_summary(self): + """Return the summary of current playing media.""" + return self._media_summary + @property def media_title(self): """Return the title of current playing media.""" @@ -456,7 +482,7 @@ def make(self): def supported_features(self): """Flag media player features that are supported.""" # force show all controls - if self.plex_server.show_all_controls: + if self.plex_server.option_show_all_controls: return ( SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK @@ -698,8 +724,24 @@ def device_state_attributes(self): """Return the scene state attributes.""" attr = { "media_content_rating": self._media_content_rating, - "session_username": self._session_username, + "session_username": self.username, "media_library_name": self._app_name, + "summary": self.media_summary, } return attr + + @property + def device_info(self): + """Return a device description for device registry.""" + if self.machine_identifier is None: + return None + + return { + "identifiers": {(PLEX_DOMAIN, self.machine_identifier)}, + "manufacturer": self._device_platform or "Plex", + "model": self._device_product or self.make, + "name": self.name, + "sw_version": self._device_version, + "via_device": (PLEX_DOMAIN, self.plex_server.machine_identifier), + } diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 2aed57946eb722..b1e93aec8c00da 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -101,23 +101,24 @@ def update(self): _LOGGER.debug("Refreshing sensor [%s]", self.unique_id) now_playing = [] for sess in self.sessions: + if sess.TYPE == "photo": + _LOGGER.debug("Photo session detected, skipping: %s", sess) + continue user = sess.usernames[0] device = sess.players[0].title now_playing_user = f"{user} - {device}" now_playing_title = "" - if sess.TYPE == "episode": + if sess.TYPE in ["clip", "episode"]: # example: - # "Supernatural (2005) - S01 · E13 - Route 666" + # "Supernatural (2005) - s01e13 - Route 666" season_title = sess.grandparentTitle if sess.show().year is not None: - season_title += " ({0})".format(sess.show().year) - season_episode = "S{0}".format(sess.parentIndex) - if sess.index is not None: - season_episode += f" · E{sess.index}" + season_title += f" ({sess.show().year!s})" + season_episode = sess.seasonEpisode episode_title = sess.title - now_playing_title = "{0} - {1} - {2}".format( - season_title, season_episode, episode_title + now_playing_title = ( + f"{season_title} - {season_episode} - {episode_title}" ) elif sess.TYPE == "track": # example: @@ -125,9 +126,7 @@ def update(self): track_artist = sess.grandparentTitle track_album = sess.parentTitle track_title = sess.title - now_playing_title = "{0} - {1} - {2}".format( - track_artist, track_album, track_title - ) + now_playing_title = f"{track_artist} - {track_album} - {track_title}" else: # example: # "picture_of_last_summer_camp (2015)" @@ -139,3 +138,17 @@ def update(self): now_playing.append((now_playing_user, now_playing_title)) self._state = len(self.sessions) self._now_playing = now_playing + + @property + def device_info(self): + """Return a device description for device registry.""" + if self.unique_id is None: + return None + + return { + "identifiers": {(PLEX_DOMAIN, self._server.machine_identifier)}, + "manufacturer": "Plex", + "model": "Plex Media Server", + "name": "Activity Sensor", + "sw_version": self._server.version, + } diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 46602cf6552e6e..5532362b87aee5 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -13,6 +13,8 @@ from .const import ( CONF_CLIENT_IDENTIFIER, + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, CONF_SERVER, CONF_SHOW_ALL_CONTROLS, CONF_USE_EPISODE_ART, @@ -51,6 +53,9 @@ def __init__(self, hass, server_config, options=None): self._verify_ssl = server_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) self.options = options self.server_choice = None + self._accounts = [] + self._owner_username = None + self._version = None # Header conditionally added as it is not available in config entry v1 if CONF_CLIENT_IDENTIFIER in server_config: @@ -93,6 +98,22 @@ def _connect_with_url(): else: _connect_with_token() + self._accounts = [ + account.name + for account in self._plex_server.systemAccounts() + if account.name + ] + + owner_account = [ + account.name + for account in self._plex_server.systemAccounts() + if account.accountID == 1 + ] + if owner_account: + self._owner_username = owner_account[0] + + self._version = self._plex_server.version + def refresh_entity(self, machine_identifier, device, session): """Forward refresh dispatch to media_player.""" unique_id = f"{self.machine_identifier}:{machine_identifier}" @@ -109,8 +130,22 @@ def update_platforms(self): _LOGGER.debug("Updating devices") available_clients = {} + ignored_clients = set() new_clients = set() + monitored_users = self.accounts + known_accounts = set(self.option_monitored_users) + if known_accounts: + monitored_users = { + user + for user in self.option_monitored_users + if self.option_monitored_users[user]["enabled"] + } + + if not self.option_ignore_new_shared_users: + for new_user in self.accounts - known_accounts: + monitored_users.add(new_user) + try: devices = self._plex_server.clients() sessions = self._plex_server.sessions() @@ -132,7 +167,15 @@ def update_platforms(self): _LOGGER.debug("New device: %s", device.machineIdentifier) for session in sessions: + if session.TYPE == "photo": + _LOGGER.debug("Photo session detected, skipping: %s", session) + continue + session_username = session.usernames[0] for player in session.players: + if session_username not in monitored_users: + ignored_clients.add(player.machineIdentifier) + _LOGGER.debug("Ignoring Plex client owned by %s", session_username) + continue self._known_idle.discard(player.machineIdentifier) available_clients.setdefault( player.machineIdentifier, {"device": player} @@ -145,6 +188,8 @@ def update_platforms(self): new_entity_configs = [] for client_id, client_data in available_clients.items(): + if client_id in ignored_clients: + continue if client_id in new_clients: new_entity_configs.append(client_data) else: @@ -152,11 +197,11 @@ def update_platforms(self): client_id, client_data["device"], client_data.get("session") ) - self._known_clients.update(new_clients) + self._known_clients.update(new_clients | ignored_clients) - idle_clients = (self._known_clients - self._known_idle).difference( - available_clients - ) + idle_clients = ( + self._known_clients - self._known_idle - ignored_clients + ).difference(available_clients) for client_id in idle_clients: self.refresh_entity(client_id, None, None) self._known_idle.add(client_id) @@ -179,6 +224,21 @@ def plex_server(self): """Return the plexapi PlexServer instance.""" return self._plex_server + @property + def accounts(self): + """Return accounts associated with the Plex server.""" + return set(self._accounts) + + @property + def owner(self): + """Return the Plex server owner username.""" + return self._owner_username + + @property + def version(self): + """Return the version of the Plex server.""" + return self._version + @property def friendly_name(self): """Return name of connected Plex server.""" @@ -195,15 +255,25 @@ def url_in_use(self): return self._plex_server._baseurl # pylint: disable=protected-access @property - def use_episode_art(self): + def option_ignore_new_shared_users(self): + """Return ignore_new_shared_users option.""" + return self.options[MP_DOMAIN].get(CONF_IGNORE_NEW_SHARED_USERS, False) + + @property + def option_use_episode_art(self): """Return use_episode_art option.""" return self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] @property - def show_all_controls(self): + def option_show_all_controls(self): """Return show_all_controls option.""" return self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] + @property + def option_monitored_users(self): + """Return dict of monitored users option.""" + return self.options[MP_DOMAIN].get(CONF_MONITORED_USERS, {}) + @property def library(self): """Return library attribute from server object.""" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index b6491db350cf90..1f99e28df8b76f 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -23,7 +23,7 @@ "all_configured": "All linked servers already configured", "already_configured": "This Plex server is already configured", "already_in_progress": "Plex is being configured", - "discovery_no_file": "No legacy config file found", + "discovery_no_file": "No legacy configuration file found", "invalid_import": "Imported configuration is invalid", "non-interactive": "Non-interactive import", "token_request_timeout": "Timed out obtaining token", @@ -36,7 +36,9 @@ "description": "Options for Plex Media Players", "data": { "use_episode_art": "Use episode art", - "show_all_controls": "Show all controls" + "show_all_controls": "Show all controls", + "ignore_new_shared_users": "Ignore new managed/shared users", + "monitored_users": "Monitored users" } } } diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 67e94c70f5c4b8..9b519f969e0214 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -13,6 +13,7 @@ HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) @@ -66,7 +67,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): - """Add the Plugwise (Anna) Thermostate.""" + """Add the Plugwise (Anna) Thermostat.""" api = haanna.Haanna( config[CONF_USERNAME], config[CONF_PASSWORD], @@ -88,7 +89,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ThermostatDevice(ClimateDevice): - """Representation of an Plugwise thermostat.""" + """Representation of the Plugwise thermostat.""" def __init__(self, api, name, min_temp, max_temp): """Set up the Plugwise API.""" @@ -96,14 +97,18 @@ def __init__(self, api, name, min_temp, max_temp): self._min_temp = min_temp self._max_temp = max_temp self._name = name + self._direct_objects = None self._domain_objects = None self._outdoor_temperature = None self._selected_schema = None + self._last_active_schema = None self._preset_mode = None self._presets = None self._presets_list = None + self._boiler_status = None self._heating_status = None self._cooling_status = None + self._dhw_status = None self._schema_names = None self._schema_status = None self._current_temperature = None @@ -115,8 +120,8 @@ def __init__(self, api, name, min_temp, max_temp): @property def hvac_action(self): - """Return the current action.""" - if self._heating_status: + """Return the current hvac action.""" + if self._heating_status or self._boiler_status or self._dhw_status: return CURRENT_HVAC_HEAT if self._cooling_status: return CURRENT_HVAC_COOL @@ -143,8 +148,10 @@ def device_state_attributes(self): attributes = {} if self._outdoor_temperature: attributes["outdoor_temperature"] = self._outdoor_temperature - attributes["available_schemas"] = self._schema_names - attributes["selected_schema"] = self._selected_schema + if self._schema_names: + attributes["available_schemas"] = self._schema_names + if self._selected_schema: + attributes["selected_schema"] = self._selected_schema if self._boiler_temperature: attributes["boiler_temperature"] = self._boiler_temperature if self._water_pressure: @@ -162,7 +169,7 @@ def preset_modes(self): @property def hvac_modes(self): """Return the available hvac modes list.""" - if self._heating_status is not None: + if self._heating_status is not None or self._boiler_status is not None: if self._cooling_status is not None: return HVAC_MODES_2 return HVAC_MODES_1 @@ -173,11 +180,11 @@ def hvac_mode(self): """Return current active hvac state.""" if self._schema_status: return HVAC_MODE_AUTO - if self._heating_status: + if self._heating_status or self._boiler_status or self._dhw_status: if self._cooling_status: return HVAC_MODE_HEAT_COOL return HVAC_MODE_HEAT - return None + return HVAC_MODE_OFF @property def target_temperature(self): @@ -193,9 +200,9 @@ def target_temperature(self): def preset_mode(self): """Return the active selected schedule-name. - Or return the active preset, or return Temporary in case of a manual change - in the set-temperature with a weekschedule active, - or return Manual in case of a manual change and no weekschedule active. + Or, return the active preset, or return Temporary in case of a manual change + in the set-temperature with a weekschedule active. + Or return Manual in case of a manual change and no weekschedule active. """ if self._presets: presets = self._presets @@ -248,7 +255,7 @@ def set_hvac_mode(self, hvac_mode): if hvac_mode == HVAC_MODE_AUTO: schema_mode = "true" self._api.set_schema_state( - self._domain_objects, self._selected_schema, schema_mode + self._domain_objects, self._last_active_schema, schema_mode ) def set_preset_mode(self, preset_mode): @@ -259,16 +266,22 @@ def set_preset_mode(self, preset_mode): def update(self): """Update the data from the thermostat.""" _LOGGER.debug("Update called") + self._direct_objects = self._api.get_direct_objects() self._domain_objects = self._api.get_domain_objects() self._outdoor_temperature = self._api.get_outdoor_temperature( self._domain_objects ) self._selected_schema = self._api.get_active_schema_name(self._domain_objects) + self._last_active_schema = self._api.get_last_active_schema_name( + self._domain_objects + ) self._preset_mode = self._api.get_current_preset(self._domain_objects) self._presets = self._api.get_presets(self._domain_objects) self._presets_list = list(self._api.get_presets(self._domain_objects)) - self._heating_status = self._api.get_heating_status(self._domain_objects) - self._cooling_status = self._api.get_cooling_status(self._domain_objects) + self._boiler_status = self._api.get_boiler_status(self._direct_objects) + self._heating_status = self._api.get_heating_status(self._direct_objects) + self._cooling_status = self._api.get_cooling_status(self._direct_objects) + self._dhw_status = self._api.get_domestic_hot_water_status(self._direct_objects) self._schema_names = self._api.get_schema_names(self._domain_objects) self._schema_status = self._api.get_schema_state(self._domain_objects) self._current_temperature = self._api.get_current_temperature( diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ccea2a67ead801..601f017d42f3ab 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/plugwise", "dependencies": [], "codeowners": ["@laetificat", "@CoMPaTech", "@bouwew"], - "requirements": ["haanna==0.13.5"] + "requirements": ["haanna==0.14.3"] } diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json index fd603aa0430e95..c4d9228532d415 100644 --- a/homeassistant/components/point/.translations/ca.json +++ b/homeassistant/components/point/.translations/ca.json @@ -3,7 +3,7 @@ "abort": { "already_setup": "Nom\u00e9s pots configurar un compte de Point.", "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", - "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "external_setup": "Point s'ha configurat correctament des d'un altre flux de dades.", "no_flows": "Necessites configurar Point abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/point/)." }, diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c20296a2c183db..d77cb4f56dae0e 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -334,7 +334,7 @@ def _sensor_override_component_metric(self, state, unit): @staticmethod def _sensor_fallback_metric(state, unit): - """Get metric from fallback logic for compatability.""" + """Get metric from fallback logic for compatibility.""" if unit in (None, ""): _LOGGER.debug("Unsupported sensor: %s", state.entity_id) return None diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 246dc2d48ade0f..315fb8b1c914a4 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -5,6 +5,7 @@ from proxmoxer import ProxmoxAPI from proxmoxer.backends.https import AuthenticationError +from requests.exceptions import SSLError import voluptuous as vol from homeassistant.const import ( @@ -18,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) + DOMAIN = "proxmoxve" PROXMOX_CLIENTS = "proxmox_clients" CONF_REALM = "realm" @@ -94,6 +96,11 @@ def setup(hass, config): "Invalid credentials for proxmox instance %s:%d", host, port ) continue + except SSLError: + _LOGGER.error( + 'Unable to verify proxmox server SSL. Try using "verify_ssl: false"' + ) + continue hass.data[PROXMOX_CLIENTS][f"{host}:{port}"] = proxmox_client @@ -140,12 +147,12 @@ def build_client(self): verify_ssl=self._verify_ssl, ) - self._connection_start_time = time.time() + self._connection_start_time = time.monotonic() def get_api_client(self): """Return the ProxmoxAPI client and rebuild it if necessary.""" - connection_age = time.time() - self._connection_start_time + connection_age = time.monotonic() - self._connection_start_time # Workaround for the Proxmoxer bug where the connection stops working after some time if connection_age > 30 * 60: diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 4781478eabe34f..c61d296587c26c 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "dependencies": [], "codeowners": ["@k4ds3"], - "requirements": ["proxmoxer==1.0.3"] + "requirements": ["proxmoxer==1.0.4"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 06498e51ad30d7..d12fbe2d3d776a 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -2,7 +2,9 @@ "domain": "proxy", "name": "Camera Proxy", "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["pillow==6.2.1"], + "requirements": [ + "pillow==7.0.0" + ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/de.json b/homeassistant/components/ps4/.translations/de.json index 6f4962a305dfaf..66eaecbb548eef 100644 --- a/homeassistant/components/ps4/.translations/de.json +++ b/homeassistant/components/ps4/.translations/de.json @@ -25,7 +25,7 @@ "name": "Name", "region": "Region" }, - "description": "Geben deine PlayStation 4-Informationen ein. Navigiere f\u00fcr \"PIN\" auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN ein.", + "description": "Gib deine PlayStation 4-Informationen ein. Navigiere f\u00fcr \"PIN\" auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN ein.", "title": "PlayStation 4" }, "mode": { diff --git a/homeassistant/components/ps4/.translations/hu.json b/homeassistant/components/ps4/.translations/hu.json index 77b13f33a51c3d..7a8623b90301d8 100644 --- a/homeassistant/components/ps4/.translations/hu.json +++ b/homeassistant/components/ps4/.translations/hu.json @@ -6,6 +6,7 @@ }, "link": { "data": { + "code": "PIN", "ip_address": "IP-c\u00edm", "name": "N\u00e9v", "region": "R\u00e9gi\u00f3" diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 44523aea85adcf..17c0eb5838c0b9 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -30,6 +30,8 @@ TCP_PORT = 997 PORT_MSG = {UDP_PORT: "port_987_bind_error", TCP_PORT: "port_997_bind_error"} +PIN_LENGTH = 8 + @config_entries.HANDLERS.register(DOMAIN) class PlayStation4FlowHandler(config_entries.ConfigFlow): @@ -143,7 +145,8 @@ async def async_step_link(self, user_input=None): if user_input is not None: self.region = user_input[CONF_REGION] self.name = user_input[CONF_NAME] - self.pin = str(user_input[CONF_CODE]) + # Assume pin had leading zeros, before coercing to int. + self.pin = str(user_input[CONF_CODE]).zfill(PIN_LENGTH) self.host = user_input[CONF_IP_ADDRESS] is_ready, is_login = await self.hass.async_add_executor_job( @@ -184,7 +187,7 @@ async def async_step_link(self, user_input=None): list(regions) ) link_schema[vol.Required(CONF_CODE)] = vol.All( - vol.Strip, vol.Length(min=8, max=8), vol.Coerce(int) + vol.Strip, vol.Length(max=PIN_LENGTH), vol.Coerce(int) ) link_schema[vol.Required(CONF_NAME, default=DEFAULT_NAME)] = str diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index c0ab470691e1c9..779da61ca48ea3 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -8,7 +8,7 @@ GAMES_FILE = ".ps4-games.json" PS4_DATA = "ps4_data" -COMMANDS = ("up", "down", "right", "left", "enter", "back", "option", "ps") +COMMANDS = ("up", "down", "right", "left", "enter", "back", "option", "ps", "ps_hold") # Deprecated used for logger/backwards compatibility from 0.89 REGIONS = ["R1", "R2", "R3", "R4", "R5"] diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 7a52a99e08b189..80c12cc746cabb 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -3,7 +3,7 @@ "name": "Sony PlayStation 4", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ps4", - "requirements": ["pyps4-2ndscreen==1.0.4"], + "requirements": ["pyps4-2ndscreen==1.0.7"], "dependencies": [], "codeowners": ["@ktnrg45"] } diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 33b5c556c7da1b..28d201d78cd8ae 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -97,11 +97,7 @@ def __init__(self, config, name, host, region, ps4, creds): def status_callback(self): """Handle status callback. Parse status.""" self._parse_status() - - @callback - def schedule_update(self): - """Schedules update with HA.""" - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def subscribe_to_protocol(self): @@ -184,7 +180,6 @@ def _parse_status(self): self._media_content_id = title_id if self._use_saved(): _LOGGER.debug("Using saved data for media: %s", title_id) - self.schedule_update() return self._media_title = name @@ -223,13 +218,11 @@ def idle(self): """Set states for state idle.""" self.reset_title() self._state = STATE_IDLE - self.schedule_update() def state_standby(self): """Set states for state standby.""" self.reset_title() self._state = STATE_STANDBY - self.schedule_update() def state_unknown(self): """Set states for state unknown.""" @@ -286,8 +279,8 @@ async def async_get_title_data(self, title_id, name): self._media_image = art or None self._media_type = media_type - self.update_list() - self.schedule_update() + await self.hass.async_add_executor_job(self.update_list) + self.async_write_ha_state() def update_list(self): """Update Game List, Correct data if different.""" diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index 3428e429b8c954..9bdd1bb53f9f80 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -2,7 +2,7 @@ "domain": "pushover", "name": "Pushover", "documentation": "https://www.home-assistant.io/integrations/pushover", - "requirements": ["python-pushover==0.4"], + "requirements": ["pushover_complete==1.1.1"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index 1930ff66f2e936..bc44cbeddb7419 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -1,8 +1,7 @@ """Pushover platform for notify component.""" import logging -from pushover import Client, InitError, RequestError -import requests +from pushover_complete import PushoverAPI import voluptuous as vol from homeassistant.components.notify import ( @@ -19,6 +18,15 @@ _LOGGER = logging.getLogger(__name__) ATTR_ATTACHMENT = "attachment" +ATTR_URL = "url" +ATTR_URL_TITLE = "url_title" +ATTR_PRIORITY = "priority" +ATTR_RETRY = "retry" +ATTR_SOUND = "sound" +ATTR_HTML = "html" +ATTR_CALLBACK_URL = "callback_url" +ATTR_EXPIRE = "expire" +ATTR_TIMESTAMP = "timestamp" CONF_USER_KEY = "user_key" @@ -29,13 +37,9 @@ def get_service(hass, config, discovery_info=None): """Get the Pushover notification service.""" - try: - return PushoverNotificationService( - hass, config[CONF_USER_KEY], config[CONF_API_KEY] - ) - except InitError: - _LOGGER.error("Wrong API key supplied") - return None + return PushoverNotificationService( + hass, config[CONF_USER_KEY], config[CONF_API_KEY] + ) class PushoverNotificationService(BaseNotificationService): @@ -46,54 +50,42 @@ def __init__(self, hass, user_key, api_token): self._hass = hass self._user_key = user_key self._api_token = api_token - self.pushover = Client(self._user_key, api_token=self._api_token) + self.pushover = PushoverAPI(self._api_token) def send_message(self, message="", **kwargs): """Send a message to a user.""" - # Make a copy and use empty dict if necessary - data = dict(kwargs.get(ATTR_DATA) or {}) - - data["title"] = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - # Check for attachment. - if ATTR_ATTACHMENT in data: - # If attachment is a URL, use requests to open it as a stream. - if data[ATTR_ATTACHMENT].startswith("http"): + # Extract params from data dict + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + data = dict(kwargs.get(ATTR_DATA) or {}) + url = data.get(ATTR_URL, None) + url_title = data.get(ATTR_URL_TITLE, None) + priority = data.get(ATTR_PRIORITY, None) + retry = data.get(ATTR_PRIORITY, None) + expire = data.get(ATTR_EXPIRE, None) + callback_url = data.get(ATTR_CALLBACK_URL, None) + timestamp = data.get(ATTR_TIMESTAMP, None) + sound = data.get(ATTR_SOUND, None) + html = 1 if data.get(ATTR_HTML, False) else 0 + + image = data.get(ATTR_ATTACHMENT, None) + # Check for attachment + if image is not None: + # Only allow attachments from whitelisted paths, check valid path + if self._hass.config.is_allowed_path(data[ATTR_ATTACHMENT]): + # try to open it as a normal file. try: - response = requests.get( - data[ATTR_ATTACHMENT], stream=True, timeout=5 - ) - if response.status_code == 200: - # Replace the attachment identifier with file object. - data[ATTR_ATTACHMENT] = response.content - else: - _LOGGER.error( - "Failed to download image %s, response code: %d", - data[ATTR_ATTACHMENT], - response.status_code, - ) - # Remove attachment key to send without attachment. - del data[ATTR_ATTACHMENT] - except requests.exceptions.RequestException as ex_val: + file_handle = open(data[ATTR_ATTACHMENT], "rb") + # Replace the attachment identifier with file object. + image = file_handle + except OSError as ex_val: _LOGGER.error(ex_val) - # Remove attachment key to try sending without attachment - del data[ATTR_ATTACHMENT] - else: - # Not a URL, check valid path first - if self._hass.config.is_allowed_path(data[ATTR_ATTACHMENT]): - # try to open it as a normal file. - try: - file_handle = open(data[ATTR_ATTACHMENT], "rb") - # Replace the attachment identifier with file object. - data[ATTR_ATTACHMENT] = file_handle - except OSError as ex_val: - _LOGGER.error(ex_val) - # Remove attachment key to send without attachment. - del data[ATTR_ATTACHMENT] - else: - _LOGGER.error("Path is not whitelisted") # Remove attachment key to send without attachment. - del data[ATTR_ATTACHMENT] + image = None + else: + _LOGGER.error("Path is not whitelisted") + # Remove attachment key to send without attachment. + image = None targets = kwargs.get(ATTR_TARGET) @@ -101,12 +93,22 @@ def send_message(self, message="", **kwargs): targets = [targets] for target in targets: - if target is not None: - data["device"] = target - try: - self.pushover.send_message(message, **data) + self.pushover.send_message( + self._user_key, + message, + target, + title, + url, + url_title, + image, + priority, + retry, + expire, + callback_url, + timestamp, + sound, + html, + ) except ValueError as val_err: _LOGGER.error(val_err) - except RequestError: - _LOGGER.exception("Could not send pushover notification") diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index fd4461e3e1b1eb..579919821a34da 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -16,6 +16,7 @@ CONF_SSL, CONF_USERNAME, CONTENT_TYPE_JSON, + DATA_RATE_MEGABYTES_PER_SECOND, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -29,7 +30,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) -SENSOR_TYPES = {"speed": ["speed", "Speed", "MB/s"]} +SENSOR_TYPES = {"speed": ["speed", "Speed", DATA_RATE_MEGABYTES_PER_SECOND]} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 9544d74b1cde5e..46f82e99a62df2 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -11,6 +11,7 @@ CONF_PASSWORD, CONF_URL, CONF_USERNAME, + DATA_RATE_KILOBYTES_PER_SECOND, STATE_IDLE, ) from homeassistant.exceptions import PlatformNotReady @@ -27,8 +28,8 @@ SENSOR_TYPES = { SENSOR_TYPE_CURRENT_STATUS: ["Status", None], - SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", "kB/s"], - SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", "kB/s"], + SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], + SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json index 6720120b5e2df5..3c64986c2bca92 100644 --- a/homeassistant/components/qnap/manifest.json +++ b/homeassistant/components/qnap/manifest.json @@ -2,7 +2,7 @@ "domain": "qnap", "name": "QNAP", "documentation": "https://www.home-assistant.io/integrations/qnap", - "requirements": ["qnapstats==0.2.7"], + "requirements": ["qnapstats==0.3.0"], "dependencies": [], "codeowners": ["@colinodell"] } diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index c3863bd0077c69..1ad53f4db486f7 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -16,6 +16,8 @@ CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, + DATA_GIBIBYTES, + DATA_RATE_MEBIBYTES_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.exceptions import PlatformNotReady @@ -62,22 +64,22 @@ "cpu_usage": ["CPU Usage", "%", "mdi:chip"], } _MEMORY_MON_COND = { - "memory_free": ["Memory Available", "GB", "mdi:memory"], - "memory_used": ["Memory Used", "GB", "mdi:memory"], + "memory_free": ["Memory Available", DATA_GIBIBYTES, "mdi:memory"], + "memory_used": ["Memory Used", DATA_GIBIBYTES, "mdi:memory"], "memory_percent_used": ["Memory Usage", "%", "mdi:memory"], } _NETWORK_MON_COND = { "network_link_status": ["Network Link", None, "mdi:checkbox-marked-circle-outline"], - "network_tx": ["Network Up", "MB/s", "mdi:upload"], - "network_rx": ["Network Down", "MB/s", "mdi:download"], + "network_tx": ["Network Up", DATA_RATE_MEBIBYTES_PER_SECOND, "mdi:upload"], + "network_rx": ["Network Down", DATA_RATE_MEBIBYTES_PER_SECOND, "mdi:download"], } _DRIVE_MON_COND = { "drive_smart_status": ["SMART Status", None, "mdi:checkbox-marked-circle-outline"], "drive_temp": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], } _VOLUME_MON_COND = { - "volume_size_used": ["Used Space", "GB", "mdi:chart-pie"], - "volume_size_free": ["Free Space", "GB", "mdi:chart-pie"], + "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie"], + "volume_size_free": ["Free Space", DATA_GIBIBYTES, "mdi:chart-pie"], "volume_percentage_used": ["Volume Used", "%", "mdi:chart-pie"], } @@ -270,7 +272,7 @@ def device_state_attributes(self): if self._api.data: data = self._api.data["system_stats"]["memory"] size = round_nicely(float(data["total"]) / 1024) - return {ATTR_MEMORY_SIZE: f"{size} GB"} + return {ATTR_MEMORY_SIZE: f"{size} {DATA_GIBIBYTES}"} class QNAPNetworkSensor(QNAPSensor): @@ -399,4 +401,6 @@ def device_state_attributes(self): data = self._api.data["volumes"][self.monitor_device] total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 - return {ATTR_VOLUME_SIZE: "{} GB".format(round_nicely(total_gb))} + return { + ATTR_VOLUME_SIZE: "{} {}".format(round_nicely(total_gb), DATA_GIBIBYTES) + } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 6ea6db621fbbd8..cc2cde26aa52d9 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -2,7 +2,10 @@ "domain": "qrcode", "name": "QR Code", "documentation": "https://www.home-assistant.io/integrations/qrcode", - "requirements": ["pillow==6.2.1", "pyzbar==0.1.7"], + "requirements": [ + "pillow==7.0.0", + "pyzbar==0.1.7" + ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 79e45ffd9a82be..6cfdd53653d647 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -14,6 +14,15 @@ CONF_MONITORED_CONDITIONS, CONF_PORT, CONF_SSL, + DATA_BYTES, + DATA_EXABYTES, + DATA_GIGABYTES, + DATA_KILOBYTES, + DATA_MEGABYTES, + DATA_PETABYTES, + DATA_TERABYTES, + DATA_YOTTABYTES, + DATA_ZETTABYTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -29,12 +38,12 @@ DEFAULT_PORT = 7878 DEFAULT_URLBASE = "" DEFAULT_DAYS = "1" -DEFAULT_UNIT = "GB" +DEFAULT_UNIT = DATA_GIGABYTES SCAN_INTERVAL = timedelta(minutes=10) SENSOR_TYPES = { - "diskspace": ["Disk Space", "GB", "mdi:harddisk"], + "diskspace": ["Disk Space", DATA_GIGABYTES, "mdi:harddisk"], "upcoming": ["Upcoming", "Movies", "mdi:television"], "wanted": ["Wanted", "Movies", "mdi:television"], "movies": ["Movies", "Movies", "mdi:television"], @@ -51,7 +60,17 @@ } # Support to Yottabytes for the future, why not -BYTE_SIZES = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] +BYTE_SIZES = [ + DATA_BYTES, + DATA_KILOBYTES, + DATA_MEGABYTES, + DATA_GIGABYTES, + DATA_TERABYTES, + DATA_PETABYTES, + DATA_EXABYTES, + DATA_ZETTABYTES, + DATA_YOTTABYTES, +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index a6beeaa187b406..cba7a736df28e5 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -290,6 +290,8 @@ def update(self): ) return self._current_humidity = humiditydata + self._program_mode = data["program_mode"] + self._preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] # Map thermostat values into various STATE_ flags. self._current_temperature = current_temp @@ -297,8 +299,6 @@ def update(self): self._fstate = CODE_TO_FAN_STATE[data["fstate"]] self._tmode = CODE_TO_TEMP_MODE[data["tmode"]] self._tstate = CODE_TO_TEMP_STATE[data["tstate"]] - self._program_mode = data["program_mode"] - self._preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] self._current_operation = self._tmode if self._tmode == HVAC_MODE_COOL: diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index dd851c0b3e3029..41fefbe8fca4af 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -11,6 +11,8 @@ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, + TIME_DAYS, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -60,9 +62,9 @@ "is_watering": "", "manual_watering": "", "next_cycle": "", - "rain_delay": "days", + "rain_delay": TIME_DAYS, "status": "", - "watering_time": "min", + "watering_time": TIME_MINUTES, } BINARY_SENSORS = ["is_watering", "status"] diff --git a/homeassistant/components/rainforest_eagle/manifest.json b/homeassistant/components/rainforest_eagle/manifest.json index 59e4947f9bb604..cb8e95df42f0a9 100644 --- a/homeassistant/components/rainforest_eagle/manifest.json +++ b/homeassistant/components/rainforest_eagle/manifest.json @@ -2,7 +2,11 @@ "domain": "rainforest_eagle", "name": "Rainforest Eagle-200", "documentation": "https://www.home-assistant.io/integrations/rainforest_eagle", - "requirements": ["eagle200_reader==0.2.1"], + "requirements": [ + "eagle200_reader==0.2.1", + "uEagle==0.0.1" + ], "dependencies": [], - "codeowners": ["@gtdiehl"] + "codeowners": ["@gtdiehl", + "@jcalbert"] } diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 315bbdff51b787..99751e63f5b231 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -4,6 +4,7 @@ from eagle200_reader import EagleReader from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout +from uEagle import Eagle as LegacyReader import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -49,6 +50,25 @@ ) +def hwtest(cloud_id, install_code, ip_address): + """Try API call 'get_network_info' to see if target device is Legacy or Eagle-200.""" + reader = LeagleReader(cloud_id, install_code, ip_address) + response = reader.get_network_info() + + # Branch to test if target is Legacy Model + if "NetworkInfo" in response: + if response["NetworkInfo"].get("ModelId", None) == "Z109-EAGLE": + return reader + + # Branch to test if target is Eagle-200 Model + if "Response" in response: + if response["Response"].get("Command", None) == "get_network_info": + return EagleReader(ip_address, cloud_id, install_code) + + # Catch-all if hardware ID tests fail + raise ValueError("Couldn't determine device model.") + + def setup_platform(hass, config, add_entities, discovery_info=None): """Create the Eagle-200 sensor.""" ip_address = config[CONF_IP_ADDRESS] @@ -56,7 +76,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): install_code = config[CONF_INSTALL_CODE] try: - eagle_reader = EagleReader(ip_address, cloud_id, install_code) + eagle_reader = hwtest(cloud_id, install_code, ip_address) except (ConnectError, HTTPError, Timeout, ValueError) as error: _LOGGER.error("Failed to connect during setup: %s", error) return @@ -138,3 +158,21 @@ def get_state(self, sensor_type): state = self.data.get(sensor_type) _LOGGER.debug("Updating: %s - %s", sensor_type, state) return state + + +class LeagleReader(LegacyReader): + """Wraps uEagle to make it behave like eagle_reader, offering update().""" + + def update(self): + """Fetch and return the four sensor values in a dict.""" + out = {} + + resp = self.get_instantaneous_demand()["InstantaneousDemand"] + out["instantanous_demand"] = resp["Demand"] + + resp = self.get_current_summation()["CurrentSummation"] + out["summation_delivered"] = resp["SummationDelivered"] + out["summation_received"] = resp["SummationReceived"] + out["summation_total"] = out["summation_delivered"] - out["summation_received"] + + return out diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json index 60458f1469e8e5..494b1ecc69cd40 100644 --- a/homeassistant/components/rainmachine/.translations/ca.json +++ b/homeassistant/components/rainmachine/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquest controlador RainMachine ja est\u00e0 configurat." + }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", "invalid_credentials": "Credencials inv\u00e0lides" diff --git a/homeassistant/components/rainmachine/.translations/da.json b/homeassistant/components/rainmachine/.translations/da.json index 34f4fff4ed07b9..fe53a86993d3b8 100644 --- a/homeassistant/components/rainmachine/.translations/da.json +++ b/homeassistant/components/rainmachine/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne RainMachine-controller er allerede konfigureret." + }, "error": { "identifier_exists": "Konto er allerede registreret", "invalid_credentials": "Ugyldige legitimationsoplysninger" diff --git a/homeassistant/components/rainmachine/.translations/de.json b/homeassistant/components/rainmachine/.translations/de.json index c262fa5a6521da..257a0908c6a26d 100644 --- a/homeassistant/components/rainmachine/.translations/de.json +++ b/homeassistant/components/rainmachine/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser RainMachine-Kontroller ist bereits konfiguriert." + }, "error": { "identifier_exists": "Konto bereits registriert", "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" diff --git a/homeassistant/components/rainmachine/.translations/en.json b/homeassistant/components/rainmachine/.translations/en.json index 54b67066f2b099..4ad5bfd7c0dbcb 100644 --- a/homeassistant/components/rainmachine/.translations/en.json +++ b/homeassistant/components/rainmachine/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This RainMachine controller is already configured." + }, "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" diff --git a/homeassistant/components/rainmachine/.translations/ko.json b/homeassistant/components/rainmachine/.translations/ko.json index 4e2df2ca21717f..66d6cb0b740761 100644 --- a/homeassistant/components/rainmachine/.translations/ko.json +++ b/homeassistant/components/rainmachine/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 RainMachine \ucee8\ud2b8\ub864\ub7ec\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json index 5ec4e5fdc34589..980c2c693ce72a 100644 --- a/homeassistant/components/rainmachine/.translations/no.json +++ b/homeassistant/components/rainmachine/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne RainMachine-kontrolleren er allerede konfigurert." + }, "error": { "identifier_exists": "Konto er allerede registrert", "invalid_credentials": "Ugyldig legitimasjon" diff --git a/homeassistant/components/rainmachine/.translations/pl.json b/homeassistant/components/rainmachine/.translations/pl.json index cf842efe9f6d0f..5e813243f13441 100644 --- a/homeassistant/components/rainmachine/.translations/pl.json +++ b/homeassistant/components/rainmachine/.translations/pl.json @@ -1,7 +1,10 @@ { "config": { + "abort": { + "already_configured": "Ten kontroler RainMachine jest ju\u017c skonfigurowany." + }, "error": { - "identifier_exists": "Konto jest ju\u017c zarejestrowane", + "identifier_exists": "Konto jest ju\u017c zarejestrowane.", "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" }, "step": { diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json index ca535663f5439f..e1bce5874e3772 100644 --- a/homeassistant/components/rainmachine/.translations/ru.json +++ b/homeassistant/components/rainmachine/.translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, "error": { "identifier_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." diff --git a/homeassistant/components/rainmachine/.translations/zh-Hant.json b/homeassistant/components/rainmachine/.translations/zh-Hant.json index 518cc54192f8bc..3d9663a9a792e7 100644 --- a/homeassistant/components/rainmachine/.translations/zh-Hant.json +++ b/homeassistant/components/rainmachine/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64 RainMachine \u63a7\u5236\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, "error": { "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", "invalid_credentials": "\u6191\u8b49\u7121\u6548" diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 5e95b11f2e48ca..4844a9e68c8c5b 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from regenmaschine import login +from regenmaschine import Client from regenmaschine.errors import RainMachineError import voluptuous as vol @@ -16,6 +16,7 @@ CONF_SCAN_INTERVAL, CONF_SSL, ) +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -23,26 +24,25 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import verify_domain_control -from .config_flow import configured_instances from .const import ( DATA_CLIENT, + DATA_PROGRAMS, + DATA_PROVISION_SETTINGS, + DATA_RESTRICTIONS_CURRENT, + DATA_RESTRICTIONS_UNIVERSAL, + DATA_ZONES, + DATA_ZONES_DETAILS, DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SSL, DOMAIN, - PROVISION_SETTINGS, - RESTRICTIONS_CURRENT, - RESTRICTIONS_UNIVERSAL, + PROGRAM_UPDATE_TOPIC, + SENSOR_UPDATE_TOPIC, + ZONE_UPDATE_TOPIC, ) _LOGGER = logging.getLogger(__name__) DATA_LISTENER = "listener" -PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update" -SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update" -ZONE_UPDATE_TOPIC = f"{DOMAIN}_zone_update" - CONF_CONTROLLERS = "controllers" CONF_PROGRAM_ID = "program_id" CONF_SECONDS = "seconds" @@ -51,6 +51,8 @@ DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC" DEFAULT_ICON = "mdi:water" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SSL = True DEFAULT_ZONE_RUN = 60 * 10 SERVICE_ALTER_PROGRAM = vol.Schema({vol.Required(CONF_PROGRAM_ID): cv.positive_int}) @@ -82,8 +84,10 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, lambda value: value.total_seconds() + ), + vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int, } ) @@ -113,9 +117,6 @@ async def async_setup(hass, config): conf = config[DOMAIN] for controller in conf[CONF_CONTROLLERS]: - if controller[CONF_IP_ADDRESS] in configured_instances(hass): - continue - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=controller @@ -127,27 +128,41 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up RainMachine as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_IP_ADDRESS] + ) _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) + client = Client(websession) try: - client = await login( + await client.load_local( config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD], - websession, port=config_entry.data[CONF_PORT], ssl=config_entry.data[CONF_SSL], ) - rainmachine = RainMachine( - client, config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN), - ) - await rainmachine.async_update() except RainMachineError as err: _LOGGER.error("An error occurred: %s", err) raise ConfigEntryNotReady + else: + # regenmaschine can load multiple controllers at once, but we only grab the one + # we loaded above: + controller = next(iter(client.controllers.values())) + + rainmachine = RainMachine( + hass, + controller, + config_entry.data[CONF_ZONE_RUN_TIME], + config_entry.data[CONF_SCAN_INTERVAL], + ) + # Update the data object, which at this point (prior to any sensors registering + # "interest" in the API), will focus on grabbing the latest program and zone data: + await rainmachine.async_update() hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine for component in ("binary_sensor", "sensor", "switch"): @@ -155,83 +170,73 @@ async def async_setup_entry(hass, config_entry): hass.config_entries.async_forward_entry_setup(config_entry, component) ) - async def refresh(event_time): - """Refresh RainMachine sensor data.""" - _LOGGER.debug("Updating RainMachine sensor data") - await rainmachine.async_update() - async_dispatcher_send(hass, SENSOR_UPDATE_TOPIC) - - hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( - hass, refresh, timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) - ) - @_verify_domain_control async def disable_program(call): """Disable a program.""" - await rainmachine.client.programs.disable(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.programs.disable(call.data[CONF_PROGRAM_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def disable_zone(call): """Disable a zone.""" - await rainmachine.client.zones.disable(call.data[CONF_ZONE_ID]) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.controller.zones.disable(call.data[CONF_ZONE_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def enable_program(call): """Enable a program.""" - await rainmachine.client.programs.enable(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.programs.enable(call.data[CONF_PROGRAM_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def enable_zone(call): """Enable a zone.""" - await rainmachine.client.zones.enable(call.data[CONF_ZONE_ID]) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.controller.zones.enable(call.data[CONF_ZONE_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def pause_watering(call): """Pause watering for a set number of seconds.""" - await rainmachine.client.watering.pause_all(call.data[CONF_SECONDS]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.watering.pause_all(call.data[CONF_SECONDS]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def start_program(call): """Start a particular program.""" - await rainmachine.client.programs.start(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.programs.start(call.data[CONF_PROGRAM_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def start_zone(call): """Start a particular zone for a certain amount of time.""" - await rainmachine.client.zones.start( + await rainmachine.controller.zones.start( call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME] ) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_all(call): """Stop all watering.""" - await rainmachine.client.watering.stop_all() - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.watering.stop_all() + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_program(call): """Stop a program.""" - await rainmachine.client.programs.stop(call.data[CONF_PROGRAM_ID]) - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.programs.stop(call.data[CONF_PROGRAM_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def stop_zone(call): """Stop a zone.""" - await rainmachine.client.zones.stop(call.data[CONF_ZONE_ID]) - async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + await rainmachine.controller.zones.stop(call.data[CONF_ZONE_ID]) + await rainmachine.async_update_programs_and_zones() @_verify_domain_control async def unpause_watering(call): """Unpause watering.""" - await rainmachine.client.watering.unpause_all() - async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + await rainmachine.controller.watering.unpause_all() + await rainmachine.async_update_programs_and_zones() for service, method, schema in [ ("disable_program", disable_program, SERVICE_ALTER_PROGRAM), @@ -271,30 +276,135 @@ async def async_unload_entry(hass, config_entry): class RainMachine: """Define a generic RainMachine object.""" - def __init__(self, client, default_zone_runtime): + def __init__(self, hass, controller, default_zone_runtime, scan_interval): """Initialize.""" - self.client = client + self._async_cancel_time_interval_listener = None + self._scan_interval_seconds = scan_interval + self.controller = controller self.data = {} self.default_zone_runtime = default_zone_runtime - self.device_mac = self.client.mac + self.device_mac = controller.mac + self.hass = hass + + self._api_category_count = { + DATA_PROVISION_SETTINGS: 0, + DATA_RESTRICTIONS_CURRENT: 0, + DATA_RESTRICTIONS_UNIVERSAL: 0, + } + self._api_category_locks = { + DATA_PROVISION_SETTINGS: asyncio.Lock(), + DATA_RESTRICTIONS_CURRENT: asyncio.Lock(), + DATA_RESTRICTIONS_UNIVERSAL: asyncio.Lock(), + } + + async def _async_update_listener_action(self, now): + """Define an async_track_time_interval action to update data.""" + await self.async_update() + + @callback + def async_deregister_sensor_api_interest(self, api_category): + """Decrement the number of entities with data needs from an API category.""" + # If this deregistration should leave us with no registration at all, remove the + # time interval: + if sum(self._api_category_count.values()) == 0: + if self._async_cancel_time_interval_listener: + self._async_cancel_time_interval_listener() + self._async_cancel_time_interval_listener = None + return + + self._api_category_count[api_category] -= 1 + + async def async_fetch_from_api(self, api_category): + """Execute the appropriate coroutine to fetch particular data from the API.""" + if api_category == DATA_PROGRAMS: + data = await self.controller.programs.all(include_inactive=True) + elif api_category == DATA_PROVISION_SETTINGS: + data = await self.controller.provisioning.settings() + elif api_category == DATA_RESTRICTIONS_CURRENT: + data = await self.controller.restrictions.current() + elif api_category == DATA_RESTRICTIONS_UNIVERSAL: + data = await self.controller.restrictions.universal() + elif api_category == DATA_ZONES: + data = await self.controller.zones.all(include_inactive=True) + elif api_category == DATA_ZONES_DETAILS: + # This API call needs to be separate from the DATA_ZONES one above because, + # maddeningly, the DATA_ZONES_DETAILS API call doesn't include the current + # state of the zone: + data = await self.controller.zones.all(details=True, include_inactive=True) + + self.data[api_category] = data + + async def async_register_sensor_api_interest(self, api_category): + """Increment the number of entities with data needs from an API category.""" + # If this is the first registration we have, start a time interval: + if not self._async_cancel_time_interval_listener: + self._async_cancel_time_interval_listener = async_track_time_interval( + self.hass, + self._async_update_listener_action, + timedelta(seconds=self._scan_interval_seconds), + ) + + self._api_category_count[api_category] += 1 + + # If a sensor registers interest in a particular API call and the data doesn't + # exist for it yet, make the API call and grab the data: + async with self._api_category_locks[api_category]: + if api_category not in self.data: + await self.async_fetch_from_api(api_category) async def async_update(self): + """Update all RainMachine data.""" + tasks = [self.async_update_programs_and_zones(), self.async_update_sensors()] + await asyncio.gather(*tasks) + + async def async_update_sensors(self): """Update sensor/binary sensor data.""" + _LOGGER.debug("Updating sensor data for RainMachine") + + # Fetch an API category if there is at least one interested entity: + tasks = {} + for category, count in self._api_category_count.items(): + if count == 0: + continue + tasks[category] = self.async_fetch_from_api(category) + + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for api_category, result in zip(tasks, results): + if isinstance(result, RainMachineError): + _LOGGER.error( + "There was an error while updating %s: %s", api_category, result + ) + continue + + async_dispatcher_send(self.hass, SENSOR_UPDATE_TOPIC) + + async def async_update_programs_and_zones(self): + """Update program and zone data. + + Program and zone updates always go together because of how linked they are: + programs affect zones and certain combinations of zones affect programs. + + Note that this call does not take into account interested entities when making + the API calls; we make the reasonable assumption that switches will always be + enabled. + """ + _LOGGER.debug("Updating program and zone data for RainMachine") + tasks = { - PROVISION_SETTINGS: self.client.provisioning.settings(), - RESTRICTIONS_CURRENT: self.client.restrictions.current(), - RESTRICTIONS_UNIVERSAL: self.client.restrictions.universal(), + DATA_PROGRAMS: self.async_fetch_from_api(DATA_PROGRAMS), + DATA_ZONES: self.async_fetch_from_api(DATA_ZONES), + DATA_ZONES_DETAILS: self.async_fetch_from_api(DATA_ZONES_DETAILS), } results = await asyncio.gather(*tasks.values(), return_exceptions=True) - for operation, result in zip(tasks, results): + for api_category, result in zip(tasks, results): if isinstance(result, RainMachineError): _LOGGER.error( - "There was an error while updating %s: %s", operation, result + "There was an error while updating %s: %s", api_category, result ) - continue - self.data[operation] = result + async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) + async_dispatcher_send(self.hass, ZONE_UPDATE_TOPIC) class RainMachineEntity(Entity): @@ -317,14 +427,14 @@ def device_class(self): def device_info(self): """Return device registry information for this entity.""" return { - "identifiers": {(DOMAIN, self.rainmachine.client.mac)}, - "name": self.rainmachine.client.name, + "identifiers": {(DOMAIN, self.rainmachine.controller.mac)}, + "name": self.rainmachine.controller.name, "manufacturer": "RainMachine", "model": "Version {0} (API: {1})".format( - self.rainmachine.client.hardware_version, - self.rainmachine.client.api_version, + self.rainmachine.controller.hardware_version, + self.rainmachine.controller.api_version, ), - "sw_version": self.rainmachine.client.software_version, + "sw_version": self.rainmachine.controller.software_version, } @property @@ -337,6 +447,16 @@ def name(self) -> str: """Return the name of the entity.""" return self._name + @property + def should_poll(self): + """Disable polling.""" + return False + + @callback + def _update_state(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + async def async_will_remove_from_hass(self): """Disconnect dispatcher listener when removed.""" for handler in self._dispatcher_handlers: diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 8362c31b11f018..34b8de80b888f9 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -2,17 +2,16 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from . import RainMachineEntity +from .const import ( DATA_CLIENT, + DATA_PROVISION_SETTINGS, + DATA_RESTRICTIONS_CURRENT, + DATA_RESTRICTIONS_UNIVERSAL, DOMAIN as RAINMACHINE_DOMAIN, - PROVISION_SETTINGS, - RESTRICTIONS_CURRENT, - RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, - RainMachineEntity, ) _LOGGER = logging.getLogger(__name__) @@ -28,40 +27,74 @@ TYPE_WEEKDAY = "weekday" BINARY_SENSORS = { - TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True), - TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True), - TYPE_FREEZE_PROTECTION: ("Freeze Protection", "mdi:weather-snowy", True), - TYPE_HOT_DAYS: ("Extra Water on Hot Days", "mdi:thermometer-lines", True), - TYPE_HOURLY: ("Hourly Restrictions", "mdi:cancel", False), - TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False), - TYPE_RAINDELAY: ("Rain Delay Restrictions", "mdi:cancel", False), - TYPE_RAINSENSOR: ("Rain Sensor Restrictions", "mdi:cancel", False), - TYPE_WEEKDAY: ("Weekday Restrictions", "mdi:cancel", False), + TYPE_FLOW_SENSOR: ("Flow Sensor", "mdi:water-pump", True, DATA_PROVISION_SETTINGS), + TYPE_FREEZE: ("Freeze Restrictions", "mdi:cancel", True, DATA_RESTRICTIONS_CURRENT), + TYPE_FREEZE_PROTECTION: ( + "Freeze Protection", + "mdi:weather-snowy", + True, + DATA_RESTRICTIONS_UNIVERSAL, + ), + TYPE_HOT_DAYS: ( + "Extra Water on Hot Days", + "mdi:thermometer-lines", + True, + DATA_RESTRICTIONS_UNIVERSAL, + ), + TYPE_HOURLY: ( + "Hourly Restrictions", + "mdi:cancel", + False, + DATA_RESTRICTIONS_CURRENT, + ), + TYPE_MONTH: ("Month Restrictions", "mdi:cancel", False, DATA_RESTRICTIONS_CURRENT), + TYPE_RAINDELAY: ( + "Rain Delay Restrictions", + "mdi:cancel", + False, + DATA_RESTRICTIONS_CURRENT, + ), + TYPE_RAINSENSOR: ( + "Rain Sensor Restrictions", + "mdi:cancel", + False, + DATA_RESTRICTIONS_CURRENT, + ), + TYPE_WEEKDAY: ( + "Weekday Restrictions", + "mdi:cancel", + False, + DATA_RESTRICTIONS_CURRENT, + ), } async def async_setup_entry(hass, entry, async_add_entities): """Set up RainMachine binary sensors based on a config entry.""" rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] - - binary_sensors = [] - for sensor_type, (name, icon, enabled_by_default) in BINARY_SENSORS.items(): - binary_sensors.append( + async_add_entities( + [ RainMachineBinarySensor( - rainmachine, sensor_type, name, icon, enabled_by_default + rainmachine, sensor_type, name, icon, enabled_by_default, api_category ) - ) - - async_add_entities(binary_sensors, True) + for ( + sensor_type, + (name, icon, enabled_by_default, api_category), + ) in BINARY_SENSORS.items() + ], + ) class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): """A sensor implementation for raincloud device.""" - def __init__(self, rainmachine, sensor_type, name, icon, enabled_by_default): + def __init__( + self, rainmachine, sensor_type, name, icon, enabled_by_default, api_category + ): """Initialize the sensor.""" super().__init__(rainmachine) + self._api_category = api_category self._enabled_by_default = enabled_by_default self._icon = icon self._name = name @@ -83,11 +116,6 @@ def is_on(self): """Return the status of the sensor.""" return self._state - @property - def should_poll(self): - """Disable polling.""" - return False - @property def unique_id(self) -> str: """Return a unique, Home Assistant friendly identifier for this entity.""" @@ -97,39 +125,40 @@ def unique_id(self) -> str: async def async_added_to_hass(self): """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - self._dispatcher_handlers.append( - async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, update) + async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, self._update_state) ) + await self.rainmachine.async_register_sensor_api_interest(self._api_category) + await self.async_update() async def async_update(self): """Update the state.""" if self._sensor_type == TYPE_FLOW_SENSOR: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "useFlowSensor" ) elif self._sensor_type == TYPE_FREEZE: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["freeze"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["freeze"] elif self._sensor_type == TYPE_FREEZE_PROTECTION: - self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][ "freezeProtectEnabled" ] elif self._sensor_type == TYPE_HOT_DAYS: - self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][ "hotDaysExtraWatering" ] elif self._sensor_type == TYPE_HOURLY: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["hourly"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["hourly"] elif self._sensor_type == TYPE_MONTH: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["month"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["month"] elif self._sensor_type == TYPE_RAINDELAY: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["rainDelay"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["rainDelay"] elif self._sensor_type == TYPE_RAINSENSOR: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["rainSensor"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["rainSensor"] elif self._sensor_type == TYPE_WEEKDAY: - self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]["weekDay"] + self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["weekDay"] + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listeners and deregister API interest.""" + super().async_will_remove_from_hass() + self.rainmachine.async_deregister_sensor_api_interest(self._api_category) diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 4753335da7838a..ffa46cc2c15195 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -1,36 +1,16 @@ """Config flow to configure the RainMachine component.""" - -from collections import OrderedDict - from regenmaschine import login from regenmaschine.errors import RainMachineError import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_SSL, -) -from homeassistant.core import callback +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT from homeassistant.helpers import aiohttp_client -from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN - - -@callback -def configured_instances(hass): - """Return a set of configured RainMachine instances.""" - return set( - entry.data[CONF_IP_ADDRESS] - for entry in hass.config_entries.async_entries(DOMAIN) - ) +from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import -@config_entries.HANDLERS.register(DOMAIN) -class RainMachineFlowHandler(config_entries.ConfigFlow): +class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a RainMachine config flow.""" VERSION = 1 @@ -38,16 +18,19 @@ class RainMachineFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the config flow.""" - self.data_schema = OrderedDict() - self.data_schema[vol.Required(CONF_IP_ADDRESS)] = str - self.data_schema[vol.Required(CONF_PASSWORD)] = str - self.data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + self.data_schema = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + } + ) async def _show_form(self, errors=None): """Show the form to the user.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema(self.data_schema), + data_schema=self.data_schema, errors=errors if errors else {}, ) @@ -57,12 +40,11 @@ async def async_step_import(self, import_config): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_IP_ADDRESS] in configured_instances(self.hass): - return await self._show_form({CONF_IP_ADDRESS: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) + self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) @@ -77,15 +59,6 @@ async def async_step_user(self, user_input=None): except RainMachineError: return await self._show_form({CONF_PASSWORD: "invalid_credentials"}) - # Since the config entry doesn't allow for configuration of SSL, make - # sure it's set: - if user_input.get(CONF_SSL) is None: - user_input[CONF_SSL] = DEFAULT_SSL - - # Timedeltas are easily serializable, so store the seconds instead: - scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds - # Unfortunately, RainMachine doesn't provide a way to refresh the # access token without using the IP address and password, so we have to # store it: diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index c3612645a8f7c5..855ff5d5df5f5c 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -1,19 +1,16 @@ """Define constants for the SimpliSafe component.""" -from datetime import timedelta -import logging - -LOGGER = logging.getLogger(__package__) - DOMAIN = "rainmachine" DATA_CLIENT = "client" +DATA_PROGRAMS = "programs" +DATA_PROVISION_SETTINGS = "provision.settings" +DATA_RESTRICTIONS_CURRENT = "restrictions.current" +DATA_RESTRICTIONS_UNIVERSAL = "restrictions.universal" +DATA_ZONES = "zones" +DATA_ZONES_DETAILS = "zones_details" DEFAULT_PORT = 8080 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) -DEFAULT_SSL = True - -PROVISION_SETTINGS = "provision.settings" -RESTRICTIONS_CURRENT = "restrictions.current" -RESTRICTIONS_UNIVERSAL = "restrictions.universal" -TOPIC_UPDATE = "update_{0}" +PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update" +SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update" +ZONE_UPDATE_TOPIC = f"{DOMAIN}_zone_update" diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 30acacafad0820..8487628a32bc02 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,16 +1,15 @@ """This platform provides support for sensor data from RainMachine.""" import logging -from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from . import RainMachineEntity +from .const import ( DATA_CLIENT, + DATA_PROVISION_SETTINGS, + DATA_RESTRICTIONS_UNIVERSAL, DOMAIN as RAINMACHINE_DOMAIN, - PROVISION_SETTINGS, - RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, - RainMachineEntity, ) _LOGGER = logging.getLogger(__name__) @@ -28,6 +27,7 @@ "clicks/m^3", None, False, + DATA_PROVISION_SETTINGS, ), TYPE_FLOW_SENSOR_CONSUMED_LITERS: ( "Flow Sensor Consumed Liters", @@ -35,6 +35,7 @@ "liter", None, False, + DATA_PROVISION_SETTINGS, ), TYPE_FLOW_SENSOR_START_INDEX: ( "Flow Sensor Start Index", @@ -42,6 +43,7 @@ "index", None, False, + DATA_PROVISION_SETTINGS, ), TYPE_FLOW_SENSOR_WATERING_CLICKS: ( "Flow Sensor Clicks", @@ -49,6 +51,7 @@ "clicks", None, False, + DATA_PROVISION_SETTINGS, ), TYPE_FREEZE_TEMP: ( "Freeze Protect Temperature", @@ -56,6 +59,7 @@ "°C", "temperature", True, + DATA_RESTRICTIONS_UNIVERSAL, ), } @@ -63,13 +67,8 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up RainMachine sensors based on a config entry.""" rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] - - sensors = [] - for ( - sensor_type, - (name, icon, unit, device_class, enabled_by_default), - ) in SENSORS.items(): - sensors.append( + async_add_entities( + [ RainMachineSensor( rainmachine, sensor_type, @@ -78,10 +77,14 @@ async def async_setup_entry(hass, entry, async_add_entities): unit, device_class, enabled_by_default, + api_category, ) - ) - - async_add_entities(sensors, True) + for ( + sensor_type, + (name, icon, unit, device_class, enabled_by_default, api_category), + ) in SENSORS.items() + ], + ) class RainMachineSensor(RainMachineEntity): @@ -96,10 +99,12 @@ def __init__( unit, device_class, enabled_by_default, + api_category, ): """Initialize.""" super().__init__(rainmachine) + self._api_category = api_category self._device_class = device_class self._enabled_by_default = enabled_by_default self._icon = icon @@ -118,11 +123,6 @@ def icon(self) -> str: """Return the icon.""" return self._icon - @property - def should_poll(self): - """Disable polling.""" - return False - @property def state(self) -> str: """Return the name of the entity.""" @@ -142,43 +142,44 @@ def unit_of_measurement(self): async def async_added_to_hass(self): """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - self._dispatcher_handlers.append( - async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, update) + async_dispatcher_connect(self.hass, SENSOR_UPDATE_TOPIC, self._update_state) ) + await self.rainmachine.async_register_sensor_api_interest(self._api_category) + await self.async_update() async def async_update(self): """Update the sensor's state.""" if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorClicksPerCubicMeter" ) elif self._sensor_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: - clicks = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + clicks = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorWateringClicks" ) - clicks_per_m3 = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( - "flowSensorClicksPerCubicMeter" - ) + clicks_per_m3 = self.rainmachine.data[DATA_PROVISION_SETTINGS][ + "system" + ].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]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorStartIndex" ) elif self._sensor_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: - self._state = self.rainmachine.data[PROVISION_SETTINGS]["system"].get( + self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get( "flowSensorWateringClicks" ) elif self._sensor_type == TYPE_FREEZE_TEMP: - self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][ + self._state = self.rainmachine.data[DATA_RESTRICTIONS_UNIVERSAL][ "freezeProtectTemp" ] + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listeners and deregister API interest.""" + super().async_will_remove_from_hass() + self.rainmachine.async_deregister_sensor_api_interest(self._api_category) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 6e26192ec825d6..7195cce2e31c89 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -14,6 +14,9 @@ "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" + }, + "abort": { + "already_configured": "This RainMachine controller is already configured." } } } diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 8da2cc4ee452dd..2bf63dbf4951fc 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -6,18 +6,17 @@ from homeassistant.components.switch import SwitchDevice from homeassistant.const import ATTR_ID -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from . import RainMachineEntity +from .const import ( DATA_CLIENT, + DATA_PROGRAMS, + DATA_ZONES, + DATA_ZONES_DETAILS, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC, ZONE_UPDATE_TOPIC, - RainMachineEntity, ) _LOGGER = logging.getLogger(__name__) @@ -94,22 +93,19 @@ 99: "Other", } +SWITCH_TYPE_PROGRAM = "program" +SWITCH_TYPE_ZONE = "zone" + async def async_setup_entry(hass, entry, async_add_entities): """Set up RainMachine switches based on a config entry.""" rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] entities = [] - - programs = await rainmachine.client.programs.all(include_inactive=True) - for program in programs: + for program in rainmachine.data[DATA_PROGRAMS]: entities.append(RainMachineProgram(rainmachine, program)) - - zones = await rainmachine.client.zones.all(include_inactive=True) - for zone in zones: - entities.append( - RainMachineZone(rainmachine, zone, rainmachine.default_zone_runtime) - ) + for zone in rainmachine.data[DATA_ZONES]: + entities.append(RainMachineZone(rainmachine, zone)) async_add_entities(entities, True) @@ -117,25 +113,31 @@ async def async_setup_entry(hass, entry, async_add_entities): class RainMachineSwitch(RainMachineEntity, SwitchDevice): """A class to represent a generic RainMachine switch.""" - def __init__(self, rainmachine, switch_type, obj): + def __init__(self, rainmachine, switch_data): """Initialize a generic RainMachine switch.""" super().__init__(rainmachine) - self._name = obj["name"] - self._obj = obj - self._rainmachine_entity_id = obj["uid"] - self._switch_type = switch_type + self._is_on = False + self._name = switch_data["name"] + self._switch_data = switch_data + self._rainmachine_entity_id = switch_data["uid"] + self._switch_type = None @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._obj.get("active")) + return self._switch_data["active"] @property def icon(self) -> str: """Return the icon.""" return "mdi:water" + @property + def is_on(self) -> bool: + """Return whether the program is running.""" + return self._is_on + @property def unique_id(self) -> str: """Return a unique, Home Assistant friendly identifier for this entity.""" @@ -145,173 +147,156 @@ def unique_id(self) -> str: self._rainmachine_entity_id, ) - @callback - def _program_updated(self): - """Update state, trigger updates.""" - self.async_schedule_update_ha_state(True) + async def _async_run_switch_coroutine(self, api_coro) -> None: + """Run a coroutine to toggle the switch.""" + try: + resp = await api_coro + except RequestError as err: + _LOGGER.error( + 'Error while toggling %s "%s": %s', + self._switch_type, + self.unique_id, + err, + ) + return + + if resp["statusCode"] != 0: + _LOGGER.error( + 'Error while toggling %s "%s": %s', + self._switch_type, + self.unique_id, + resp["message"], + ) + return + + self.hass.async_create_task(self.rainmachine.async_update_programs_and_zones()) class RainMachineProgram(RainMachineSwitch): """A RainMachine program.""" - def __init__(self, rainmachine, obj): + def __init__(self, rainmachine, switch_data): """Initialize a generic RainMachine switch.""" - super().__init__(rainmachine, "program", obj) - - @property - def is_on(self) -> bool: - """Return whether the program is running.""" - return bool(self._obj.get("status")) + super().__init__(rainmachine, switch_data) + self._switch_type = SWITCH_TYPE_PROGRAM @property def zones(self) -> list: """Return a list of active zones associated with this program.""" - return [z for z in self._obj["wateringTimes"] if z["active"]] + return [z for z in self._switch_data["wateringTimes"] if z["active"]] async def async_added_to_hass(self): """Register callbacks.""" self._dispatcher_handlers.append( async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated + self.hass, PROGRAM_UPDATE_TOPIC, self._update_state ) ) async def async_turn_off(self, **kwargs) -> None: """Turn the program off.""" - - try: - await self.rainmachine.client.programs.stop(self._rainmachine_entity_id) - async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) - except RequestError as err: - _LOGGER.error( - 'Unable to turn off program "%s": %s', self.unique_id, str(err) - ) + await self._async_run_switch_coroutine( + self.rainmachine.controller.programs.stop(self._rainmachine_entity_id) + ) async def async_turn_on(self, **kwargs) -> None: """Turn the program on.""" - - try: - await self.rainmachine.client.programs.start(self._rainmachine_entity_id) - async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) - except RequestError as err: - _LOGGER.error( - 'Unable to turn on program "%s": %s', self.unique_id, str(err) - ) + await self._async_run_switch_coroutine( + self.rainmachine.controller.programs.start(self._rainmachine_entity_id) + ) async def async_update(self) -> None: """Update info for the program.""" + [self._switch_data] = [ + p + for p in self.rainmachine.data[DATA_PROGRAMS] + if p["uid"] == self._rainmachine_entity_id + ] - try: - self._obj = await self.rainmachine.client.programs.get( - self._rainmachine_entity_id - ) + self._is_on = bool(self._switch_data["status"]) - try: - next_run = datetime.strptime( - "{0} {1}".format(self._obj["nextRun"], self._obj["startTime"]), - "%Y-%m-%d %H:%M", - ).isoformat() - except ValueError: - next_run = None - - self._attrs.update( - { - ATTR_ID: self._obj["uid"], - ATTR_NEXT_RUN: next_run, - ATTR_SOAK: self._obj.get("soak"), - ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get("status")], - ATTR_ZONES: ", ".join(z["name"] for z in self.zones), - } - ) - except RequestError as err: - _LOGGER.error( - 'Unable to update info for program "%s": %s', self.unique_id, str(err) - ) + try: + next_run = datetime.strptime( + "{0} {1}".format( + self._switch_data["nextRun"], self._switch_data["startTime"] + ), + "%Y-%m-%d %H:%M", + ).isoformat() + except ValueError: + next_run = None + + self._attrs.update( + { + ATTR_ID: self._switch_data["uid"], + ATTR_NEXT_RUN: next_run, + ATTR_SOAK: self._switch_data.get("soak"), + ATTR_STATUS: PROGRAM_STATUS_MAP[self._switch_data["status"]], + ATTR_ZONES: ", ".join(z["name"] for z in self.zones), + } + ) class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - def __init__(self, rainmachine, obj, zone_run_time): + def __init__(self, rainmachine, switch_data): """Initialize a RainMachine zone.""" - super().__init__(rainmachine, "zone", obj) - - self._properties_json = {} - self._run_time = zone_run_time - - @property - def is_on(self) -> bool: - """Return whether the zone is running.""" - return bool(self._obj.get("state")) + super().__init__(rainmachine, switch_data) + self._switch_type = SWITCH_TYPE_ZONE async def async_added_to_hass(self): """Register callbacks.""" self._dispatcher_handlers.append( async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated + self.hass, PROGRAM_UPDATE_TOPIC, self._update_state ) ) self._dispatcher_handlers.append( - async_dispatcher_connect( - self.hass, ZONE_UPDATE_TOPIC, self._program_updated - ) + async_dispatcher_connect(self.hass, ZONE_UPDATE_TOPIC, self._update_state) ) async def async_turn_off(self, **kwargs) -> None: """Turn the zone off.""" - - try: - await self.rainmachine.client.zones.stop(self._rainmachine_entity_id) - except RequestError as err: - _LOGGER.error('Unable to turn off zone "%s": %s', self.unique_id, str(err)) + await self._async_run_switch_coroutine( + self.rainmachine.controller.zones.stop(self._rainmachine_entity_id) + ) async def async_turn_on(self, **kwargs) -> None: """Turn the zone on.""" - - try: - await self.rainmachine.client.zones.start( - self._rainmachine_entity_id, self._run_time + await self._async_run_switch_coroutine( + self.rainmachine.controller.zones.start( + self._rainmachine_entity_id, self.rainmachine.default_zone_runtime ) - except RequestError as err: - _LOGGER.error('Unable to turn on zone "%s": %s', self.unique_id, str(err)) + ) async def async_update(self) -> None: """Update info for the zone.""" - - try: - self._obj = await self.rainmachine.client.zones.get( - self._rainmachine_entity_id - ) - - self._properties_json = await self.rainmachine.client.zones.get( - self._rainmachine_entity_id, details=True - ) - - self._attrs.update( - { - ATTR_ID: self._obj["uid"], - ATTR_AREA: self._properties_json.get("waterSense").get("area"), - ATTR_CURRENT_CYCLE: self._obj.get("cycle"), - ATTR_FIELD_CAPACITY: self._properties_json.get("waterSense").get( - "fieldCapacity" - ), - ATTR_NO_CYCLES: self._obj.get("noOfCycles"), - ATTR_PRECIP_RATE: self._properties_json.get("waterSense").get( - "precipitationRate" - ), - ATTR_RESTRICTIONS: self._obj.get("restriction"), - ATTR_SLOPE: SLOPE_TYPE_MAP.get(self._properties_json.get("slope")), - ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(self._properties_json.get("sun")), - ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get( - self._properties_json.get("group_id") - ), - ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get( - self._properties_json.get("sun") - ), - ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(self._obj.get("type")), - } - ) - except RequestError as err: - _LOGGER.error( - 'Unable to update info for zone "%s": %s', self.unique_id, str(err) - ) + [self._switch_data] = [ + z + for z in self.rainmachine.data[DATA_ZONES] + if z["uid"] == self._rainmachine_entity_id + ] + [details] = [ + z + for z in self.rainmachine.data[DATA_ZONES_DETAILS] + if z["uid"] == self._rainmachine_entity_id + ] + + self._is_on = bool(self._switch_data["state"]) + + self._attrs.update( + { + ATTR_ID: self._switch_data["uid"], + ATTR_AREA: details.get("waterSense").get("area"), + ATTR_CURRENT_CYCLE: self._switch_data.get("cycle"), + ATTR_FIELD_CAPACITY: details.get("waterSense").get("fieldCapacity"), + ATTR_NO_CYCLES: self._switch_data.get("noOfCycles"), + ATTR_PRECIP_RATE: details.get("waterSense").get("precipitationRate"), + ATTR_RESTRICTIONS: self._switch_data.get("restriction"), + ATTR_SLOPE: SLOPE_TYPE_MAP.get(details.get("slope")), + ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(details.get("sun")), + ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(details.get("group_id")), + ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(details.get("sun")), + ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(self._switch_data.get("type")), + } + ) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index ab56a5fc33b6c7..af34d4dd9f6318 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -59,14 +59,16 @@ DEFAULT_URL = "sqlite:///{hass_config_path}" DEFAULT_DB_FILE = "home-assistant_v2.db" +DEFAULT_DB_MAX_RETRIES = 10 +DEFAULT_DB_RETRY_WAIT = 3 CONF_DB_URL = "db_url" +CONF_DB_MAX_RETRIES = "db_max_retries" +CONF_DB_RETRY_WAIT = "db_retry_wait" CONF_PURGE_KEEP_DAYS = "purge_keep_days" CONF_PURGE_INTERVAL = "purge_interval" CONF_EVENT_TYPES = "event_types" -CONNECT_RETRY_WAIT = 3 - FILTER_SCHEMA = vol.Schema( { vol.Optional(CONF_EXCLUDE, default={}): vol.Schema( @@ -96,6 +98,12 @@ vol.Coerce(int), vol.Range(min=0) ), vol.Optional(CONF_DB_URL): cv.string, + vol.Optional( + CONF_DB_MAX_RETRIES, default=DEFAULT_DB_MAX_RETRIES + ): cv.positive_int, + vol.Optional( + CONF_DB_RETRY_WAIT, default=DEFAULT_DB_RETRY_WAIT + ): cv.positive_int, } ) }, @@ -133,6 +141,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = config[DOMAIN] keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) + db_max_retries = conf[CONF_DB_MAX_RETRIES] + db_retry_wait = conf[CONF_DB_RETRY_WAIT] db_url = conf.get(CONF_DB_URL, None) if not db_url: @@ -145,6 +155,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: keep_days=keep_days, purge_interval=purge_interval, uri=db_url, + db_max_retries=db_max_retries, + db_retry_wait=db_retry_wait, include=include, exclude=exclude, ) @@ -174,6 +186,8 @@ def __init__( keep_days: int, purge_interval: int, uri: str, + db_max_retries: int, + db_retry_wait: int, include: Dict, exclude: Dict, ) -> None: @@ -186,6 +200,8 @@ def __init__( self.queue: Any = queue.Queue() self.recording_start = dt_util.utcnow() self.db_url = uri + self.db_max_retries = db_max_retries + self.db_retry_wait = db_retry_wait self.async_db_ready = asyncio.Future() self.engine: Any = None self.run_info: Any = None @@ -217,9 +233,9 @@ def run(self): tries = 1 connected = False - while not connected and tries <= 10: + while not connected and tries <= self.db_max_retries: if tries != 1: - time.sleep(CONNECT_RETRY_WAIT) + time.sleep(self.db_retry_wait) try: self._setup_connection() migration.migrate_schema(self) @@ -230,7 +246,7 @@ def run(self): _LOGGER.error( "Error during connection setup: %s (retrying in %s seconds)", err, - CONNECT_RETRY_WAIT, + self.db_retry_wait, ) tries += 1 @@ -337,9 +353,9 @@ def async_purge(now): tries = 1 updated = False - while not updated and tries <= 10: + while not updated and tries <= self.db_max_retries: if tries != 1: - time.sleep(CONNECT_RETRY_WAIT) + time.sleep(self.db_retry_wait) try: with session_scope(session=self.get_session()) as session: try: @@ -367,7 +383,7 @@ def async_purge(now): "Error in database connectivity: %s. " "(retrying in %s seconds)", err, - CONNECT_RETRY_WAIT, + self.db_retry_wait, ) tries += 1 diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json index 1c58366f6b55ff..f1687d73e04b07 100644 --- a/homeassistant/components/reddit/manifest.json +++ b/homeassistant/components/reddit/manifest.json @@ -2,7 +2,7 @@ "domain": "reddit", "name": "Reddit", "documentation": "https://www.home-assistant.io/integrations/reddit", - "requirements": ["praw==6.5.0"], + "requirements": ["praw==6.5.1"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index b7d36010714c8e..8fdd1f2f858ab1 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -3,9 +3,6 @@ For more info on the API see: https://help.rejseplanen.dk/hc/en-us/articles/214174465-Rejseplanen-s-API - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.rejseplanen/ """ from datetime import datetime, timedelta import logging @@ -15,7 +12,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -133,7 +130,7 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 2abd5844001444..3f8bded6a85275 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,6 +2,7 @@ from datetime import timedelta import functools as ft import logging +from typing import Any, Iterable import voluptuous as vol @@ -19,9 +20,10 @@ ) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.loader import bind_hass -# mypy: allow-untyped-defs, no-check-untyped-defs +# mypy: allow-untyped-calls _LOGGER = logging.getLogger(__name__) @@ -57,12 +59,12 @@ @bind_hass -def is_on(hass, entity_id): +def is_on(hass: HomeAssistantType, entity_id: str) -> bool: """Return if the remote is on based on the statemachine.""" return hass.states.is_state(entity_id, STATE_ON) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Track states and offer events for remotes.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) await component.async_setup(config) @@ -111,32 +113,26 @@ class RemoteDevice(ToggleEntity): """Representation of a remote.""" @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return 0 - def send_command(self, command, **kwargs): - """Send a command to a device.""" + def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to a device.""" raise NotImplementedError() - def async_send_command(self, command, **kwargs): - """Send a command to a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job( + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to a device.""" + assert self.hass is not None + await self.hass.async_add_executor_job( ft.partial(self.send_command, command, **kwargs) ) - def learn_command(self, **kwargs): + def learn_command(self, **kwargs: Any) -> None: """Learn a command from a device.""" raise NotImplementedError() - def async_learn_command(self, **kwargs): - """Learn a command from a device. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_executor_job( - ft.partial(self.learn_command, **kwargs) - ) + async def async_learn_command(self, **kwargs: Any) -> None: + """Learn a command from a device.""" + assert self.hass is not None + await self.hass.async_add_executor_job(ft.partial(self.learn_command, **kwargs)) diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index 8c8b7f3960919c..fd7eea12f7e53b 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -2,7 +2,7 @@ "domain": "rest", "name": "RESTful", "documentation": "https://www.home-assistant.io/integrations/rest", - "requirements": [], + "requirements": ["jsonpath==0.82", "xmltodict==0.12.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 51120cb350ca11..70424325241bf1 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,10 +1,13 @@ """Support for RESTful API sensors.""" import json import logging +from xml.parsers.expat import ExpatError +from jsonpath import jsonpath import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol +import xmltodict from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA from homeassistant.const import ( @@ -38,7 +41,9 @@ DEFAULT_FORCE_UPDATE = False DEFAULT_TIMEOUT = 10 + CONF_JSON_ATTRS = "json_attributes" +CONF_JSON_ATTRS_PATH = "json_attributes_path" METHODS = ["POST", "GET"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -57,6 +62,7 @@ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, @@ -84,6 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_class = config.get(CONF_DEVICE_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) + json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) force_update = config.get(CONF_FORCE_UPDATE) timeout = config.get(CONF_TIMEOUT) @@ -120,6 +127,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): json_attrs, force_update, resource_template, + json_attrs_path, ) ], True, @@ -140,6 +148,7 @@ def __init__( json_attrs, force_update, resource_template, + json_attrs_path, ): """Initialize the REST sensor.""" self._hass = hass @@ -153,6 +162,7 @@ def __init__( self._attributes = None self._force_update = force_update self._resource_template = resource_template + self._json_attrs_path = json_attrs_path @property def name(self): @@ -191,12 +201,29 @@ def update(self): self.rest.update() value = self.rest.data + _LOGGER.debug("Data fetched from resource: %s", value) + content_type = self.rest.headers.get("content-type") + + if content_type and content_type.startswith("text/xml"): + try: + value = json.dumps(xmltodict.parse(value)) + _LOGGER.debug("JSON converted from XML: %s", value) + except ExpatError: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON." + ) + _LOGGER.debug("Erroneous XML: %s", value) if self._json_attrs: self._attributes = {} if value: try: json_dict = json.loads(value) + if self._json_attrs_path is not None: + json_dict = jsonpath(json_dict, self._json_attrs_path) + # jsonpath will always store the result in json_dict[0] + # so the next line happens to work exactly as needed to + # find the result if isinstance(json_dict, list): json_dict = json_dict[0] if isinstance(json_dict, dict): @@ -240,6 +267,7 @@ def __init__( self._verify_ssl = verify_ssl self._timeout = timeout self.data = None + self.headers = None def set_url(self, url): """Set url.""" @@ -259,6 +287,8 @@ def update(self): verify=self._verify_ssl, ) self.data = response.text + self.headers = response.headers except requests.exceptions.RequestException as ex: _LOGGER.error("Error fetching data: %s failed with %s", self._resource, ex) self.data = None + self.headers = None diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 7dfbb964167e60..bb2ede6d555e76 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -16,6 +16,7 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -55,6 +56,7 @@ async def async_setup(hass, config): """Set up the REST command component.""" + @callback def async_register_rest_command(name, command_config): """Create service for rest command.""" websession = async_get_clientsession(hass, command_config.get(CONF_VERIFY_SSL)) diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 2e5875b9d0865f..b8665fae9ef2f2 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -552,10 +552,10 @@ def _handle_event(self, event): elif command in ["off", "alloff"]: self._state = False - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - return self._async_handle_command("turn_on") + await self._async_handle_command("turn_on") - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - return self._async_handle_command("turn_off") + await self._async_handle_command("turn_off") diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index f41c4cde2f7d3f..794542cb9d4c82 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -23,6 +23,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + TYPE_STANDARD = "standard" TYPE_INVERTED = "inverted" @@ -146,17 +148,17 @@ def assumed_state(self): """Return True because covers can be stopped midway.""" return True - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Turn the device close.""" - return self._async_handle_command("close_cover") + await self._async_handle_command("close_cover") - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Turn the device open.""" - return self._async_handle_command("open_cover") + await self._async_handle_command("open_cover") - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Turn the device stop.""" - return self._async_handle_command("stop_cover") + await self._async_handle_command("stop_cover") class InvertedRflinkCover(RflinkCover): diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index db616b92fc42f9..01004a3b45a425 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -31,6 +31,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + TYPE_DIMMABLE = "dimmable" TYPE_SWITCHABLE = "switchable" TYPE_HYBRID = "hybrid" @@ -157,14 +159,12 @@ async def add_new_device(event): hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device -# pylint: disable=too-many-ancestors class RflinkLight(SwitchableRflinkDevice, Light): """Representation of a Rflink light.""" pass -# pylint: disable=too-many-ancestors class DimmableRflinkLight(SwitchableRflinkDevice, Light): """Rflink light device that support dimming.""" @@ -210,7 +210,6 @@ def supported_features(self): return SUPPORT_BRIGHTNESS -# pylint: disable=too-many-ancestors class HybridRflinkLight(SwitchableRflinkDevice, Light): """Rflink light device that sends out both dim and on/off commands. @@ -274,7 +273,6 @@ def supported_features(self): return SUPPORT_BRIGHTNESS -# pylint: disable=too-many-ancestors class ToggleRflinkLight(SwitchableRflinkDevice, Light): """Rflink light device which sends out only 'on' commands. diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 28aea1adc31a22..77b6413f9945ee 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -2,7 +2,7 @@ "domain": "rflink", "name": "RFLink", "documentation": "https://www.home-assistant.io/integrations/rflink", - "requirements": ["rflink==0.0.50"], + "requirements": ["rflink==0.0.51"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 8e0ce9a0c8e68b..943f8a6aae6115 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -22,6 +22,8 @@ _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional( @@ -67,7 +69,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(devices_from_config(config)) -# pylint: disable=too-many-ancestors class RflinkSwitch(SwitchableRflinkDevice, SwitchDevice): """Representation of a Rflink switch.""" diff --git a/homeassistant/components/ring/.translations/ca.json b/homeassistant/components/ring/.translations/ca.json index d51de2b86676d6..c25bdb22eee028 100644 --- a/homeassistant/components/ring/.translations/ca.json +++ b/homeassistant/components/ring/.translations/ca.json @@ -3,12 +3,23 @@ "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat" }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, "step": { + "2fa": { + "data": { + "2fa": "Codi de dos factors" + }, + "title": "Autenticaci\u00f3 de dos factors" + }, "user": { "data": { "password": "Contrasenya", "username": "Nom d'usuari" - } + }, + "title": "Inici de sessi\u00f3 amb un compte de Ring" } }, "title": "Ring" diff --git a/homeassistant/components/ring/.translations/hu.json b/homeassistant/components/ring/.translations/hu.json new file mode 100644 index 00000000000000..578399c8152ffd --- /dev/null +++ b/homeassistant/components/ring/.translations/hu.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s.", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "2fa": { + "data": { + "2fa": "K\u00e9tfaktoros k\u00f3d" + }, + "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Bejelentkez\u00e9s a Ring fi\u00f3kkal" + } + }, + "title": "Ring" + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/.translations/nl.json b/homeassistant/components/ring/.translations/nl.json index 1bb012bd25e8c2..70736b15a9cc45 100644 --- a/homeassistant/components/ring/.translations/nl.json +++ b/homeassistant/components/ring/.translations/nl.json @@ -9,6 +9,9 @@ }, "step": { "2fa": { + "data": { + "2fa": "Twee-factor code" + }, "title": "Tweestapsverificatie" }, "user": { diff --git a/homeassistant/components/ring/.translations/pl.json b/homeassistant/components/ring/.translations/pl.json index f34903ff7d1078..e592522c43b369 100644 --- a/homeassistant/components/ring/.translations/pl.json +++ b/homeassistant/components/ring/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." }, "error": { "invalid_auth": "Niepoprawne uwierzytelnienie", diff --git a/homeassistant/components/ring/.translations/sl.json b/homeassistant/components/ring/.translations/sl.json new file mode 100644 index 00000000000000..58e86634312b79 --- /dev/null +++ b/homeassistant/components/ring/.translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "2fa": { + "data": { + "2fa": "Dvofaktorska koda" + }, + "title": "Dvofaktorska avtentikacija" + }, + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "title": "Prijava s ra\u010dunom Ring" + } + }, + "title": "Ring" + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/.translations/sv.json b/homeassistant/components/ring/.translations/sv.json new file mode 100644 index 00000000000000..e92790740fbd51 --- /dev/null +++ b/homeassistant/components/ring/.translations/sv.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "2fa": { + "data": { + "2fa": "Tv\u00e5faktorkod" + }, + "title": "Tv\u00e5faktorautentisering" + }, + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + }, + "title": "Logga in med Ring-konto" + } + }, + "title": "Ring" + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 34aa9f6b0ec97f..0d54db5993fdd7 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -9,12 +9,9 @@ from oauthlib.oauth2 import AccessDeniedError import requests from ring_doorbell import Auth, Ring -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ +from homeassistant.const import __version__ from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.async_ import run_callback_threadsafe @@ -30,18 +27,6 @@ PLATFORMS = ("binary_sensor", "light", "sensor", "switch", "camera") -CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(DOMAIN): vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - async def async_setup(hass, config): """Set up the Ring component.""" @@ -56,16 +41,6 @@ def legacy_cleanup(): await hass.async_add_executor_job(legacy_cleanup) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": config[DOMAIN]["username"], - "password": config[DOMAIN]["password"], - }, - ) - ) return True diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a25e0283753720..fd9dbe0a17e291 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -75,13 +75,6 @@ async def async_step_2fa(self, user_input=None): step_id="2fa", data_schema=vol.Schema({"2fa": str}), ) - async def async_step_import(self, user_input): - """Handle import.""" - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - return await self.async_step_user(user_input) - class Require2FA(exceptions.HomeAssistantError): """Error to indicate we require 2FA.""" diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 8df1191a420b09..704bde67a5cf61 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -177,7 +177,7 @@ def icon(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES async def async_update(self): """Get the latest data and update the state.""" @@ -233,7 +233,7 @@ async def async_update(self): ) except RMVtransportApiConnectionError: self.departures = [] - _LOGGER.warning("Could not retrive data from rmv.de") + _LOGGER.warning("Could not retrieve data from rmv.de") return self.station = _data.get("station") _deps = [] diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index b92a95af9d7b49..ba67f61b2eec9a 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/roku", "requirements": ["roku==4.0.0"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/rpi_gpio_pwm/light.py b/homeassistant/components/rpi_gpio_pwm/light.py index aededbc676cf05..96ac3c6f2edaf4 100644 --- a/homeassistant/components/rpi_gpio_pwm/light.py +++ b/homeassistant/components/rpi_gpio_pwm/light.py @@ -19,7 +19,7 @@ SUPPORT_TRANSITION, Light, ) -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE, STATE_ON +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_TYPE, STATE_ON import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.color as color_util @@ -58,6 +58,7 @@ vol.Required(CONF_TYPE): vol.In(CONF_LED_TYPES), vol.Optional(CONF_FREQUENCY): cv.positive_int, vol.Optional(CONF_ADDRESS): cv.byte, + vol.Optional(CONF_HOST): cv.string, } ], ) @@ -76,6 +77,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if CONF_FREQUENCY in led_conf: opt_args["freq"] = led_conf[CONF_FREQUENCY] if driver_type == CONF_DRIVER_GPIO: + if CONF_HOST in led_conf: + opt_args["host"] = led_conf[CONF_HOST] driver = GpioDriver(pins, **opt_args) elif driver_type == CONF_DRIVER_PCA9685: if CONF_ADDRESS in led_conf: diff --git a/homeassistant/components/rpi_gpio_pwm/manifest.json b/homeassistant/components/rpi_gpio_pwm/manifest.json index 688cad8324e15f..46fe96a6426523 100644 --- a/homeassistant/components/rpi_gpio_pwm/manifest.json +++ b/homeassistant/components/rpi_gpio_pwm/manifest.json @@ -2,7 +2,7 @@ "domain": "rpi_gpio_pwm", "name": "pigpio Daemon PWM LED", "documentation": "https://www.home-assistant.io/integrations/rpi_gpio_pwm", - "requirements": ["pwmled==1.4.1"], + "requirements": ["pwmled==1.5.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 4ae272ca9bd36e..c6833fcfda038b 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -9,6 +9,7 @@ CONF_MONITORED_VARIABLES, CONF_NAME, CONF_URL, + DATA_RATE_KILOBYTES_PER_SECOND, STATE_IDLE, ) from homeassistant.exceptions import PlatformNotReady @@ -24,8 +25,8 @@ DEFAULT_NAME = "rtorrent" SENSOR_TYPES = { SENSOR_TYPE_CURRENT_STATUS: ["Status", None], - SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", "kB/s"], - SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", "kB/s"], + SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], + SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index c954553160e7f4..a1fe057d9fb4cb 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -185,22 +185,23 @@ def volume_level(self): """ return float(self._zone_var("volume", 0)) / 50.0 - def async_turn_off(self): + async def async_turn_off(self): """Turn off the zone.""" - return self._russ.send_zone_event(self._zone_id, "ZoneOff") + await self._russ.send_zone_event(self._zone_id, "ZoneOff") - def async_turn_on(self): + async def async_turn_on(self): """Turn on the zone.""" - return self._russ.send_zone_event(self._zone_id, "ZoneOn") + await self._russ.send_zone_event(self._zone_id, "ZoneOn") - def async_set_volume_level(self, volume): + async def async_set_volume_level(self, volume): """Set the volume level.""" rvol = int(volume * 50.0) - return self._russ.send_zone_event(self._zone_id, "KeyPress", "Volume", rvol) + await self._russ.send_zone_event(self._zone_id, "KeyPress", "Volume", rvol) - def async_select_source(self, source): + async def async_select_source(self, source): """Select the source input for this zone.""" for source_id, name in self._sources: if name.lower() != source.lower(): continue - return self._russ.send_zone_event(self._zone_id, "SelectSource", source_id) + await self._russ.send_zone_event(self._zone_id, "SelectSource", source_id) + break diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index f436bcb8a72297..b36abbedb48738 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -14,6 +14,9 @@ CONF_PORT, CONF_SENSORS, CONF_SSL, + DATA_GIGABYTES, + DATA_MEGABYTES, + DATA_RATE_MEGABYTES_PER_SECOND, ) from homeassistant.core import callback from homeassistant.helpers import discovery @@ -49,16 +52,16 @@ SENSOR_TYPES = { "current_status": ["Status", None, "status"], - "speed": ["Speed", "MB/s", "kbpersec"], - "queue_size": ["Queue", "MB", "mb"], - "queue_remaining": ["Left", "MB", "mbleft"], - "disk_size": ["Disk", "GB", "diskspacetotal1"], - "disk_free": ["Disk Free", "GB", "diskspace1"], + "speed": ["Speed", DATA_RATE_MEGABYTES_PER_SECOND, "kbpersec"], + "queue_size": ["Queue", DATA_MEGABYTES, "mb"], + "queue_remaining": ["Left", DATA_MEGABYTES, "mbleft"], + "disk_size": ["Disk", DATA_GIGABYTES, "diskspacetotal1"], + "disk_free": ["Disk Free", DATA_GIGABYTES, "diskspace1"], "queue_count": ["Queue Count", None, "noofslots_total"], - "day_size": ["Daily Total", "GB", "day_size"], - "week_size": ["Weekly Total", "GB", "week_size"], - "month_size": ["Monthly Total", "GB", "month_size"], - "total_size": ["Total", "GB", "total_size"], + "day_size": ["Daily Total", DATA_GIGABYTES, "day_size"], + "week_size": ["Weekly Total", DATA_GIGABYTES, "week_size"], + "month_size": ["Monthly Total", DATA_GIGABYTES, "month_size"], + "total_size": ["Total", DATA_GIGABYTES, "total_size"], } SPEED_LIMIT_SCHEMA = vol.Schema( diff --git a/homeassistant/components/sabnzbd/manifest.json b/homeassistant/components/sabnzbd/manifest.json index 78cfd4aa1f0a22..6fec5c008b3d0d 100644 --- a/homeassistant/components/sabnzbd/manifest.json +++ b/homeassistant/components/sabnzbd/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/sabnzbd", "requirements": ["pysabnzbd==1.1.0"], "dependencies": ["configurator"], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 704e9996d2d53a..55c2371aabbf0b 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -22,6 +22,7 @@ POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + TIME_HOURS, ) from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.exceptions import PlatformNotReady @@ -34,13 +35,11 @@ MIN_INTERVAL = 5 MAX_INTERVAL = 300 -UNIT_OF_MEASUREMENT_HOURS = "h" - INVERTER_TYPES = ["ethernet", "wifi"] SAJ_UNIT_MAPPINGS = { "": None, - "h": UNIT_OF_MEASUREMENT_HOURS, + "h": TIME_HOURS, "kg": MASS_KILOGRAMS, "kWh": ENERGY_KILO_WATT_HOUR, "W": POWER_WATT, @@ -217,7 +216,7 @@ def per_day_basis(self) -> bool: @property def per_total_basis(self) -> bool: - """Return if the sensors value is cummulative or not.""" + """Return if the sensors value is cumulative or not.""" return self._sensor.per_total_basis @property @@ -225,6 +224,7 @@ def date_updated(self) -> date: """Return the date when the sensor was last updated.""" return self._sensor.date + @callback def async_update_values(self, unknown_state=False): """Update this sensor.""" update = False diff --git a/homeassistant/components/salt/__init__.py b/homeassistant/components/salt/__init__.py new file mode 100644 index 00000000000000..29c371ece5222f --- /dev/null +++ b/homeassistant/components/salt/__init__.py @@ -0,0 +1 @@ +"""The salt component.""" diff --git a/homeassistant/components/salt/device_tracker.py b/homeassistant/components/salt/device_tracker.py new file mode 100644 index 00000000000000..7c03403622a8d4 --- /dev/null +++ b/homeassistant/components/salt/device_tracker.py @@ -0,0 +1,71 @@ +"""Support for Salt Fiber Box routers.""" +import logging + +from saltbox import RouterLoginException, RouterNotReachableException, SaltBox +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +def get_scanner(hass, config): + """Return the Salt device scanner.""" + scanner = SaltDeviceScanner(config[DOMAIN]) + + # Test whether the router is accessible. + data = scanner.get_salt_data() + return scanner if data is not None else None + + +class SaltDeviceScanner(DeviceScanner): + """This class queries a Salt Fiber Box router.""" + + def __init__(self, config): + """Initialize the scanner.""" + host = config[CONF_HOST] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + self.saltbox = SaltBox(f"http://{host}", username, password) + self.online_clients = [] + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + return [client["mac"] for client in self.online_clients] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + for client in self.online_clients: + if client["mac"] == device: + return client["name"] + return None + + def get_salt_data(self): + """Retrieve data from Salt router and return parsed result.""" + try: + clients = self.saltbox.get_online_clients() + return clients + except (RouterLoginException, RouterNotReachableException) as error: + _LOGGER.warning(error) + return None + + def _update_info(self): + """Pull the current information from the Salt router.""" + _LOGGER.debug("Loading data from Salt Fiber Box") + data = self.get_salt_data() + self.online_clients = data or [] diff --git a/homeassistant/components/salt/manifest.json b/homeassistant/components/salt/manifest.json new file mode 100644 index 00000000000000..019fdf9ae5f453 --- /dev/null +++ b/homeassistant/components/salt/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "salt", + "name": "Salt Fiber Box", + "documentation": "https://www.home-assistant.io/integrations/salt", + "requirements": ["saltbox==0.1.3"], + "dependencies": [], + "codeowners": ["@bjornorri"] +} diff --git a/homeassistant/components/samsungtv/.translations/ca.json b/homeassistant/components/samsungtv/.translations/ca.json index beeb62d8bdb001..7ca5879a5c08d1 100644 --- a/homeassistant/components/samsungtv/.translations/ca.json +++ b/homeassistant/components/samsungtv/.translations/ca.json @@ -1,7 +1,17 @@ { "config": { + "abort": { + "already_configured": "La Samsung TV ja configurada.", + "already_in_progress": "La configuraci\u00f3 de la Samsung TV ja est\u00e0 en curs.", + "auth_missing": "Home Assistant no est\u00e0 autenticat per connectar-se amb aquesta Samsung TV.", + "not_found": "No s'han trobat Samsung TV's compatibles a la xarxa.", + "not_successful": "No s'ha pogut connectar amb el dispositiu Samsung TV.", + "not_supported": "Actualment aquest dispositiu Samsung TV no \u00e9s compatible." + }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { + "description": "Vols configurar la Samsung TV {model}? Si mai abans l'has connectat a Home Assistant haur\u00edes de veure una finestra emergent a la TV demanant autenticaci\u00f3. Les configuracuons manuals d'aquesta TV es sobreescriuran.", "title": "Samsung TV" }, "user": { @@ -9,6 +19,7 @@ "host": "Amfitri\u00f3 o adre\u00e7a IP", "name": "Nom" }, + "description": "Introdeix les dades de la Samsung TV. Si mai abans l'has connectat a Home Assistant haur\u00edes de veure una finestra emergent demanant autenticaci\u00f3.", "title": "Samsung TV" } }, diff --git a/homeassistant/components/samsungtv/.translations/da.json b/homeassistant/components/samsungtv/.translations/da.json index 594127688c28e5..379fd5d8b6d2f3 100644 --- a/homeassistant/components/samsungtv/.translations/da.json +++ b/homeassistant/components/samsungtv/.translations/da.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "Dette Samsung-tv er allerede konfigureret.", "already_in_progress": "Samsung-tv-konfiguration er allerede i gang.", - "auth_missing": "Home Assistant er ikke godkendt til at oprette forbindelse til dette Samsung-tv.", + "auth_missing": "Home Assistant er ikke godkendt til at oprette forbindelse til dette Samsung-tv. Tjek dit tvs indstillinger for at godkende Home Assistant.", "not_found": "Der blev ikke fundet nogen underst\u00f8ttede Samsung-tv-enheder p\u00e5 netv\u00e6rket.", + "not_successful": "Kan ikke oprette forbindelse til denne Samsung tv-enhed.", "not_supported": "Dette Samsung TV underst\u00f8ttes i \u00f8jeblikket ikke." }, + "flow_title": "Samsung-tv: {model}", "step": { "confirm": { "description": "Vil du konfigurere Samsung-tv {model}? Hvis du aldrig har oprettet forbindelse til Home Assistant f\u00f8r, b\u00f8r du se en popup p\u00e5 dit tv, der beder om godkendelse. Manuelle konfigurationer for dette tv vil blive overskrevet.", diff --git a/homeassistant/components/samsungtv/.translations/de.json b/homeassistant/components/samsungtv/.translations/de.json index 60372837ffcc04..27b9ecc37dfb08 100644 --- a/homeassistant/components/samsungtv/.translations/de.json +++ b/homeassistant/components/samsungtv/.translations/de.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "Dieser Samsung TV ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf f\u00fcr Samsung TV wird bereits ausgef\u00fchrt.", - "auth_missing": "Home Assistant ist nicht authentifiziert, um eine Verbindung zu diesem Samsung TV herzustellen.", + "auth_missing": "Home Assistant ist nicht berechtigt, eine Verbindung zu diesem Samsung TV herzustellen. \u00dcberpr\u00fcfe die Einstellungen deines Fernsehger\u00e4ts, um Home Assistant zu autorisieren.", "not_found": "Keine unterst\u00fctzten Samsung TV-Ger\u00e4te im Netzwerk gefunden.", + "not_successful": "Es kann keine Verbindung zu diesem Samsung-Fernsehger\u00e4t hergestellt werden.", "not_supported": "Dieses Samsung TV-Ger\u00e4t wird derzeit nicht unterst\u00fctzt." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { "description": "M\u00f6chtest du Samsung TV {model} einrichten? Wenn du noch nie eine Verbindung zum Home Assistant hergestellt hast, solltest du ein Popup-Fenster auf deinem Fernseher sehen, das nach einer Authentifizierung fragt. Manuelle Konfigurationen f\u00fcr dieses Fernsehger\u00e4t werden \u00fcberschrieben.", @@ -17,7 +19,7 @@ "host": "Host oder IP-Adresse", "name": "Name" }, - "description": "Gebe deine Samsung TV-Informationen ein. Wenn du noch nie eine Verbindung zum Home Assistant hergestellt hast, solltest du ein Popup-Fenster auf deinem Fernseher sehen, das nach einer Authentifizierung fragt.", + "description": "Gib deine Samsung TV-Informationen ein. Wenn du noch nie eine Verbindung zum Home Assistant hergestellt hast, solltest du ein Popup-Fenster auf deinem Fernseher sehen, das nach einer Authentifizierung fragt.", "title": "Samsung TV" } }, diff --git a/homeassistant/components/samsungtv/.translations/en.json b/homeassistant/components/samsungtv/.translations/en.json index 24ab81c007c9bf..2d3856fbaffd04 100644 --- a/homeassistant/components/samsungtv/.translations/en.json +++ b/homeassistant/components/samsungtv/.translations/en.json @@ -3,13 +3,15 @@ "abort": { "already_configured": "This Samsung TV is already configured.", "already_in_progress": "Samsung TV configuration is already in progress.", - "auth_missing": "Home Assistant is not authenticated to connect to this Samsung TV.", + "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Please check your TV's settings to authorize Home Assistant.", "not_found": "No supported Samsung TV devices found on the network.", - "not_supported": "This Samsung TV devices is currently not supported." + "not_successful": "Unable to connect to this Samsung TV device.", + "not_supported": "This Samsung TV device is currently not supported." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authentication. Manual configurations for this TV will be overwritten.", + "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization. Manual configurations for this TV will be overwritten.", "title": "Samsung TV" }, "user": { @@ -17,7 +19,7 @@ "host": "Host or IP address", "name": "Name" }, - "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authentication.", + "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authorization.", "title": "Samsung TV" } }, diff --git a/homeassistant/components/samsungtv/.translations/es.json b/homeassistant/components/samsungtv/.translations/es.json index 3535d4bc65f058..4466b329a2a363 100644 --- a/homeassistant/components/samsungtv/.translations/es.json +++ b/homeassistant/components/samsungtv/.translations/es.json @@ -5,8 +5,10 @@ "already_in_progress": "La configuraci\u00f3n del televisor Samsung ya est\u00e1 en progreso.", "auth_missing": "Home Assistant no est\u00e1 autenticado para conectarse a este televisor Samsung.", "not_found": "No se encontraron televisiones Samsung compatibles en la red.", + "not_successful": "No se puede conectar a este dispositivo Samsung TV.", "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible." }, + "flow_title": "Televisor Samsung: {model}", "step": { "confirm": { "description": "\u00bfDesea configurar el televisor Samsung {model} ? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor pidiendo autenticaci\u00f3n. Las configuraciones manuales para este televisor se sobrescribir\u00e1n.", diff --git a/homeassistant/components/samsungtv/.translations/fr.json b/homeassistant/components/samsungtv/.translations/fr.json index b880e41e5dfcc8..e381660a3e2cdc 100644 --- a/homeassistant/components/samsungtv/.translations/fr.json +++ b/homeassistant/components/samsungtv/.translations/fr.json @@ -5,8 +5,10 @@ "already_in_progress": "La configuration du t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 en cours.", "auth_missing": "Home Assistant n'est pas authentifi\u00e9 pour se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung.", "not_found": "Aucun t\u00e9l\u00e9viseur Samsung pris en charge trouv\u00e9 sur le r\u00e9seau.", + "not_successful": "Impossible de se connecter \u00e0 cet appareil Samsung TV.", "not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { "description": "Voulez vous installer la TV {model} Samsung? Si vous n'avez jamais connect\u00e9 Home Assistant avant, vous devriez voir une fen\u00eatre contextuelle sur votre t\u00e9l\u00e9viseur demandant une authentification. Les configurations manuelles de ce t\u00e9l\u00e9viseur seront \u00e9cras\u00e9es.", diff --git a/homeassistant/components/samsungtv/.translations/hu.json b/homeassistant/components/samsungtv/.translations/hu.json new file mode 100644 index 00000000000000..c7a046428bcec9 --- /dev/null +++ b/homeassistant/components/samsungtv/.translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Ez a Samsung TV m\u00e1r konfigur\u00e1lva van.", + "already_in_progress": "A Samsung TV konfigur\u00e1l\u00e1sa m\u00e1r folyamatban van.", + "auth_missing": "A Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizze a TV-k\u00e9sz\u00fcl\u00e9k\u00e9ben a Home Assistant enged\u00e9lyez\u00e9si be\u00e1ll\u00edt\u00e1sait.", + "not_found": "A h\u00e1l\u00f3zaton nem tal\u00e1lhat\u00f3 t\u00e1mogatott Samsung TV-eszk\u00f6z.", + "not_successful": "Nem lehet csatlakozni ehhez a Samsung TV k\u00e9sz\u00fcl\u00e9khez.", + "not_supported": "Ez a Samsung TV k\u00e9sz\u00fcl\u00e9k jelenleg nem t\u00e1mogatott." + }, + "flow_title": "Samsung TV: {model}", + "step": { + "confirm": { + "description": "Be\u00e1ll\u00edtja a Samsung TV {model} k\u00e9sz\u00fcl\u00e9ket? Ha soha nem csatlakozott home assistant-hez ezel\u0151tt, meg kell jelennie egy felugr\u00f3 ablaknak a TV-ben, ahol hiteles\u00edt\u00e9st k\u00e9r. A tv-k\u00e9sz\u00fcl\u00e9k manu\u00e1lis konfigur\u00e1ci\u00f3i fel\u00fcl\u00edr\u00f3dnak.", + "title": "Samsung TV" + }, + "user": { + "data": { + "host": "Hosztn\u00e9v vagy IP c\u00edm", + "name": "N\u00e9v" + }, + "description": "\u00cdrja be a Samsung TV adatait. Ha soha nem csatlakoztatta a Home Assistant alkalmaz\u00e1st ezel\u0151tt, l\u00e1tnia kell a t\u00e9v\u00e9ben egy felugr\u00f3 ablakot, amely enged\u00e9lyt k\u00e9r.", + "title": "Samsung TV" + } + }, + "title": "Samsung TV" + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/.translations/it.json b/homeassistant/components/samsungtv/.translations/it.json index c783db24720c16..3d2d4dd8e11fa4 100644 --- a/homeassistant/components/samsungtv/.translations/it.json +++ b/homeassistant/components/samsungtv/.translations/it.json @@ -3,13 +3,15 @@ "abort": { "already_configured": "Questo Samsung TV \u00e8 gi\u00e0 configurato.", "already_in_progress": "La configurazione di Samsung TV \u00e8 gi\u00e0 in corso.", - "auth_missing": "Home Assistant non \u00e8 autenticato per connettersi a questo Samsung TV.", + "auth_missing": "Home Assistant non \u00e8 autorizzato a connettersi a questo Samsung TV. Controlla le impostazioni del tuo TV per autorizzare Home Assistant.", "not_found": "Nessun dispositivo Samsung TV supportato trovato sulla rete.", + "not_successful": "Impossibile connettersi a questo dispositivo Samsung TV.", "not_supported": "Questo dispositivo Samsung TV non \u00e8 attualmente supportato." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "Vuoi configurare Samsung TV {model} ? Se non hai mai collegato Home Assistant dovresti vedere un popup sul televisore in cui viene richiesta l'autenticazione. Le configurazioni manuali per questo televisore verranno sovrascritte.", + "description": "Vuoi configurare Samsung TV {model}? Se non hai mai connesso Home Assistant in precedenza, dovresti vedere un messaggio sul tuo TV in cui \u00e8 richiesta l'autorizzazione. Le configurazioni manuali per questo TV verranno sovrascritte.", "title": "Samsung TV" }, "user": { @@ -17,7 +19,7 @@ "host": "Host o indirizzo IP", "name": "Nome" }, - "description": "Inserisci le informazioni del tuo Samsung TV. Se non hai mai connesso Home Assistant dovresti vedere un popup sul televisore in cui viene richiesta l'autenticazione.", + "description": "Inserisci le informazioni del tuo Samsung TV. Se non hai mai connesso Home Assistant in precedenza, dovresti vedere un messaggio sul TV in cui \u00e8 richiesta l'autorizzazione.", "title": "Samsung TV" } }, diff --git a/homeassistant/components/samsungtv/.translations/ko.json b/homeassistant/components/samsungtv/.translations/ko.json index 2817c36989ba15..0226fd52dc09e2 100644 --- a/homeassistant/components/samsungtv/.translations/ko.json +++ b/homeassistant/components/samsungtv/.translations/ko.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "\uc774 \uc0bc\uc131 TV \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "already_in_progress": "\uc0bc\uc131 TV \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.", - "auth_missing": "Home Assistant \uac00 \ud574\ub2f9 \uc0bc\uc131 TV \uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "auth_missing": "Home Assistant \uac00 \ud574\ub2f9 \uc0bc\uc131 TV \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc788\ub294 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. TV \uc124\uc815\uc744 \ud655\uc778\ud558\uc5ec Home Assistant \ub97c \uc2b9\uc778\ud574\uc8fc\uc138\uc694.", "not_found": "\uc9c0\uc6d0\ub418\ub294 \uc0bc\uc131 TV \ubaa8\ub378\uc774 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "not_successful": "\uc0bc\uc131 TV \uae30\uae30\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "not_supported": "\uc774 \uc0bc\uc131 TV \ubaa8\ub378\uc740 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." }, + "flow_title": "\uc0bc\uc131 TV: {model}", "step": { "confirm": { "description": "\uc0bc\uc131 TV {model} \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c? Home Assistant \ub97c \uc5f0\uacb0 \ud55c \uc801\uc774 \uc5c6\ub2e4\uba74 TV \uc5d0\uc11c \uc778\uc99d\uc744 \uc694\uccad\ud558\ub294 \ud31d\uc5c5\uc774 \ud45c\uc2dc\ub429\ub2c8\ub2e4. \uc774 TV \uc758 \uc218\ub3d9\uc73c\ub85c \uad6c\uc131\ub41c \ub0b4\uc6a9\uc744 \ub36e\uc5b4\uc501\ub2c8\ub2e4.", diff --git a/homeassistant/components/samsungtv/.translations/lb.json b/homeassistant/components/samsungtv/.translations/lb.json index fe1f02e55eaa24..b3a94a1a2a6f75 100644 --- a/homeassistant/components/samsungtv/.translations/lb.json +++ b/homeassistant/components/samsungtv/.translations/lb.json @@ -5,8 +5,10 @@ "already_in_progress": "Konfiguratioun fir d\u00ebs Samsung TV ass schonn am gaang.", "auth_missing": "Home Assistant ass net authentifiz\u00e9iert fir sech mat d\u00ebsem Samsung TV ze verbannen.", "not_found": "Keng \u00ebnnerst\u00ebtzte Samsung TV am Netzwierk fonnt.", + "not_successful": "Keng Verbindung mat d\u00ebsem Samsung TV Apparat m\u00e9iglech.", "not_supported": "D\u00ebsen Samsung TV Modell g\u00ebtt momentan net \u00ebnnerst\u00ebtzt" }, + "flow_title": "Samsnung TV:{model}", "step": { "confirm": { "description": "W\u00ebllt dir de Samsung TV {model} ariichten?. Falls dir Home Assistant nach ni domat verbonnen hutt misst den TV eng Meldung mat enger Authentifiz\u00e9ierung uweisen. Manuell Konfiguratioun g\u00ebtt iwwerschriwwen.", diff --git a/homeassistant/components/samsungtv/.translations/nl.json b/homeassistant/components/samsungtv/.translations/nl.json index 93bb5953e31b86..09c0bba05a387c 100644 --- a/homeassistant/components/samsungtv/.translations/nl.json +++ b/homeassistant/components/samsungtv/.translations/nl.json @@ -1,12 +1,17 @@ { "config": { "abort": { - "auth_missing": "Home Assistant is niet geverifieerd om verbinding te maken met deze Samsung TV.", + "already_configured": "Deze Samsung TV is al geconfigureerd.", + "already_in_progress": "Samsung TV configuratie is al in uitvoering.", + "auth_missing": "Home Assistant is niet geautoriseerd om verbinding te maken met deze Samsung TV.", "not_found": "Geen ondersteunde Samsung TV-apparaten gevonden op het netwerk.", - "not_supported": "Deze Samsung TV-apparaten wordt momenteel niet ondersteund." + "not_successful": "Niet in staat om verbinding te maken met dit Samsung TV toestel.", + "not_supported": "Deze Samsung TV wordt momenteel niet ondersteund." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { + "description": "Wilt u Samsung TV {model} instellen? Als u nooit eerder Home Assistant hebt verbonden dan zou u een popup op uw TV moeten zien waarin u om toestemming wordt vraagt. Handmatige configuraties voor deze TV worden overschreven", "title": "Samsung TV" }, "user": { @@ -14,6 +19,7 @@ "host": "Hostnaam of IP-adres", "name": "Naam" }, + "description": "Voer uw Samsung TV informatie in. Als u nooit eerder Home Assistant hebt verbonden dan zou u een popup op uw TV moeten zien waarin u om toestemming wordt vraagt.", "title": "Samsung TV" } }, diff --git a/homeassistant/components/samsungtv/.translations/no.json b/homeassistant/components/samsungtv/.translations/no.json index dcd437642b27c1..544ab581be8adb 100644 --- a/homeassistant/components/samsungtv/.translations/no.json +++ b/homeassistant/components/samsungtv/.translations/no.json @@ -3,13 +3,15 @@ "abort": { "already_configured": "Denne Samsung TV-en er allerede konfigurert.", "already_in_progress": "Samsung TV-konfigurasjon p\u00e5g\u00e5r allerede.", - "auth_missing": "Home Assistant er ikke autentisert for \u00e5 koble til denne Samsung TV-en.", + "auth_missing": "Home Assistant er ikke autorisert til \u00e5 koble til denne Samsung-TV. Vennligst kontroller innstillingene for TV-en for \u00e5 autorisere Home Assistent.", "not_found": "Ingen st\u00f8ttede Samsung TV-enheter funnet i nettverket.", + "not_successful": "Kan ikke koble til denne Samsung TV-enheten.", "not_supported": "Denne Samsung TV-enhetene st\u00f8ttes forel\u00f8pig ikke." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "Vil du sette opp Samsung TV {model} ? Hvis du aldri koblet til Home Assistant f\u00f8r, vil en popup p\u00e5 TVen be om godkjenning. Manuelle konfigurasjoner for denne TVen vil bli overskrevet.", + "description": "Vil du sette opp Samsung TV {model} ? Hvis du aldri har koblet til Home Assistant f\u00f8r, vil en popup p\u00e5 TVen be om godkjenning. Manuelle konfigurasjoner for denne TVen vil bli overskrevet.", "title": "Samsung TV" }, "user": { @@ -17,7 +19,7 @@ "host": "Vert eller IP-adresse", "name": "Navn" }, - "description": "Skriv inn Samsung TV-informasjonen din. Hvis du aldri koblet til Home Assistant f\u00f8r, vil en popup p\u00e5 TVen be om godkjenning.", + "description": "Skriv inn Samsung TV-informasjonen din. Hvis du aldri har koblet til Home Assistant f\u00f8r, vil en popup p\u00e5 TVen be om godkjenning.", "title": "Samsung TV" } }, diff --git a/homeassistant/components/samsungtv/.translations/pl.json b/homeassistant/components/samsungtv/.translations/pl.json index e31aea01d46821..200d8d2cf9ac42 100644 --- a/homeassistant/components/samsungtv/.translations/pl.json +++ b/homeassistant/components/samsungtv/.translations/pl.json @@ -5,8 +5,10 @@ "already_in_progress": "Konfiguracja telewizora Samsung jest ju\u017c w toku.", "auth_missing": "Home Assistant nie jest uwierzytelniony, aby po\u0142\u0105czy\u0107 si\u0119 z tym telewizorem Samsung.", "not_found": "W sieci nie znaleziono obs\u0142ugiwanych telewizor\u00f3w Samsung.", - "not_supported": "Te telewizor Samsung nie jest obecnie obs\u0142ugiwany." + "not_successful": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 urz\u0105dzeniem Samsung TV.", + "not_supported": "Ten telewizor Samsung nie jest obecnie obs\u0142ugiwany." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { "description": "Czy chcesz skonfigurowa\u0107 telewizor Samsung {model}? Je\u015bli nigdy wcze\u015bniej ten telewizor nie by\u0142 \u0142\u0105czony z Home Assistant'em na jego ekranie powinna pojawi\u0107 si\u0119 pro\u015bba o uwierzytelnienie. R\u0119czne konfiguracje tego telewizora zostan\u0105 zast\u0105pione.", diff --git a/homeassistant/components/samsungtv/.translations/ru.json b/homeassistant/components/samsungtv/.translations/ru.json index d5dd11a1b8056d..14f772c5e1d6b6 100644 --- a/homeassistant/components/samsungtv/.translations/ru.json +++ b/homeassistant/components/samsungtv/.translations/ru.json @@ -3,13 +3,15 @@ "abort": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", - "auth_missing": "Home Assistant \u043d\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u043e\u0432\u0430\u043d \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u044d\u0442\u043e\u043c\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "auth_missing": "Home Assistant \u043d\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u044d\u0442\u043e\u043c\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430.", "not_found": "\u0412 \u0441\u0435\u0442\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432.", + "not_successful": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", "not_supported": "\u042d\u0442\u0430 \u043c\u043e\u0434\u0435\u043b\u044c \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f." }, + "flow_title": "Samsung TV: {model}", "step": { "confirm": { - "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Samsung {model}? \u0415\u0441\u043b\u0438 \u044d\u0442\u043e\u0442 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0440\u0430\u043d\u0435\u0435 \u043d\u0435 \u0431\u044b\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a Home Assistant, \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u043e \u043f\u043e\u044f\u0432\u0438\u0442\u044c\u0441\u044f \u0432\u0441\u043f\u043b\u044b\u0432\u0430\u044e\u0449\u0435\u0435 \u043e\u043a\u043d\u043e \u0441 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e, \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u0430\u043d\u044b.", + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Samsung {model}? \u0415\u0441\u043b\u0438 \u044d\u0442\u043e\u0442 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0440\u0430\u043d\u0435\u0435 \u043d\u0435 \u0431\u044b\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a Home Assistant, \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u043e \u043f\u043e\u044f\u0432\u0438\u0442\u044c\u0441\u044f \u0432\u0441\u043f\u043b\u044b\u0432\u0430\u044e\u0449\u0435\u0435 \u043e\u043a\u043d\u043e \u0441 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u043c \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438. \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u044b\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e, \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u0430\u043d\u044b.", "title": "\u0422\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Samsung" }, "user": { @@ -17,7 +19,7 @@ "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435 Samsung. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e\u0442 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0440\u0430\u043d\u0435\u0435 \u043d\u0435 \u0431\u044b\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a Home Assistant, \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u043e \u043f\u043e\u044f\u0432\u0438\u0442\u044c\u0441\u044f \u0432\u0441\u043f\u043b\u044b\u0432\u0430\u044e\u0449\u0435\u0435 \u043e\u043a\u043d\u043e \u0441 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435 Samsung. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e\u0442 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0440\u0430\u043d\u0435\u0435 \u043d\u0435 \u0431\u044b\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a Home Assistant, \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 \u0434\u043e\u043b\u0436\u043d\u043e \u043f\u043e\u044f\u0432\u0438\u0442\u044c\u0441\u044f \u0432\u0441\u043f\u043b\u044b\u0432\u0430\u044e\u0449\u0435\u0435 \u043e\u043a\u043d\u043e \u0441 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u043c \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", "title": "\u0422\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Samsung" } }, diff --git a/homeassistant/components/samsungtv/.translations/sl.json b/homeassistant/components/samsungtv/.translations/sl.json new file mode 100644 index 00000000000000..95286476ed013b --- /dev/null +++ b/homeassistant/components/samsungtv/.translations/sl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Ta televizor Samsung je \u017ee konfiguriran.", + "already_in_progress": "Konfiguracija Samsung TV je \u017ee v teku.", + "auth_missing": "Home Assistant nima dovoljenja za povezavo s tem televizorjem Samsung. Preverite nastavitve televizorja, da ga pooblastite.", + "not_found": "V omre\u017eju ni bilo najdenih nobenih podprtih naprav Samsung TV.", + "not_successful": "Povezave s to napravo Samsung TV ni mogo\u010de vzpostaviti.", + "not_supported": "Ta naprava Samsung TV trenutno ni podprta." + }, + "flow_title": "Samsung TV: {model}", + "step": { + "confirm": { + "description": "Vnesite podatke o televizorju Samsung. \u010ce \u0161e nikoli niste povezali Home Assistant, bi morali na televizorju videli pojavno okno, ki zahteva va\u0161e dovoljenje. Ro\u010dna konfiguracija za ta TV bo prepisana.", + "title": "Samsung TV" + }, + "user": { + "data": { + "host": "Gostitelj ali IP naslov", + "name": "Ime" + }, + "description": "Vnesite podatke o televizorju Samsung. \u010ce \u0161e nikoli niste povezali Home Assistant, bi morali na televizorju videli pojavno okno, ki zahteva va\u0161e dovoljenje.", + "title": "Samsung TV" + } + }, + "title": "Samsung TV" + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/.translations/sv.json b/homeassistant/components/samsungtv/.translations/sv.json new file mode 100644 index 00000000000000..f75e8238506297 --- /dev/null +++ b/homeassistant/components/samsungtv/.translations/sv.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Denna Samsung TV \u00e4r redan konfigurerad.", + "already_in_progress": "Samsung TV-konfiguration p\u00e5g\u00e5r redan.", + "auth_missing": "Home Assistant har inte beh\u00f6righet att ansluta till denna Samsung TV. Kontrollera tv:ns inst\u00e4llningar f\u00f6r att godk\u00e4nna Home Assistant.", + "not_found": "Inga Samsung TV-enheter som st\u00f6ds finns i n\u00e4tverket.", + "not_successful": "Det g\u00e5r inte att ansluta till denna Samsung TV-enhet.", + "not_supported": "Denna Samsung TV-enhet st\u00f6ds f\u00f6r n\u00e4rvarande inte." + }, + "flow_title": "Samsung TV: {model}", + "step": { + "confirm": { + "description": "Vill du st\u00e4lla in Samsung TV {model}? Om du aldrig har anslutit Home Assistant innan du ska se ett popup-f\u00f6nster p\u00e5 tv:n och be om auktorisering. Manuella konfigurationer f\u00f6r den h\u00e4r TV:n skrivs \u00f6ver.", + "title": "Samsung TV" + }, + "user": { + "data": { + "host": "V\u00e4rdnamn eller IP-adress", + "name": "Namn" + }, + "description": "Ange informationen f\u00f6r din Samsung TV. Om du aldrig har anslutit denna till Home Assistant tidigare borde du se en popup om autentisering p\u00e5 din TV.", + "title": "Samsung TV" + } + }, + "title": "Samsung TV" + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/.translations/tr.json b/homeassistant/components/samsungtv/.translations/tr.json new file mode 100644 index 00000000000000..3cf1f135e1fd7b --- /dev/null +++ b/homeassistant/components/samsungtv/.translations/tr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Bu Samsung TV zaten ayarlanm\u0131\u015f.", + "already_in_progress": "Samsung TV ayar\u0131 zaten s\u00fcr\u00fcyor.", + "auth_missing": "Home Assistant'\u0131n bu Samsung TV'ye ba\u011flanma izni yok. Home Assistant'\u0131 yetkilendirmek i\u00e7in l\u00fctfen TV'nin ayarlar\u0131n\u0131 kontrol et.", + "not_found": "A\u011fda desteklenen Samsung TV cihaz\u0131 bulunamad\u0131.", + "not_successful": "Bu Samsung TV cihaz\u0131na ba\u011flan\u0131lam\u0131yor.", + "not_supported": "Bu Samsung TV cihaz\u0131 \u015fu anda desteklenmiyor." + }, + "flow_title": "Samsung TV: {model}", + "step": { + "user": { + "data": { + "host": "Host veya IP adresi", + "name": "Ad" + }, + "description": "Samsung TV bilgilerini gir. Daha \u00f6nce hi\u00e7 Home Assistant'a ba\u011flamad\u0131ysan, TV'nde izin isteyen bir pencere g\u00f6receksindir.", + "title": "Samsung TV" + } + }, + "title": "Samsung TV" + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/.translations/zh-Hant.json b/homeassistant/components/samsungtv/.translations/zh-Hant.json index 272dffaa4826b0..80cfa32a6bf360 100644 --- a/homeassistant/components/samsungtv/.translations/zh-Hant.json +++ b/homeassistant/components/samsungtv/.translations/zh-Hant.json @@ -3,10 +3,12 @@ "abort": { "already_configured": "\u4e09\u661f\u96fb\u8996\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u4e09\u661f\u96fb\u8996\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", - "auth_missing": "Home Assistant \u672a\u7372\u5f97\u9a57\u8b49\u4ee5\u9023\u7dda\u81f3\u6b64\u4e09\u661f\u96fb\u8996\u3002", + "auth_missing": "Home Assistant \u672a\u7372\u5f97\u9a57\u8b49\u4ee5\u9023\u7dda\u81f3\u6b64\u4e09\u661f\u96fb\u8996\u3002\u8acb\u6aa2\u67e5\u60a8\u7684\u96fb\u8996\u8a2d\u5b9a\u4ee5\u76e1\u8208\u9a57\u8b49\u3002", "not_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u652f\u63f4\u7684\u4e09\u661f\u96fb\u8996\u3002", + "not_successful": "\u7121\u6cd5\u9023\u7dda\u81f3\u4e09\u661f\u96fb\u8996\u8a2d\u5099\u3002", "not_supported": "\u4e0d\u652f\u63f4\u6b64\u6b3e\u4e09\u661f\u96fb\u8996\u3002" }, + "flow_title": "\u4e09\u661f\u96fb\u8996\uff1a{model}", "step": { "confirm": { "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u4e09\u661f\u96fb\u8996 {model}\uff1f\u5047\u5982\u60a8\u4e4b\u524d\u672a\u66fe\u9023\u7dda\u81f3 Home Assistant\uff0c\u61c9\u8a72\u6703\u65bc\u96fb\u8996\u4e0a\u6536\u5230\u9a57\u8b49\u8a0a\u606f\u3002\u624b\u52d5\u8a2d\u5b9a\u5c07\u6703\u8986\u84cb\u539f\u8a2d\u5b9a\u3002", diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 5647b407bfb475..bc49dc3156df63 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -3,6 +3,7 @@ import voluptuous as vol +from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv @@ -41,7 +42,14 @@ def ensure_unique_hosts(value): async def async_setup(hass, config): """Set up the Samsung TV integration.""" if DOMAIN in config: + hass.data[DOMAIN] = {} for entry_config in config[DOMAIN]: + ip_address = await hass.async_add_executor_job( + socket.gethostbyname, entry_config[CONF_HOST] + ) + hass.data[DOMAIN][ip_address] = { + CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION) + } hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"}, data=entry_config @@ -54,7 +62,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up the Samsung TV platform.""" hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "media_player") + hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN) ) return True diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 0bf39cc248b3b5..e52123297ab957 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -5,11 +5,11 @@ from samsungctl import Remote from samsungctl.exceptions import AccessDenied, UnhandledResponse import voluptuous as vol +from websocket import WebSocketException from homeassistant import config_entries from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME, ATTR_UPNP_UDN, @@ -24,22 +24,21 @@ ) # pylint:disable=unused-import -from .const import ( - CONF_MANUFACTURER, - CONF_MODEL, - CONF_ON_ACTION, - DOMAIN, - LOGGER, - METHODS, -) +from .const import CONF_MANUFACTURER, CONF_MODEL, DOMAIN, LOGGER DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) RESULT_AUTH_MISSING = "auth_missing" RESULT_SUCCESS = "success" -RESULT_NOT_FOUND = "not_found" +RESULT_NOT_SUCCESSFUL = "not_successful" RESULT_NOT_SUPPORTED = "not_supported" +SUPPORTED_METHODS = ( + {"method": "websocket", "timeout": 1}, + # We need this high timeout because waiting for auth popup is just an open socket + {"method": "legacy", "timeout": 31}, +) + def _get_ip(host): if host is None: @@ -63,60 +62,56 @@ def __init__(self): self._method = None self._model = None self._name = None - self._on_script = None self._port = None self._title = None - self._uuid = None + self._id = None def _get_entry(self): return self.async_create_entry( title=self._title, data={ CONF_HOST: self._host, - CONF_ID: self._uuid, + CONF_ID: self._id, CONF_IP_ADDRESS: self._ip, CONF_MANUFACTURER: self._manufacturer, CONF_METHOD: self._method, CONF_MODEL: self._model, CONF_NAME: self._name, - CONF_ON_ACTION: self._on_script, CONF_PORT: self._port, }, ) def _try_connect(self): """Try to connect and check auth.""" - for method in METHODS: + for cfg in SUPPORTED_METHODS: config = { "name": "HomeAssistant", "description": "HomeAssistant", "id": "ha.component.samsung", "host": self._host, - "method": method, "port": self._port, - "timeout": 1, } + config.update(cfg) try: LOGGER.debug("Try config: %s", config) with Remote(config.copy()): LOGGER.debug("Working config: %s", config) - self._method = method + self._method = cfg["method"] return RESULT_SUCCESS except AccessDenied: LOGGER.debug("Working but denied config: %s", config) return RESULT_AUTH_MISSING - except UnhandledResponse: + except (UnhandledResponse, WebSocketException): LOGGER.debug("Working but unsupported config: %s", config) return RESULT_NOT_SUPPORTED - except (OSError): - LOGGER.debug("Failing config: %s", config) + except OSError as err: + LOGGER.debug("Failing config: %s, error: %s", config, err) LOGGER.debug("No working config found") - return RESULT_NOT_FOUND + return RESULT_NOT_SUCCESSFUL async def async_step_import(self, user_input=None): """Handle configuration by yaml file.""" - self._on_script = user_input.get(CONF_ON_ACTION) self._port = user_input.get(CONF_PORT) return await self.async_step_user(user_input) @@ -133,7 +128,8 @@ async def async_step_user(self, user_input=None): self._host = user_input.get(CONF_HOST) self._ip = self.context[CONF_IP_ADDRESS] = ip_address - self._title = user_input.get(CONF_NAME) + self._name = user_input.get(CONF_NAME) + self._title = self._name result = await self.hass.async_add_executor_job(self._try_connect) @@ -150,24 +146,27 @@ async def async_step_ssdp(self, user_input=None): self._host = host self._ip = self.context[CONF_IP_ADDRESS] = ip_address - self._manufacturer = user_input[ATTR_UPNP_MANUFACTURER] - self._model = user_input[ATTR_UPNP_MODEL_NAME] - self._name = user_input[ATTR_UPNP_FRIENDLY_NAME] - if self._name.startswith("[TV]"): - self._name = self._name[4:] - self._title = f"{self._name} ({self._model})" - self._uuid = user_input[ATTR_UPNP_UDN] - if self._uuid.startswith("uuid:"): - self._uuid = self._uuid[5:] + self._manufacturer = user_input.get(ATTR_UPNP_MANUFACTURER) + self._model = user_input.get(ATTR_UPNP_MODEL_NAME) + self._name = f"Samsung {self._model}" + self._id = user_input.get(ATTR_UPNP_UDN) + self._title = self._model + + # probably access denied + if self._id is None: + return self.async_abort(reason=RESULT_AUTH_MISSING) + if self._id.startswith("uuid:"): + self._id = self._id[5:] config_entry = await self.async_set_unique_id(ip_address) if config_entry: - config_entry.data[CONF_ID] = self._uuid + config_entry.data[CONF_ID] = self._id config_entry.data[CONF_MANUFACTURER] = self._manufacturer config_entry.data[CONF_MODEL] = self._model self.hass.config_entries.async_update_entry(config_entry) return self.async_abort(reason="already_configured") + self.context["title_placeholders"] = {"model": self._model} return await self.async_step_confirm() async def async_step_confirm(self, user_input=None): @@ -182,3 +181,19 @@ async def async_step_confirm(self, user_input=None): return self.async_show_form( step_id="confirm", description_placeholders={"model": self._model} ) + + async def async_step_reauth(self, user_input=None): + """Handle configuration by re-auth.""" + self._host = user_input[CONF_HOST] + self._id = user_input.get(CONF_ID) + self._ip = user_input[CONF_IP_ADDRESS] + self._manufacturer = user_input.get(CONF_MANUFACTURER) + self._model = user_input.get(CONF_MODEL) + self._name = user_input.get(CONF_NAME) + self._port = user_input.get(CONF_PORT) + self._title = self._model or self._name + + await self.async_set_unique_id(self._ip) + self.context["title_placeholders"] = {"model": self._title} + + return await self.async_step_confirm() diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 7cf71e406cb1de..46f6fb59a8c69f 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -4,10 +4,8 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "samsungtv" -DEFAULT_NAME = "Samsung TV Remote" +DEFAULT_NAME = "Samsung TV" CONF_MANUFACTURER = "manufacturer" CONF_MODEL = "model" CONF_ON_ACTION = "turn_on_action" - -METHODS = ("websocket", "legacy") diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 0d0a360fc20075..3adc3b52eb3629 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -7,7 +7,7 @@ ], "ssdp": [ { - "deviceType": "urn:samsung.com:device:RemoteControlReceiver:1" + "st": "urn:samsung.com:device:RemoteControlReceiver:1" } ], "dependencies": [], diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index aca54838a99ee2..8de42d157b7b51 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -23,7 +23,9 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, + CONF_IP_ADDRESS, CONF_METHOD, + CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON, @@ -59,8 +61,16 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Samsung TV from a config entry.""" - turn_on_action = config_entry.data.get(CONF_ON_ACTION) - on_script = Script(hass, turn_on_action) if turn_on_action else None + ip_address = config_entry.data[CONF_IP_ADDRESS] + on_script = None + if ( + DOMAIN in hass.data + and ip_address in hass.data[DOMAIN] + and CONF_ON_ACTION in hass.data[DOMAIN][ip_address] + and hass.data[DOMAIN][ip_address][CONF_ON_ACTION] + ): + turn_on_action = hass.data[DOMAIN][ip_address][CONF_ON_ACTION] + on_script = Script(hass, turn_on_action) async_add_entities([SamsungTVDevice(config_entry, on_script)]) @@ -70,12 +80,11 @@ class SamsungTVDevice(MediaPlayerDevice): def __init__(self, config_entry, on_script): """Initialize the Samsung device.""" self._config_entry = config_entry - self._name = config_entry.title - self._uuid = config_entry.data.get(CONF_ID) self._manufacturer = config_entry.data.get(CONF_MANUFACTURER) self._model = config_entry.data.get(CONF_MODEL) + self._name = config_entry.data.get(CONF_NAME) self._on_script = on_script - self._update_listener = None + self._uuid = config_entry.data.get(CONF_ID) # Assume that the TV is not muted self._muted = False # Assume that the TV is in Play mode @@ -88,7 +97,7 @@ def __init__(self, config_entry, on_script): # Generate a configuration for the Samsung library self._config = { "name": "HomeAssistant", - "description": self._name, + "description": "HomeAssistant", "id": "ha.component.samsung", "method": config_entry.data[CONF_METHOD], "port": config_entry.data.get(CONF_PORT), @@ -124,7 +133,19 @@ def get_remote(self): """Create or return a remote control instance.""" if self._remote is None: # We need to create a new instance to reconnect. - self._remote = SamsungRemote(self._config.copy()) + try: + self._remote = SamsungRemote(self._config.copy()) + # This is only happening when the auth was switched to DENY + # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket + except samsung_exceptions.AccessDenied: + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=self._config_entry.data, + ) + ) + raise return self._remote diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index ee762503e5ce4c..2e36062669f573 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -1,10 +1,11 @@ { "config": { + "flow_title": "Samsung TV: {model}", "title": "Samsung TV", "step": { "user": { "title": "Samsung TV", - "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authentication.", + "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authorization.", "data": { "host": "Host or IP address", "name": "Name" @@ -12,15 +13,15 @@ }, "confirm": { "title": "Samsung TV", - "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authentication. Manual configurations for this TV will be overwritten." + "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization. Manual configurations for this TV will be overwritten." } }, "abort": { "already_in_progress": "Samsung TV configuration is already in progress.", "already_configured": "This Samsung TV is already configured.", - "auth_missing": "Home Assistant is not authenticated to connect to this Samsung TV.", - "not_found": "No supported Samsung TV devices found on the network.", - "not_supported": "This Samsung TV devices is currently not supported." + "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Please check your TV's settings to authorize Home Assistant.", + "not_successful": "Unable to connect to this Samsung TV device.", + "not_supported": "This Samsung TV device is currently not supported." } } } diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 75ec2bfd87546c..46b06b9369891a 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -61,10 +61,7 @@ async def async_setup(hass, config): await component.async_setup(config) # Ensure Home Assistant platform always loaded. - await component.async_setup_platform( - HA_DOMAIN, {"platform": "homeasistant", STATES: []} - ) - + await component.async_setup_platform(HA_DOMAIN, {"platform": HA_DOMAIN, STATES: []}) component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_activate") return True @@ -97,9 +94,6 @@ def activate(self): """Activate scene. Try to get entities into requested state.""" raise NotImplementedError() - def async_activate(self): - """Activate scene. Try to get entities into requested state. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.activate) + async def async_activate(self): + """Activate scene. Try to get entities into requested state.""" + await self.hass.async_add_job(self.activate) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index e0800cdef27ce7..90352bbd10889b 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/scrape", "requirements": ["beautifulsoup4==4.8.2"], "dependencies": [], + "after_dependencies": ["rest"], "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 1d180b54cfd6bc..9384c58db81783 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -1,6 +1,7 @@ """Support for scripts.""" import asyncio import logging +from typing import List import voluptuous as vol @@ -8,6 +9,7 @@ ATTR_ENTITY_ID, ATTR_NAME, CONF_ALIAS, + CONF_ICON, EVENT_SCRIPT_STARTED, SERVICE_RELOAD, SERVICE_TOGGLE, @@ -15,6 +17,7 @@ SERVICE_TURN_ON, STATE_ON, ) +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity @@ -40,7 +43,8 @@ SCRIPT_ENTRY_SCHEMA = vol.Schema( { - CONF_ALIAS: cv.string, + vol.Optional(CONF_ALIAS): cv.string, + vol.Optional(CONF_ICON): cv.icon, vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DESCRIPTION, default=""): cv.string, vol.Optional(CONF_FIELDS, default={}): { @@ -69,9 +73,75 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) +@callback +def scripts_with_entity(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all scripts that reference the entity.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + results = [] + + for script_entity in component.entities: + if entity_id in script_entity.script.referenced_entities: + results.append(script_entity.entity_id) + + return results + + +@callback +def entities_in_script(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all entities in a scene.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + script_entity = component.get_entity(entity_id) + + if script_entity is None: + return [] + + return list(script_entity.script.referenced_entities) + + +@callback +def scripts_with_device(hass: HomeAssistant, device_id: str) -> List[str]: + """Return all scripts that reference the device.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + results = [] + + for script_entity in component.entities: + if device_id in script_entity.script.referenced_devices: + results.append(script_entity.entity_id) + + return results + + +@callback +def devices_in_script(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all devices in a scene.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + script_entity = component.get_entity(entity_id) + + if script_entity is None: + return [] + + return list(script_entity.script.referenced_devices) + + async def async_setup(hass, config): """Load the scripts from the configuration.""" - component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = component = EntityComponent(_LOGGER, DOMAIN, hass) await _async_process_config(hass, config, component) @@ -139,9 +209,15 @@ async def service_handler(service): scripts = [] for object_id, cfg in config.get(DOMAIN, {}).items(): - alias = cfg.get(CONF_ALIAS, object_id) - script = ScriptEntity(hass, object_id, alias, cfg[CONF_SEQUENCE]) - scripts.append(script) + scripts.append( + ScriptEntity( + hass, + object_id, + cfg.get(CONF_ALIAS, object_id), + cfg.get(CONF_ICON), + cfg[CONF_SEQUENCE], + ) + ) hass.services.async_register( DOMAIN, object_id, service_handler, schema=SCRIPT_SERVICE_SCHEMA ) @@ -159,11 +235,16 @@ async def service_handler(service): class ScriptEntity(ToggleEntity): """Representation of a script entity.""" - def __init__(self, hass, object_id, name, sequence): + icon = None + + def __init__(self, hass, object_id, name, icon, sequence): """Initialize the script.""" self.object_id = object_id + self.icon = icon self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self.script = Script(hass, sequence, name, self.async_update_ha_state) + self.script = Script( + hass, sequence, name, self.async_update_ha_state, logger=_LOGGER + ) @property def should_poll(self): @@ -200,22 +281,15 @@ async def async_turn_on(self, **kwargs): {ATTR_NAME: self.script.name, ATTR_ENTITY_ID: self.entity_id}, context=context, ) - try: - await self.script.async_run(kwargs.get(ATTR_VARIABLES), context) - except Exception as err: - self.script.async_log_exception( - _LOGGER, f"Error executing script {self.entity_id}", err - ) - raise err + await self.script.async_run(kwargs.get(ATTR_VARIABLES), context) async def async_turn_off(self, **kwargs): """Turn script off.""" - self.script.async_stop() + await self.script.async_stop() async def async_will_remove_from_hass(self): """Stop script and remove service when it will be removed from Home Assistant.""" - if self.script.is_running: - self.script.async_stop() + await self.script.async_stop() # remove service self.hass.services.async_remove(DOMAIN, self.object_id) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 51de916f456753..a3bbd3844aaf16 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -1,14 +1,16 @@ """The Search integration.""" -from collections import defaultdict +from collections import defaultdict, deque +import logging import voluptuous as vol -from homeassistant.components import group, websocket_api +from homeassistant.components import automation, group, script, websocket_api from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import device_registry, entity_registry DOMAIN = "search" +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: dict): @@ -59,6 +61,8 @@ class Searcher: # These types won't be further explored. Config entries + Output types. DONT_RESOLVE = {"scene", "automation", "script", "group", "config_entry", "area"} + # These types exist as an entity and so need cleanup in results + EXIST_AS_ENTITY = {"script", "scene", "automation", "group"} def __init__( self, @@ -71,27 +75,33 @@ def __init__( self._device_reg = device_reg self._entity_reg = entity_reg self.results = defaultdict(set) - self._to_resolve = set() + self._to_resolve = deque() @callback def async_search(self, item_type, item_id): """Find results.""" + _LOGGER.debug("Searching for %s/%s", item_type, item_id) self.results[item_type].add(item_id) - self._to_resolve.add((item_type, item_id)) + self._to_resolve.append((item_type, item_id)) while self._to_resolve: - search_type, search_id = self._to_resolve.pop() + search_type, search_id = self._to_resolve.popleft() getattr(self, f"_resolve_{search_type}")(search_id) # Clean up entity_id items, from the general "entity" type result, # that are also found in the specific entity domain type. - self.results["entity"] -= self.results["script"] - self.results["entity"] -= self.results["scene"] - self.results["entity"] -= self.results["automation"] - self.results["entity"] -= self.results["group"] + for result_type in self.EXIST_AS_ENTITY: + self.results["entity"] -= self.results[result_type] # Remove entry into graph from search results. - self.results[item_type].remove(item_id) + to_remove_item_type = item_type + if item_type == "entity": + domain = split_entity_id(item_id)[0] + + if domain in self.EXIST_AS_ENTITY: + to_remove_item_type = domain + + self.results[to_remove_item_type].remove(item_id) # Filter out empty sets. return {key: val for key, val in self.results.items() if val} @@ -105,7 +115,7 @@ def _add_or_resolve(self, item_type, item_id): self.results[item_type].add(item_id) if item_type not in self.DONT_RESOLVE: - self._to_resolve.add((item_type, item_id)) + self._to_resolve.append((item_type, item_id)) @callback def _resolve_area(self, area_id) -> None: @@ -133,7 +143,11 @@ def _resolve_device(self, device_id) -> None: ): self._add_or_resolve("entity", entity_entry.entity_id) - # Extra: Find automations that reference this device + for entity_id in script.scripts_with_device(self.hass, device_id): + self._add_or_resolve("entity", entity_id) + + for entity_id in automation.automations_with_device(self.hass, device_id): + self._add_or_resolve("entity", entity_id) @callback def _resolve_entity(self, entity_id) -> None: @@ -146,6 +160,12 @@ def _resolve_entity(self, entity_id) -> None: for entity in group.groups_with_entity(self.hass, entity_id): self._add_or_resolve("entity", entity) + for entity in automation.automations_with_entity(self.hass, entity_id): + self._add_or_resolve("entity", entity) + + for entity in script.scripts_with_entity(self.hass, entity_id): + self._add_or_resolve("entity", entity) + # Find devices entity_entry = self._entity_reg.async_get(entity_id) if entity_entry is not None: @@ -157,7 +177,7 @@ def _resolve_entity(self, entity_id) -> None: domain = split_entity_id(entity_id)[0] - if domain in ("scene", "automation", "script", "group"): + if domain in self.EXIST_AS_ENTITY: self._add_or_resolve(domain, entity_id) @callback @@ -166,7 +186,13 @@ def _resolve_automation(self, automation_entity_id) -> None: Will only be called if automation is an entry point. """ - # Extra: Check with automation integration what entities/devices they reference + for entity in automation.entities_in_automation( + self.hass, automation_entity_id + ): + self._add_or_resolve("entity", entity) + + for device in automation.devices_in_automation(self.hass, automation_entity_id): + self._add_or_resolve("device", device) @callback def _resolve_script(self, script_entity_id) -> None: @@ -174,7 +200,11 @@ def _resolve_script(self, script_entity_id) -> None: Will only be called if script is an entry point. """ - # Extra: Check with script integration what entities/devices they reference + for entity in script.entities_in_script(self.hass, script_entity_id): + self._add_or_resolve("entity", entity) + + for device in script.devices_in_script(self.hass, script_entity_id): + self._add_or_resolve("device", device) @callback def _resolve_group(self, group_entity_id) -> None: diff --git a/homeassistant/components/search/manifest.json b/homeassistant/components/search/manifest.json index 337ce45f9bfa99..581a702f514d2b 100644 --- a/homeassistant/components/search/manifest.json +++ b/homeassistant/components/search/manifest.json @@ -7,6 +7,6 @@ "zeroconf": [], "homekit": {}, "dependencies": ["websocket_api"], - "after_dependencies": ["scene", "group"], + "after_dependencies": ["scene", "group", "automation", "script"], "codeowners": ["@home-assistant/core"] } diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json index 8a87205d4b76ea..900fe9252b4321 100644 --- a/homeassistant/components/sendgrid/manifest.json +++ b/homeassistant/components/sendgrid/manifest.json @@ -2,7 +2,7 @@ "domain": "sendgrid", "name": "SendGrid", "documentation": "https://www.home-assistant.io/integrations/sendgrid", - "requirements": ["sendgrid==6.1.0"], + "requirements": ["sendgrid==6.1.1"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/sense/.translations/en.json b/homeassistant/components/sense/.translations/en.json new file mode 100644 index 00000000000000..32e6f48e153bf6 --- /dev/null +++ b/homeassistant/components/sense/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email Address", + "password": "Password" + }, + "title": "Connect to your Sense Energy Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index ce0d3bce5dc974..f54e4092178d59 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -1,4 +1,5 @@ """Support for monitoring a Sense energy sensor.""" +import asyncio from datetime import timedelta import logging @@ -9,21 +10,25 @@ ) import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.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 -_LOGGER = logging.getLogger(__name__) - -ACTIVE_UPDATE_RATE = 60 +from .const import ( + ACTIVE_UPDATE_RATE, + DEFAULT_TIMEOUT, + DOMAIN, + SENSE_DATA, + SENSE_DEVICE_UPDATE, +) -DEFAULT_TIMEOUT = 5 -DOMAIN = "sense" +_LOGGER = logging.getLogger(__name__) -SENSE_DATA = "sense_data" -SENSE_DEVICE_UPDATE = "sense_devices_update" +PLATFORMS = ["sensor", "binary_sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -39,34 +44,88 @@ ) -async def async_setup(hass, config): - """Set up the Sense sensor.""" +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Sense component.""" + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_EMAIL: conf[CONF_EMAIL], + CONF_PASSWORD: conf[CONF_PASSWORD], + CONF_TIMEOUT: conf.get[CONF_TIMEOUT], + }, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Sense from a config entry.""" + + entry_data = entry.data + email = entry_data[CONF_EMAIL] + password = entry_data[CONF_PASSWORD] + timeout = entry_data[CONF_TIMEOUT] - username = config[DOMAIN][CONF_EMAIL] - password = config[DOMAIN][CONF_PASSWORD] + gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway.rate_limit = ACTIVE_UPDATE_RATE - timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) - hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE - await hass.data[SENSE_DATA].authenticate(username, password) + await gateway.authenticate(email, password) except SenseAuthenticationException: _LOGGER.error("Could not authenticate with sense server") return False - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) - hass.async_create_task( - async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) - ) + except SenseAPITimeoutException: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = {SENSE_DATA: gateway} + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) async def async_sense_update(now): """Retrieve latest state.""" try: - await hass.data[SENSE_DATA].update_realtime() - async_dispatcher_send(hass, SENSE_DEVICE_UPDATE) + gateway = hass.data[DOMAIN][entry.entry_id][SENSE_DATA] + await gateway.update_realtime() + async_dispatcher_send( + hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}" + ) except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") - async_track_time_interval( + hass.data[DOMAIN][entry.entry_id][ + "track_time_remove_callback" + ] = async_track_time_interval( hass, async_sense_update, timedelta(seconds=ACTIVE_UPDATE_RATE) ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + track_time_remove_callback = hass.data[DOMAIN][entry.entry_id][ + "track_time_remove_callback" + ] + track_time_remove_callback() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 81f1b64c864388..2ae79d71e5a14c 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -4,11 +4,14 @@ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry -from . import SENSE_DATA, SENSE_DEVICE_UPDATE +from .const import DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE _LOGGER = logging.getLogger(__name__) +ATTR_WATTS = "watts" +DEVICE_ID_SOLAR = "solar" BIN_SENSOR_CLASS = "power" MDI_ICONS = { "ac": "air-conditioner", @@ -41,6 +44,7 @@ "skillet": "pot", "smartcamera": "webcam", "socket": "power-plug", + "solar_alt": "solar-power", "sound": "speaker", "stove": "stove", "trash": "trash-can", @@ -50,21 +54,40 @@ } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense binary sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] + data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_monitor_id = data.sense_monitor_id sense_devices = await data.get_discovered_device_data() devices = [ - SenseDevice(data, device) + SenseDevice(data, device, sense_monitor_id) for device in sense_devices - if device["tags"]["DeviceListAllowed"] == "true" + if device["id"] == DEVICE_ID_SOLAR + or device["tags"]["DeviceListAllowed"] == "true" ] + + await _migrate_old_unique_ids(hass, devices) + async_add_entities(devices) +async def _migrate_old_unique_ids(hass, devices): + registry = await async_get_registry(hass) + for device in devices: + # Migration of old not so unique ids + old_entity_id = registry.async_get_entity_id( + "binary_sensor", DOMAIN, device.old_unique_id + ) + if old_entity_id is not None: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + device.old_unique_id, + device.unique_id, + ) + registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) + + def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) @@ -73,10 +96,12 @@ def sense_to_mdi(sense_icon): class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" - def __init__(self, data, device): + def __init__(self, data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" self._name = device["name"] self._id = device["id"] + self._sense_monitor_id = sense_monitor_id + self._unique_id = f"{sense_monitor_id}-{self._id}" self._icon = sense_to_mdi(device["icon"]) self._data = data self._undo_dispatch_subscription = None @@ -93,7 +118,12 @@ def name(self): @property def unique_id(self): - """Return the id of the binary sensor.""" + """Return the unique id of the binary sensor.""" + return self._unique_id + + @property + def old_unique_id(self): + """Return the old not so unique id of the binary sensor.""" return self._id @property @@ -120,7 +150,7 @@ def update(): self.async_schedule_update_ha_state(True) self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, SENSE_DEVICE_UPDATE, update + self.hass, f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", update ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py new file mode 100644 index 00000000000000..68bbb9ed932dad --- /dev/null +++ b/homeassistant/components/sense/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Sense integration.""" +import logging + +from sense_energy import ( + ASyncSenseable, + SenseAPITimeoutException, + SenseAuthenticationException, +) +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT + +from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT + +from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + timeout = data[CONF_TIMEOUT] + + gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway.rate_limit = ACTIVE_UPDATE_RATE + await gateway.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD]) + + # Return info that you want to store in the config entry. + return {"title": data[CONF_EMAIL]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sense.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + await self.async_set_unique_id(user_input[CONF_EMAIL]) + return self.async_create_entry(title=info["title"], data=user_input) + except SenseAPITimeoutException: + errors["base"] = "cannot_connect" + except SenseAuthenticationException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py new file mode 100644 index 00000000000000..cc30591e02a702 --- /dev/null +++ b/homeassistant/components/sense/const.py @@ -0,0 +1,7 @@ +"""Constants for monitoring a Sense energy sensor.""" +DOMAIN = "sense" +DEFAULT_TIMEOUT = 10 +ACTIVE_UPDATE_RATE = 60 +DEFAULT_NAME = "Sense" +SENSE_DATA = "sense_data" +SENSE_DEVICE_UPDATE = "sense_devices_update" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index e27d4bb72f6259..61f09fb444ba9e 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -2,7 +2,12 @@ "domain": "sense", "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", - "requirements": ["sense_energy==0.7.0"], + "requirements": [ + "sense_energy==0.7.0" + ], "dependencies": [], - "codeowners": ["@kbickar"] -} + "codeowners": [ + "@kbickar" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index d177a480ddf200..8d3c8f9e171362 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from . import SENSE_DATA +from .const import DOMAIN, SENSE_DATA _LOGGER = logging.getLogger(__name__) @@ -46,11 +46,9 @@ def __init__(self, name, sensor_type): SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] + data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) async def update_trends(): @@ -61,8 +59,11 @@ async def update_active(): """Update the active power usage.""" await data.update_realtime() + sense_monitor_id = data.sense_monitor_id + devices = [] - for typ in SENSOR_TYPES.values(): + for type_id in SENSOR_TYPES: + typ = SENSOR_TYPES[type_id] for var in SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type @@ -71,7 +72,13 @@ async def update_active(): update_call = update_active else: update_call = update_trends - devices.append(Sense(data, name, sensor_type, is_production, update_call)) + + unique_id = f"{sense_monitor_id}-{type_id}-{var}".lower() + devices.append( + Sense( + data, name, sensor_type, is_production, update_call, var, unique_id + ) + ) async_add_entities(devices) @@ -79,10 +86,14 @@ async def update_active(): class Sense(Entity): """Implementation of a Sense energy sensor.""" - def __init__(self, data, name, sensor_type, is_production, update_call): + def __init__( + self, data, name, sensor_type, is_production, update_call, sensor_id, unique_id + ): """Initialize the Sense sensor.""" name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME self._name = f"{name} {name_type}" + self._unique_id = unique_id + self._available = False self._data = data self._sensor_type = sensor_type self.update_sensor = update_call @@ -104,6 +115,11 @@ def state(self): """Return the state of the sensor.""" return self._state + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -114,6 +130,11 @@ def icon(self): """Icon to use in the frontend, if any.""" return ICON + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + async def async_update(self): """Get the latest data, update state.""" @@ -131,3 +152,5 @@ async def async_update(self): else: state = self._data.get_trend(self._sensor_type, self._is_production) self._state = round(state, 1) + + self._available = True diff --git a/homeassistant/components/sense/strings.json b/homeassistant/components/sense/strings.json new file mode 100644 index 00000000000000..d3af47b537844f --- /dev/null +++ b/homeassistant/components/sense/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Sense", + "step": { + "user": { + "title": "Connect to your Sense Energy Monitor", + "data": { + "email": "Email Address", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/components/sensor/.translations/sv.json b/homeassistant/components/sensor/.translations/sv.json new file mode 100644 index 00000000000000..90001148f127a5 --- /dev/null +++ b/homeassistant/components/sensor/.translations/sv.json @@ -0,0 +1,26 @@ +{ + "device_automation": { + "condition_type": { + "is_battery_level": "Aktuell {entity_name} batteriniv\u00e5", + "is_humidity": "Aktuell {entity_name} fuktighet", + "is_illuminance": "Aktuell {entity_name} belysning", + "is_power": "Aktuell {entity_name} str\u00f6m", + "is_pressure": "Aktuellt {entity_name} tryck", + "is_signal_strength": "Aktuell {entity_name} signalstyrka", + "is_temperature": "Aktuell {entity_name} temperatur", + "is_timestamp": "Aktuell {entity_name} tidsst\u00e4mpel", + "is_value": "Aktuellt {entity_name} v\u00e4rde" + }, + "trigger_type": { + "battery_level": "{entity_name} batteriniv\u00e5 \u00e4ndras", + "humidity": "{entity_name} fuktighet \u00e4ndras", + "illuminance": "{entity_name} belysning \u00e4ndras", + "power": "{entity_name} str\u00f6mf\u00f6r\u00e4ndringar", + "pressure": "{entity_name} tryckf\u00f6r\u00e4ndringar", + "signal_strength": "{entity_name} signalstyrka \u00e4ndras", + "temperature": "{entity_name} temperaturf\u00f6r\u00e4ndringar", + "timestamp": "{entity_name} tidst\u00e4mpel \u00e4ndras", + "value": "{entity_name} v\u00e4rde \u00e4ndras" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/zh-Hant.json b/homeassistant/components/sensor/.translations/zh-Hant.json index eb8f47a1fd9463..9bf8abc823071c 100644 --- a/homeassistant/components/sensor/.translations/zh-Hant.json +++ b/homeassistant/components/sensor/.translations/zh-Hant.json @@ -1,26 +1,26 @@ { "device_automation": { "condition_type": { - "is_battery_level": "\u76ee\u524d {entity_name} \u96fb\u91cf", - "is_humidity": "\u76ee\u524d {entity_name} \u6fd5\u5ea6", - "is_illuminance": "\u76ee\u524d {entity_name} \u7167\u5ea6", - "is_power": "\u76ee\u524d {entity_name} \u96fb\u529b", - "is_pressure": "\u76ee\u524d {entity_name} \u58d3\u529b", - "is_signal_strength": "\u76ee\u524d {entity_name} \u8a0a\u865f\u5f37\u5ea6", - "is_temperature": "\u76ee\u524d {entity_name} \u6eab\u5ea6", - "is_timestamp": "\u76ee\u524d {entity_name} \u6642\u9593\u6a19\u8a18", - "is_value": "\u76ee\u524d {entity_name} \u503c" + "is_battery_level": "\u76ee\u524d{entity_name}\u96fb\u91cf", + "is_humidity": "\u76ee\u524d{entity_name}\u6fd5\u5ea6", + "is_illuminance": "\u76ee\u524d{entity_name}\u7167\u5ea6", + "is_power": "\u76ee\u524d{entity_name}\u96fb\u529b", + "is_pressure": "\u76ee\u524d{entity_name}\u58d3\u529b", + "is_signal_strength": "\u76ee\u524d{entity_name}\u8a0a\u865f\u5f37\u5ea6", + "is_temperature": "\u76ee\u524d{entity_name}\u6eab\u5ea6", + "is_timestamp": "\u76ee\u524d{entity_name}\u6642\u9593\u6a19\u8a18", + "is_value": "\u76ee\u524d{entity_name}\u503c" }, "trigger_type": { - "battery_level": "{entity_name} \u96fb\u91cf\u8b8a\u66f4", - "humidity": "{entity_name} \u6fd5\u5ea6\u8b8a\u66f4", - "illuminance": "{entity_name} \u7167\u5ea6\u8b8a\u66f4", - "power": "{entity_name} \u96fb\u529b\u8b8a\u66f4", - "pressure": "{entity_name} \u58d3\u529b\u8b8a\u66f4", - "signal_strength": "{entity_name} \u8a0a\u865f\u5f37\u5ea6\u8b8a\u66f4", - "temperature": "{entity_name} \u6eab\u5ea6\u8b8a\u66f4", - "timestamp": "{entity_name} \u6642\u9593\u6a19\u8a18\u8b8a\u66f4", - "value": "{entity_name} \u503c\u8b8a\u66f4" + "battery_level": "{entity_name}\u96fb\u91cf\u8b8a\u66f4", + "humidity": "{entity_name}\u6fd5\u5ea6\u8b8a\u66f4", + "illuminance": "{entity_name}\u7167\u5ea6\u8b8a\u66f4", + "power": "{entity_name}\u96fb\u529b\u8b8a\u66f4", + "pressure": "{entity_name}\u58d3\u529b\u8b8a\u66f4", + "signal_strength": "{entity_name}\u8a0a\u865f\u5f37\u5ea6\u8b8a\u66f4", + "temperature": "{entity_name}\u6eab\u5ea6\u8b8a\u66f4", + "timestamp": "{entity_name}\u6642\u9593\u6a19\u8a18\u8b8a\u66f4", + "value": "{entity_name}\u503c\u8b8a\u66f4" } } } \ No newline at end of file diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 7417765f9f4b5b..bb0348eb6a73ed 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -22,7 +22,7 @@ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_registry import ( async_entries_for_device, @@ -128,6 +128,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: diff --git a/homeassistant/components/sentry/.translations/af.json b/homeassistant/components/sentry/.translations/af.json new file mode 100644 index 00000000000000..61ef8f8d38992d --- /dev/null +++ b/homeassistant/components/sentry/.translations/af.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Sentry" + } + }, + "title": "Sentry" + } +} \ No newline at end of file diff --git a/homeassistant/components/sentry/.translations/de.json b/homeassistant/components/sentry/.translations/de.json index ea1e3f674ae3a2..db71d8818bcf5e 100644 --- a/homeassistant/components/sentry/.translations/de.json +++ b/homeassistant/components/sentry/.translations/de.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "Gebe deine Sentry-DSN ein", + "description": "Gib deine Sentry-DSN ein", "title": "Sentry" } }, diff --git a/homeassistant/components/sentry/.translations/hu.json b/homeassistant/components/sentry/.translations/hu.json new file mode 100644 index 00000000000000..64318828e6d8dc --- /dev/null +++ b/homeassistant/components/sentry/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Az Sentry m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "bad_dsn": "\u00c9rv\u00e9nytelen DSN", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "description": "Add meg a Sentry DSN-t", + "title": "Sentry" + } + }, + "title": "Sentry" + } +} \ No newline at end of file diff --git a/homeassistant/components/sentry/.translations/nl.json b/homeassistant/components/sentry/.translations/nl.json index 7e198e836d7421..67bd1ea54e285b 100644 --- a/homeassistant/components/sentry/.translations/nl.json +++ b/homeassistant/components/sentry/.translations/nl.json @@ -1,7 +1,18 @@ { "config": { + "abort": { + "already_configured": "Sentry is al geconfigureerd" + }, "error": { + "bad_dsn": "Ongeldige DSN", "unknown": "Onverwachte fout" - } + }, + "step": { + "user": { + "description": "Voer uw Sentry DSN in", + "title": "Sentry" + } + }, + "title": "Sentry" } } \ No newline at end of file diff --git a/homeassistant/components/sentry/.translations/pl.json b/homeassistant/components/sentry/.translations/pl.json index 4bb7abbc3283dd..d97fa159a87fbc 100644 --- a/homeassistant/components/sentry/.translations/pl.json +++ b/homeassistant/components/sentry/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Sentry jest ju\u017c skonfigurowane" + "already_configured": "Sentry jest ju\u017c skonfigurowane." }, "error": { "bad_dsn": "Nieprawid\u0142owy DSN", diff --git a/homeassistant/components/sentry/.translations/sv.json b/homeassistant/components/sentry/.translations/sv.json new file mode 100644 index 00000000000000..7f0968e7dbee48 --- /dev/null +++ b/homeassistant/components/sentry/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Sentry har redan konfigurerats" + }, + "error": { + "bad_dsn": "Ogiltig DSN", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "description": "Ange din Sentry DSN", + "title": "Sentry" + } + }, + "title": "Sentry" + } +} \ No newline at end of file diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 75587e4eab7981..2e7604ee97db1f 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -79,7 +79,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return "µg/m³" + return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER def update(self): """Read from sensor and update the state.""" diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 672679b7254988..eba33e75f71757 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -2,7 +2,9 @@ "domain": "seven_segments", "name": "Seven Segments OCR", "documentation": "https://www.home-assistant.io/integrations/seven_segments", - "requirements": ["pillow==6.2.1"], + "requirements": [ + "pillow==7.0.0" + ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 856ea0784ba866..3f61f70f858027 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -153,15 +153,14 @@ def async_clear_completed(self): self.items = [itm for itm in self.items if not itm["complete"]] self.hass.async_add_job(self.save) - @asyncio.coroutine - def async_load(self): + async def async_load(self): """Load items.""" def load(): """Load the items synchronously.""" return load_json(self.hass.config.path(PERSISTENCE), default=[]) - self.items = yield from self.hass.async_add_job(load) + self.items = await self.hass.async_add_executor_job(load) def save(self): """Save the items.""" @@ -242,7 +241,7 @@ def websocket_handle_items(hass, connection, msg): def websocket_handle_add(hass, connection, msg): """Handle add item to shopping_list.""" item = hass.data[DOMAIN].async_add(msg["name"]) - hass.bus.async_fire(EVENT) + hass.bus.async_fire(EVENT, {"action": "add", "item": item}) connection.send_message(websocket_api.result_message(msg["id"], item)) @@ -256,7 +255,7 @@ async def websocket_handle_update(hass, connection, msg): try: item = hass.data[DOMAIN].async_update(item_id, data) - hass.bus.async_fire(EVENT) + hass.bus.async_fire(EVENT, {"action": "update", "item": item}) connection.send_message(websocket_api.result_message(msg_id, item)) except KeyError: connection.send_message( @@ -268,5 +267,5 @@ async def websocket_handle_update(hass, connection, msg): def websocket_handle_clear(hass, connection, msg): """Handle clearing shopping_list items.""" hass.data[DOMAIN].async_clear_completed() - hass.bus.async_fire(EVENT) + hass.bus.async_fire(EVENT, {"action": "clear"}) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 27e2fe9b5636ab..da07290f422bad 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -100,7 +100,7 @@ def get_devices(self, device_types): @property def auth(self): - """Return the API authentification.""" + """Return the API authentication.""" return self._auth @property diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index 175b1edc4c68ed..ff67749b192319 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -1,6 +1,9 @@ """Person detection using Sighthound cloud service.""" +import io import logging +from pathlib import Path +from PIL import Image, ImageDraw import simplehound.core as hound import voluptuous as vol @@ -14,6 +17,7 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv +from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) @@ -22,6 +26,7 @@ ATTR_BOUNDING_BOX = "bounding_box" ATTR_PEOPLE = "people" CONF_ACCOUNT_TYPE = "account_type" +CONF_SAVE_FILE_FOLDER = "save_file_folder" DEV = "dev" PROD = "prod" @@ -29,6 +34,7 @@ { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]), + vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir, } ) @@ -45,10 +51,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Sighthound error %s setup aborted", exc) return + save_file_folder = config.get(CONF_SAVE_FILE_FOLDER) + if save_file_folder: + save_file_folder = Path(save_file_folder) + entities = [] for camera in config[CONF_SOURCE]: sighthound = SighthoundEntity( - api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME) + api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), save_file_folder ) entities.append(sighthound) add_entities(entities) @@ -57,7 +67,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SighthoundEntity(ImageProcessingEntity): """Create a sighthound entity.""" - def __init__(self, api, camera_entity, name): + def __init__(self, api, camera_entity, name, save_file_folder): """Init.""" self._api = api self._camera = camera_entity @@ -69,6 +79,7 @@ def __init__(self, api, camera_entity, name): self._state = None self._image_width = None self._image_height = None + self._save_file_folder = save_file_folder def process_image(self, image): """Process an image.""" @@ -81,6 +92,8 @@ def process_image(self, image): self._image_height = metadata["image_height"] for person in people: self.fire_person_detected_event(person) + if self._save_file_folder and self._state > 0: + self.save_image(image, people, self._save_file_folder) def fire_person_detected_event(self, person): """Send event with detected total_persons.""" @@ -94,6 +107,19 @@ def fire_person_detected_event(self, person): }, ) + def save_image(self, image, people, directory): + """Save a timestamped image with bounding boxes around targets.""" + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + draw = ImageDraw.Draw(img) + + for person in people: + box = hound.bbox_to_tf_style( + person["boundingBox"], self._image_width, self._image_height + ) + draw_box(draw, box, self._image_width, self._image_height) + latest_save_path = directory / f"{self._name}_latest.jpg" + img.save(latest_save_path) + @property def camera_entity(self): """Return camera entity id from process pictures.""" diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json index 98a7b4e59a6ffb..3efa1c33e8548f 100644 --- a/homeassistant/components/signal_messenger/manifest.json +++ b/homeassistant/components/signal_messenger/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/signal_messenger", "dependencies": [], "codeowners": ["@bbernhard"], - "requirements": ["pysignalclirestapi==0.1.4"] + "requirements": ["pysignalclirestapi==0.2.4"] } diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 8fbf9c708734d8..cee871fb17ecaa 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -17,6 +17,7 @@ CONF_RECP_NR = "recipients" CONF_SIGNAL_CLI_REST_API = "url" ATTR_FILENAME = "attachment" +ATTR_FILENAMES = "attachments" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -34,9 +35,7 @@ def get_service(hass, config, discovery_info=None): recp_nrs = config[CONF_RECP_NR] signal_cli_rest_api_url = config[CONF_SIGNAL_CLI_REST_API] - signal_cli_rest_api = SignalCliRestApi( - signal_cli_rest_api_url, sender_nr, api_version=1 - ) + signal_cli_rest_api = SignalCliRestApi(signal_cli_rest_api_url, sender_nr) return SignalNotificationService(recp_nrs, signal_cli_rest_api) @@ -60,12 +59,21 @@ def send_message(self, message="", **kwargs): data = kwargs.get(ATTR_DATA) - filename = None - if data is not None and ATTR_FILENAME in data: - filename = data[ATTR_FILENAME] + filenames = None + if data is not None: + if ATTR_FILENAMES in data: + filenames = data[ATTR_FILENAMES] + if ATTR_FILENAME in data: + _LOGGER.warning( + "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108." + ) + if filenames is None: + filenames = [data[ATTR_FILENAME]] + else: + filenames.append(data[ATTR_FILENAME]) try: - self._signal_cli_rest_api.send_message(message, self._recp_nrs, filename) + self._signal_cli_rest_api.send_message(message, self._recp_nrs, filenames) except SignalCliRestApiError as ex: _LOGGER.error("%s", ex) raise ex diff --git a/homeassistant/components/simplisafe/.translations/ca.json b/homeassistant/components/simplisafe/.translations/ca.json index a02c3a5e28ea7f..a89e4c753cb280 100644 --- a/homeassistant/components/simplisafe/.translations/ca.json +++ b/homeassistant/components/simplisafe/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquest compte SimpliSafe ja est\u00e0 en \u00fas." + }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", "invalid_credentials": "Credencials inv\u00e0lides" diff --git a/homeassistant/components/simplisafe/.translations/da.json b/homeassistant/components/simplisafe/.translations/da.json index 0d3970eeba565b..ccd829795209be 100644 --- a/homeassistant/components/simplisafe/.translations/da.json +++ b/homeassistant/components/simplisafe/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne SimpliSafe-konto er allerede i brug." + }, "error": { "identifier_exists": "Konto er allerede registreret", "invalid_credentials": "Ugyldige legitimationsoplysninger" diff --git a/homeassistant/components/simplisafe/.translations/de.json b/homeassistant/components/simplisafe/.translations/de.json index ee7eaecc85264e..4d5eefc480b2da 100644 --- a/homeassistant/components/simplisafe/.translations/de.json +++ b/homeassistant/components/simplisafe/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieses SimpliSafe-Konto wird bereits verwendet." + }, "error": { "identifier_exists": "Konto bereits registriert", "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" @@ -11,7 +14,7 @@ "password": "Passwort", "username": "E-Mail-Adresse" }, - "title": "Gebe deine Informationen ein" + "title": "Gib deine Informationen ein" } }, "title": "SimpliSafe" diff --git a/homeassistant/components/simplisafe/.translations/en.json b/homeassistant/components/simplisafe/.translations/en.json index b000335af8fb11..7e9c26291f7601 100644 --- a/homeassistant/components/simplisafe/.translations/en.json +++ b/homeassistant/components/simplisafe/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This SimpliSafe account is already in use." + }, "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" diff --git a/homeassistant/components/simplisafe/.translations/ko.json b/homeassistant/components/simplisafe/.translations/ko.json index 5cbe233a05e64b..3327ddf9ab1ef9 100644 --- a/homeassistant/components/simplisafe/.translations/ko.json +++ b/homeassistant/components/simplisafe/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 SimpliSafe \uacc4\uc815\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/simplisafe/.translations/no.json b/homeassistant/components/simplisafe/.translations/no.json index 7c28209514e489..4c25893791b968 100644 --- a/homeassistant/components/simplisafe/.translations/no.json +++ b/homeassistant/components/simplisafe/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk." + }, "error": { "identifier_exists": "Konto er allerede registrert", "invalid_credentials": "Ugyldig legitimasjon" diff --git a/homeassistant/components/simplisafe/.translations/pl.json b/homeassistant/components/simplisafe/.translations/pl.json index ad8a15d06b7455..3a9c160a0c54a8 100644 --- a/homeassistant/components/simplisafe/.translations/pl.json +++ b/homeassistant/components/simplisafe/.translations/pl.json @@ -1,7 +1,10 @@ { "config": { + "abort": { + "already_configured": "To konto SimpliSafe jest ju\u017c w u\u017cyciu." + }, "error": { - "identifier_exists": "Konto jest ju\u017c zarejestrowane", + "identifier_exists": "Konto jest ju\u017c zarejestrowane.", "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" }, "step": { diff --git a/homeassistant/components/simplisafe/.translations/ru.json b/homeassistant/components/simplisafe/.translations/ru.json index 301eed6d1c1ad8..2d8b63c4bab06f 100644 --- a/homeassistant/components/simplisafe/.translations/ru.json +++ b/homeassistant/components/simplisafe/.translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + }, "error": { "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." diff --git a/homeassistant/components/simplisafe/.translations/zh-Hant.json b/homeassistant/components/simplisafe/.translations/zh-Hant.json index bd0b2c6f3d6f9c..b456bde33c771b 100644 --- a/homeassistant/components/simplisafe/.translations/zh-Hant.json +++ b/homeassistant/components/simplisafe/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64 SimpliSafe \u5e33\u865f\u5df2\u88ab\u4f7f\u7528\u3002" + }, "error": { "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", "invalid_credentials": "\u6191\u8b49\u7121\u6548" diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d5538e6a3720ae..38a715d494aae1 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -4,11 +4,26 @@ from simplipy import API from simplipy.errors import InvalidCredentialsError, SimplipyError -from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF +from simplipy.websocket import ( + EVENT_CAMERA_MOTION_DETECTED, + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + EVENT_DOORBELL_DETECTED, + EVENT_ENTRY_DETECTED, + EVENT_LOCK_LOCKED, + EVENT_LOCK_UNLOCKED, + EVENT_MOTION_DETECTED, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import ( + ATTR_CODE, + CONF_CODE, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( @@ -27,30 +42,57 @@ verify_domain_control, ) -from .config_flow import configured_instances -from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE +from .const import ( + ATTR_ALARM_DURATION, + ATTR_ALARM_VOLUME, + ATTR_CHIME_VOLUME, + ATTR_ENTRY_DELAY_AWAY, + ATTR_ENTRY_DELAY_HOME, + ATTR_EXIT_DELAY_AWAY, + ATTR_EXIT_DELAY_HOME, + ATTR_LIGHT, + ATTR_VOICE_PROMPT_VOLUME, + DATA_CLIENT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + VOLUMES, +) _LOGGER = logging.getLogger(__name__) CONF_ACCOUNTS = "accounts" DATA_LISTENER = "listener" - -ATTR_ALARM_DURATION = "alarm_duration" -ATTR_ALARM_VOLUME = "alarm_volume" -ATTR_CHIME_VOLUME = "chime_volume" -ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" -ATTR_ENTRY_DELAY_HOME = "entry_delay_home" -ATTR_EXIT_DELAY_AWAY = "exit_delay_away" -ATTR_EXIT_DELAY_HOME = "exit_delay_home" -ATTR_LIGHT = "light" +TOPIC_UPDATE = "simplisafe_update_data_{0}" + +EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" +EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" + +DEFAULT_SOCKET_MIN_RETRY = 15 + +WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] +WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT = [ + EVENT_CAMERA_MOTION_DETECTED, + EVENT_DOORBELL_DETECTED, + EVENT_ENTRY_DETECTED, + EVENT_MOTION_DETECTED, +] + +ATTR_CATEGORY = "category" +ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" +ATTR_LAST_EVENT_INFO = "last_event_info" +ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" +ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" +ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" +ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" +ATTR_LAST_EVENT_TYPE = "last_event_type" +ATTR_LAST_EVENT_TYPE = "last_event_type" +ATTR_MESSAGE = "message" ATTR_PIN_LABEL = "label" ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" ATTR_PIN_VALUE = "pin" ATTR_SYSTEM_ID = "system_id" -ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" - -VOLUMES = [VOLUME_OFF, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_HIGH] +ATTR_TIMESTAMP = "timestamp" SERVICE_BASE_SCHEMA = vol.Schema({vol.Required(ATTR_SYSTEM_ID): cv.positive_int}) @@ -141,9 +183,6 @@ async def async_setup(hass, config): conf = config[DOMAIN] for account in conf[CONF_ACCOUNTS]: - if account[CONF_USERNAME] in configured_instances(hass): - continue - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -161,6 +200,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up SimpliSafe as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_USERNAME] + ) + _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) @@ -176,9 +220,8 @@ async def async_setup_entry(hass, config_entry): _async_save_refresh_token(hass, config_entry, api.refresh_token) - systems = await api.get_systems() - simplisafe = SimpliSafe(hass, api, systems, config_entry) - await simplisafe.async_update() + simplisafe = SimpliSafe(hass, api, config_entry) + await simplisafe.async_init() hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = simplisafe for component in ("alarm_control_panel", "lock"): @@ -186,22 +229,6 @@ async def async_setup_entry(hass, config_entry): hass.config_entries.async_forward_entry_setup(config_entry, component) ) - async def refresh(event_time): - """Refresh data from the SimpliSafe account.""" - await simplisafe.async_update() - _LOGGER.debug("Updated data for all SimpliSafe systems") - async_dispatcher_send(hass, TOPIC_UPDATE) - - hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( - hass, refresh, DEFAULT_SCAN_INTERVAL - ) - - # Register the base station for each system: - for system in systems.values(): - hass.async_create_task( - async_register_base_station(hass, system, config_entry.entry_id) - ) - @callback def verify_system_exists(coro): """Log an error if a service call uses an invalid system ID.""" @@ -209,7 +236,7 @@ def verify_system_exists(coro): async def decorator(call): """Decorate.""" system_id = int(call.data[ATTR_SYSTEM_ID]) - if system_id not in systems: + if system_id not in simplisafe.systems: _LOGGER.error("Unknown system ID in service call: %s", system_id) return await coro(call) @@ -222,7 +249,7 @@ def v3_only(coro): async def decorator(call): """Decorate.""" - system = systems[int(call.data[ATTR_SYSTEM_ID])] + system = simplisafe.systems[int(call.data[ATTR_SYSTEM_ID])] if system.version != 3: _LOGGER.error("Service only available on V3 systems") return @@ -234,7 +261,7 @@ async def decorator(call): @_verify_domain_control async def remove_pin(call): """Remove a PIN.""" - system = systems[call.data[ATTR_SYSTEM_ID]] + system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] try: await system.remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) except SimplipyError as err: @@ -245,7 +272,7 @@ async def remove_pin(call): @_verify_domain_control async def set_pin(call): """Set a PIN.""" - system = systems[call.data[ATTR_SYSTEM_ID]] + system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] try: await system.set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) except SimplipyError as err: @@ -257,7 +284,7 @@ async def set_pin(call): @_verify_domain_control async def set_system_properties(call): """Set one or more system parameters.""" - system = systems[call.data[ATTR_SYSTEM_ID]] + system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] try: await system.set_properties( { @@ -300,61 +327,235 @@ async def async_unload_entry(hass, entry): return True +class SimpliSafeWebsocket: + """Define a SimpliSafe websocket "manager" object.""" + + def __init__(self, hass, websocket): + """Initialize.""" + self._hass = hass + self._websocket = websocket + self.last_events = {} + + @staticmethod + def _on_connect(): + """Define a handler to fire when the websocket is connected.""" + _LOGGER.info("Connected to websocket") + + @staticmethod + def _on_disconnect(): + """Define a handler to fire when the websocket is disconnected.""" + _LOGGER.info("Disconnected from websocket") + + def _on_event(self, event): + """Define a handler to fire when a new SimpliSafe event arrives.""" + _LOGGER.debug("New websocket event: %s", event) + self.last_events[event.system_id] = event + async_dispatcher_send(self._hass, TOPIC_UPDATE.format(event.system_id)) + + if event.event_type not in WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT: + return + + if event.sensor_type: + sensor_type = event.sensor_type.name + else: + sensor_type = None + + self._hass.bus.async_fire( + EVENT_SIMPLISAFE_EVENT, + event_data={ + ATTR_LAST_EVENT_CHANGED_BY: event.changed_by, + ATTR_LAST_EVENT_TYPE: event.event_type, + ATTR_LAST_EVENT_INFO: event.info, + ATTR_LAST_EVENT_SENSOR_NAME: event.sensor_name, + ATTR_LAST_EVENT_SENSOR_SERIAL: event.sensor_serial, + ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type, + ATTR_SYSTEM_ID: event.system_id, + ATTR_LAST_EVENT_TIMESTAMP: event.timestamp, + }, + ) + + async def async_websocket_connect(self): + """Register handlers and connect to the websocket.""" + self._websocket.on_connect(self._on_connect) + self._websocket.on_disconnect(self._on_disconnect) + self._websocket.on_event(self._on_event) + + await self._websocket.async_connect() + + class SimpliSafe: - """Define a SimpliSafe API object.""" + """Define a SimpliSafe data object.""" - def __init__(self, hass, api, systems, config_entry): + def __init__(self, hass, api, config_entry): """Initialize.""" self._api = api self._config_entry = config_entry + self._emergency_refresh_token_used = False self._hass = hass - self.last_event_data = {} - self.systems = systems + self._system_notifications = {} + self.initial_event_to_use = {} + self.systems = {} + self.websocket = SimpliSafeWebsocket(hass, api.websocket) - async def _update_system(self, system): - """Update a system.""" - try: + @callback + def _async_process_new_notifications(self, system): + """Act on any new system notifications.""" + old_notifications = self._system_notifications.get(system.system_id, []) + latest_notifications = system.notifications + + # Save the latest notifications: + self._system_notifications[system.system_id] = latest_notifications + + # Process any notifications that are new: + to_add = set(latest_notifications) - set(old_notifications) + + if not to_add: + return + + _LOGGER.debug("New system notifications: %s", to_add) + + for notification in to_add: + text = notification.text + if notification.link: + text = f"{text} For more information: {notification.link}" + + self._hass.bus.async_fire( + EVENT_SIMPLISAFE_NOTIFICATION, + event_data={ + ATTR_CATEGORY: notification.category, + ATTR_CODE: notification.code, + ATTR_MESSAGE: text, + ATTR_TIMESTAMP: notification.timestamp, + }, + ) + + async def async_init(self): + """Initialize the data class.""" + asyncio.create_task(self.websocket.async_websocket_connect()) + + self.systems = await self._api.get_systems() + for system in self.systems.values(): + self._hass.async_create_task( + async_register_base_station( + self._hass, system, self._config_entry.entry_id + ) + ) + + # Future events will come from the websocket, but since subscription to the + # websocket doesn't provide the most recent event, we grab it from the REST + # API to ensure event-related attributes aren't empty on startup: + try: + self.initial_event_to_use[ + system.system_id + ] = await system.get_latest_event() + except SimplipyError as err: + _LOGGER.error("Error while fetching initial event: %s", err) + self.initial_event_to_use[system.system_id] = {} + + async def refresh(event_time): + """Refresh data from the SimpliSafe account.""" + await self.async_update() + + self._hass.data[DOMAIN][DATA_LISTENER][ + self._config_entry.entry_id + ] = async_track_time_interval(self._hass, refresh, DEFAULT_SCAN_INTERVAL) + + await self.async_update() + + async def async_update(self): + """Get updated data from SimpliSafe.""" + + async def update_system(system): + """Update a system.""" await system.update() - latest_event = await system.get_latest_event() - except SimplipyError as err: - _LOGGER.error( - 'SimpliSafe error while updating "%s": %s', system.address, err + self._async_process_new_notifications(system) + _LOGGER.debug('Updated REST API data for "%s"', system.address) + async_dispatcher_send(self._hass, TOPIC_UPDATE.format(system.system_id)) + + tasks = [update_system(system) for system in self.systems.values()] + + def cancel_tasks(): + """Cancel tasks and ensure their cancellation is processed.""" + for task in tasks: + task.cancel() + + try: + await asyncio.gather(*tasks) + except InvalidCredentialsError: + cancel_tasks() + + if self._emergency_refresh_token_used: + _LOGGER.error( + "SimpliSafe authentication disconnected. Please restart HASS." + ) + remove_listener = self._hass.data[DOMAIN][DATA_LISTENER].pop( + self._config_entry.entry_id + ) + remove_listener() + return + + _LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") + self._emergency_refresh_token_used = True + return await self._api.refresh_access_token( + self._config_entry.data[CONF_TOKEN] ) + except SimplipyError as err: + cancel_tasks() + _LOGGER.error("SimpliSafe error while updating: %s", err) return except Exception as err: # pylint: disable=broad-except - _LOGGER.error('Unknown error while updating "%s": %s', system.address, err) + cancel_tasks() + _LOGGER.error("Unknown error while updating: %s", err) return - self.last_event_data[system.system_id] = latest_event - if self._api.refresh_token_dirty: _async_save_refresh_token( self._hass, self._config_entry, self._api.refresh_token ) - async def async_update(self): - """Get updated data from SimpliSafe.""" - tasks = [self._update_system(system) for system in self.systems.values()] - - await asyncio.gather(*tasks) + # If we've reached this point using an emergency refresh token, we're in the + # clear and we can discard it: + if self._emergency_refresh_token_used: + self._emergency_refresh_token_used = False class SimpliSafeEntity(Entity): """Define a base SimpliSafe entity.""" - def __init__(self, system, name, *, serial=None): + def __init__(self, simplisafe, system, name, *, serial=None): """Initialize.""" self._async_unsub_dispatcher_connect = None - self._attrs = {ATTR_SYSTEM_ID: system.system_id} + self._last_processed_websocket_event = None self._name = name self._online = True + self._simplisafe = simplisafe self._system = system + self.websocket_events_to_listen_for = [ + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + ] if serial: self._serial = serial else: self._serial = system.serial + self._attrs = { + ATTR_LAST_EVENT_INFO: simplisafe.initial_event_to_use[system.system_id].get( + "info" + ), + ATTR_LAST_EVENT_SENSOR_NAME: simplisafe.initial_event_to_use[ + system.system_id + ].get("sensorName"), + ATTR_LAST_EVENT_SENSOR_TYPE: simplisafe.initial_event_to_use[ + system.system_id + ].get("sensorType"), + ATTR_LAST_EVENT_TIMESTAMP: simplisafe.initial_event_to_use[ + system.system_id + ].get("eventTimestamp"), + ATTR_SYSTEM_ID: system.system_id, + } + @property def available(self): """Return whether the entity is available.""" @@ -391,6 +592,36 @@ def unique_id(self): """Return the unique ID of the entity.""" return self._serial + @callback + def _async_should_ignore_websocket_event(self, event): + """Return whether this entity should ignore a particular websocket event. + + Note that we can't check for a final condition – whether the event belongs to + a particular entity, like a lock – because some events (like arming the system + from a keypad _or_ from the website) should impact the same entity. + """ + # We've already processed this event: + if self._last_processed_websocket_event == event: + return True + + # This is an event for a system other than the one this entity belongs to: + if event.system_id != self._system.system_id: + return True + + # This isn't an event that this entity cares about: + if event.event_type not in self.websocket_events_to_listen_for: + return True + + # This event is targeted at a specific entity whose serial number is different + # from this one's: + if ( + event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL + and event.sensor_serial != self._serial + ): + return True + + return False + async def async_added_to_hass(self): """Register callbacks.""" @@ -400,8 +631,65 @@ def update(): self.async_schedule_update_ha_state(True) self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, update + self.hass, TOPIC_UPDATE.format(self._system.system_id), update + ) + + async def async_update(self): + """Update the entity.""" + self.async_update_from_rest_api() + + last_websocket_event = self._simplisafe.websocket.last_events.get( + self._system.system_id + ) + + if self._async_should_ignore_websocket_event(last_websocket_event): + return + + self._last_processed_websocket_event = last_websocket_event + + if last_websocket_event.sensor_type: + sensor_type = last_websocket_event.sensor_type.name + else: + sensor_type = None + + self._attrs.update( + { + ATTR_LAST_EVENT_INFO: last_websocket_event.info, + ATTR_LAST_EVENT_SENSOR_NAME: last_websocket_event.sensor_name, + ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type, + ATTR_LAST_EVENT_TIMESTAMP: last_websocket_event.timestamp, + } ) + self._async_internal_update_from_websocket_event(last_websocket_event) + + @callback + def async_update_from_rest_api(self): + """Update the entity with the provided REST API data.""" + pass + + @callback + def _async_internal_update_from_websocket_event(self, event): + """Check for connection events and set offline appropriately. + + Should not be called directly. + """ + if event.event_type == EVENT_CONNECTION_LOST: + self._online = False + elif event.event_type == EVENT_CONNECTION_RESTORED: + self._online = True + + # It's uncertain whether SimpliSafe events will still propagate down the + # websocket when the base station is offline. Just in case, we guard against + # further action until connection is restored: + if not self._online: + return + + self.async_update_from_websocket_event(event) + + @callback + def async_update_from_websocket_event(self, event): + """Update the entity with the provided websocket API data.""" + pass async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 37aa2d845857c4..9166c59bec00b5 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -2,9 +2,21 @@ import logging import re -from simplipy.entity import EntityTypes +from simplipy.errors import SimplipyError from simplipy.system import SystemStates -from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF +from simplipy.websocket import ( + EVENT_ALARM_CANCELED, + EVENT_ALARM_TRIGGERED, + EVENT_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD, + EVENT_ARMED_AWAY_BY_REMOTE, + EVENT_ARMED_HOME, + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE, + EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_REMOTE, + EVENT_HOME_EXIT_DELAY, +) from homeassistant.components.alarm_control_panel import ( FORMAT_NUMBER, @@ -19,43 +31,37 @@ CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, ) -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.core import callback from . import SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN +from .const import ( + ATTR_ALARM_DURATION, + ATTR_ALARM_VOLUME, + ATTR_CHIME_VOLUME, + ATTR_ENTRY_DELAY_AWAY, + ATTR_ENTRY_DELAY_HOME, + ATTR_EXIT_DELAY_AWAY, + ATTR_EXIT_DELAY_HOME, + ATTR_LIGHT, + ATTR_VOICE_PROMPT_VOLUME, + DATA_CLIENT, + DOMAIN, + VOLUME_STRING_MAP, +) _LOGGER = logging.getLogger(__name__) -ATTR_ALARM_ACTIVE = "alarm_active" -ATTR_ALARM_DURATION = "alarm_duration" -ATTR_ALARM_VOLUME = "alarm_volume" ATTR_BATTERY_BACKUP_POWER_LEVEL = "battery_backup_power_level" -ATTR_CHIME_VOLUME = "chime_volume" -ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" -ATTR_ENTRY_DELAY_HOME = "entry_delay_home" -ATTR_EXIT_DELAY_AWAY = "exit_delay_away" -ATTR_EXIT_DELAY_HOME = "exit_delay_home" ATTR_GSM_STRENGTH = "gsm_strength" -ATTR_LAST_EVENT_INFO = "last_event_info" -ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" -ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" -ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" -ATTR_LAST_EVENT_TYPE = "last_event_type" -ATTR_LIGHT = "light" +ATTR_PIN_NAME = "pin_name" ATTR_RF_JAMMING = "rf_jamming" -ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" ATTR_WALL_POWER_LEVEL = "wall_power_level" ATTR_WIFI_STRENGTH = "wifi_strength" -VOLUME_STRING_MAP = { - VOLUME_HIGH: "high", - VOLUME_LOW: "low", - VOLUME_MEDIUM: "medium", - VOLUME_OFF: "off", -} - async def async_setup_entry(hass, entry, async_add_entities): """Set up a SimpliSafe alarm control panel based on a config entry.""" @@ -74,34 +80,42 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): def __init__(self, simplisafe, system, code): """Initialize the SimpliSafe alarm.""" - super().__init__(system, "Alarm Control Panel") + super().__init__(simplisafe, system, "Alarm Control Panel") self._changed_by = None self._code = code - self._simplisafe = simplisafe - self._state = None + self._last_event = None - self._attrs.update({ATTR_ALARM_ACTIVE: self._system.alarm_going_off}) - if self._system.version == 3: - self._attrs.update( - { - ATTR_ALARM_DURATION: self._system.alarm_duration, - ATTR_ALARM_VOLUME: VOLUME_STRING_MAP[self._system.alarm_volume], - ATTR_BATTERY_BACKUP_POWER_LEVEL: self._system.battery_backup_power_level, - ATTR_CHIME_VOLUME: VOLUME_STRING_MAP[self._system.chime_volume], - ATTR_ENTRY_DELAY_AWAY: self._system.entry_delay_away, - ATTR_ENTRY_DELAY_HOME: self._system.entry_delay_home, - ATTR_EXIT_DELAY_AWAY: self._system.exit_delay_away, - ATTR_EXIT_DELAY_HOME: self._system.exit_delay_home, - ATTR_GSM_STRENGTH: self._system.gsm_strength, - ATTR_LIGHT: self._system.light, - ATTR_RF_JAMMING: self._system.rf_jamming, - ATTR_VOICE_PROMPT_VOLUME: VOLUME_STRING_MAP[ - self._system.voice_prompt_volume - ], - ATTR_WALL_POWER_LEVEL: self._system.wall_power_level, - ATTR_WIFI_STRENGTH: self._system.wifi_strength, - } - ) + if system.alarm_going_off: + self._state = STATE_ALARM_TRIGGERED + elif system.state == SystemStates.away: + self._state = STATE_ALARM_ARMED_AWAY + elif system.state in ( + SystemStates.away_count, + SystemStates.exit_delay, + SystemStates.home_count, + ): + self._state = STATE_ALARM_ARMING + elif system.state == SystemStates.home: + self._state = STATE_ALARM_ARMED_HOME + elif system.state == SystemStates.off: + self._state = STATE_ALARM_DISARMED + else: + self._state = None + + for event_type in ( + EVENT_ALARM_CANCELED, + EVENT_ALARM_TRIGGERED, + EVENT_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD, + EVENT_ARMED_AWAY_BY_REMOTE, + EVENT_ARMED_HOME, + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE, + EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_REMOTE, + EVENT_HOME_EXIT_DELAY, + ): + self.websocket_events_to_listen_for.append(event_type) @property def changed_by(self): @@ -139,69 +153,91 @@ async def async_alarm_disarm(self, code=None): if not self._validate_code(code, "disarming"): return - await self._system.set_off() + try: + await self._system.set_off() + except SimplipyError as err: + _LOGGER.error('Error while disarming "%s": %s', self._system.name, err) + return + + self._state = STATE_ALARM_DISARMED async def async_alarm_arm_home(self, code=None): """Send arm home command.""" if not self._validate_code(code, "arming home"): return - await self._system.set_home() + try: + await self._system.set_home() + except SimplipyError as err: + _LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err) + return + + self._state = STATE_ALARM_ARMED_HOME async def async_alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, "arming away"): return - await self._system.set_away() - - async def async_update(self): - """Update alarm status.""" - event_data = self._simplisafe.last_event_data[self._system.system_id] - - if event_data.get("pinName"): - self._changed_by = event_data["pinName"] - - if self._system.state == SystemStates.error: - self._online = False + try: + await self._system.set_away() + except SimplipyError as err: + _LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err) return - self._online = True + self._state = STATE_ALARM_ARMING - if self._system.state == SystemStates.off: + @callback + def async_update_from_rest_api(self): + """Update the entity with the provided REST API data.""" + if self._system.version == 3: + self._attrs.update( + { + ATTR_ALARM_DURATION: self._system.alarm_duration, + ATTR_ALARM_VOLUME: VOLUME_STRING_MAP[self._system.alarm_volume], + ATTR_BATTERY_BACKUP_POWER_LEVEL: self._system.battery_backup_power_level, + ATTR_CHIME_VOLUME: VOLUME_STRING_MAP[self._system.chime_volume], + ATTR_ENTRY_DELAY_AWAY: self._system.entry_delay_away, + ATTR_ENTRY_DELAY_HOME: self._system.entry_delay_home, + ATTR_EXIT_DELAY_AWAY: self._system.exit_delay_away, + ATTR_EXIT_DELAY_HOME: self._system.exit_delay_home, + ATTR_GSM_STRENGTH: self._system.gsm_strength, + ATTR_LIGHT: self._system.light, + ATTR_RF_JAMMING: self._system.rf_jamming, + ATTR_VOICE_PROMPT_VOLUME: VOLUME_STRING_MAP[ + self._system.voice_prompt_volume + ], + ATTR_WALL_POWER_LEVEL: self._system.wall_power_level, + ATTR_WIFI_STRENGTH: self._system.wifi_strength, + } + ) + + @callback + def async_update_from_websocket_event(self, event): + """Update the entity with the provided websocket API event data.""" + if event.event_type in ( + EVENT_ALARM_CANCELED, + EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_REMOTE, + ): self._state = STATE_ALARM_DISARMED - elif self._system.state in (SystemStates.home, SystemStates.home_count): - self._state = STATE_ALARM_ARMED_HOME - elif self._system.state in ( - SystemStates.away, - SystemStates.away_count, - SystemStates.exit_delay, + elif event.event_type == EVENT_ALARM_TRIGGERED: + self._state = STATE_ALARM_TRIGGERED + elif event.event_type in ( + EVENT_ARMED_AWAY, + EVENT_ARMED_AWAY_BY_KEYPAD, + EVENT_ARMED_AWAY_BY_REMOTE, ): self._state = STATE_ALARM_ARMED_AWAY + elif event.event_type == EVENT_ARMED_HOME: + self._state = STATE_ALARM_ARMED_HOME + elif event.event_type in ( + EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, + EVENT_AWAY_EXIT_DELAY_BY_REMOTE, + EVENT_HOME_EXIT_DELAY, + ): + self._state = STATE_ALARM_ARMING else: self._state = None - last_event = self._simplisafe.last_event_data[self._system.system_id] - - try: - last_event_sensor_type = EntityTypes(last_event["sensorType"]).name - except ValueError: - _LOGGER.warning( - 'Encountered unknown entity type: %s ("%s"). Please report it at' - "https://github.com/home-assistant/home-assistant/issues.", - last_event["sensorType"], - last_event["sensorName"], - ) - last_event_sensor_type = None - - self._attrs.update( - { - ATTR_LAST_EVENT_INFO: last_event["info"], - ATTR_LAST_EVENT_SENSOR_NAME: last_event["sensorName"], - ATTR_LAST_EVENT_SENSOR_TYPE: last_event_sensor_type, - ATTR_LAST_EVENT_TIMESTAMP: utc_from_timestamp( - last_event["eventTimestamp"] - ), - ATTR_LAST_EVENT_TYPE: last_event["eventType"], - } - ) + self._changed_by = event.changed_by diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 9c93cd18626aea..4963f9d2de1f06 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,28 +1,16 @@ """Config flow to configure the SimpliSafe component.""" -from collections import OrderedDict - from simplipy import API from simplipy.errors import SimplipyError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DOMAIN - - -@callback -def configured_instances(hass): - """Return a set of configured SimpliSafe instances.""" - return set( - entry.data[CONF_USERNAME] for entry in hass.config_entries.async_entries(DOMAIN) - ) +from .const import DOMAIN # pylint: disable=unused-import -@config_entries.HANDLERS.register(DOMAIN) -class SimpliSafeFlowHandler(config_entries.ConfigFlow): +class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a SimpliSafe config flow.""" VERSION = 1 @@ -30,16 +18,19 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the config flow.""" - self.data_schema = OrderedDict() - self.data_schema[vol.Required(CONF_USERNAME)] = str - self.data_schema[vol.Required(CONF_PASSWORD)] = str - self.data_schema[vol.Optional(CONF_CODE)] = str + self.data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CODE): str, + } + ) async def _show_form(self, errors=None): """Show the form to the user.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema(self.data_schema), + data_schema=self.data_schema, errors=errors if errors else {}, ) @@ -49,12 +40,11 @@ async def async_step_import(self, import_config): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_USERNAME] in configured_instances(self.hass): - return await self._show_form({CONF_USERNAME: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() username = user_input[CONF_USERNAME] websession = aiohttp_client.async_get_clientsession(self.hass) @@ -64,7 +54,7 @@ async def async_step_user(self, user_input=None): username, user_input[CONF_PASSWORD], websession ) except SimplipyError: - return await self._show_form({"base": "invalid_credentials"}) + return await self._show_form(errors={"base": "invalid_credentials"}) return self.async_create_entry( title=user_input[CONF_USERNAME], diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index 4dfef39de464c3..6ca5f8323a7886 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -1,10 +1,28 @@ """Define constants for the SimpliSafe component.""" from datetime import timedelta +from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF + DOMAIN = "simplisafe" DATA_CLIENT = "client" DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) -TOPIC_UPDATE = "update" +ATTR_ALARM_DURATION = "alarm_duration" +ATTR_ALARM_VOLUME = "alarm_volume" +ATTR_CHIME_VOLUME = "chime_volume" +ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" +ATTR_ENTRY_DELAY_HOME = "entry_delay_home" +ATTR_EXIT_DELAY_AWAY = "exit_delay_away" +ATTR_EXIT_DELAY_HOME = "exit_delay_home" +ATTR_LIGHT = "light" +ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" + +VOLUMES = [VOLUME_OFF, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_HIGH] +VOLUME_STRING_MAP = { + VOLUME_HIGH: "high", + VOLUME_LOW: "low", + VOLUME_MEDIUM: "medium", + VOLUME_OFF: "off", +} diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 10c5d310e73eee..fc98d67ccbffa7 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,10 +1,12 @@ """Support for SimpliSafe locks.""" import logging +from simplipy.errors import SimplipyError from simplipy.lock import LockStates +from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED from homeassistant.components.lock import LockDevice -from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED +from homeassistant.core import callback from . import SimpliSafeEntity from .const import DATA_CLIENT, DOMAIN @@ -15,19 +17,13 @@ ATTR_JAMMED = "jammed" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" -STATE_MAP = { - LockStates.locked: STATE_LOCKED, - LockStates.unknown: STATE_UNKNOWN, - LockStates.unlocked: STATE_UNLOCKED, -} - async def async_setup_entry(hass, entry, async_add_entities): """Set up SimpliSafe locks based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] async_add_entities( [ - SimpliSafeLock(system, lock) + SimpliSafeLock(simplisafe, system, lock) for system in simplisafe.systems.values() for lock in system.locks.values() ] @@ -37,32 +33,43 @@ async def async_setup_entry(hass, entry, async_add_entities): class SimpliSafeLock(SimpliSafeEntity, LockDevice): """Define a SimpliSafe lock.""" - def __init__(self, system, lock): + def __init__(self, simplisafe, system, lock): """Initialize.""" - super().__init__(system, lock.name, serial=lock.serial) + super().__init__(simplisafe, system, lock.name, serial=lock.serial) + self._is_locked = False self._lock = lock + for event_type in (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED): + self.websocket_events_to_listen_for.append(event_type) + @property def is_locked(self): """Return true if the lock is locked.""" - return STATE_MAP.get(self._lock.state) == STATE_LOCKED + return self._is_locked async def async_lock(self, **kwargs): """Lock the lock.""" - await self._lock.lock() + try: + await self._lock.lock() + except SimplipyError as err: + _LOGGER.error('Error while locking "%s": %s', self._lock.name, err) + return + + self._is_locked = True async def async_unlock(self, **kwargs): """Unlock the lock.""" - await self._lock.unlock() - - async def async_update(self): - """Update lock status.""" - if self._lock.offline or self._lock.disabled: - self._online = False + try: + await self._lock.unlock() + except SimplipyError as err: + _LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) return - self._online = True + self._is_locked = False + @callback + def async_update_from_rest_api(self): + """Update the entity with the provided REST API data.""" self._attrs.update( { ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery, @@ -70,3 +77,11 @@ async def async_update(self): ATTR_PIN_PAD_LOW_BATTERY: self._lock.pin_pad_low_battery, } ) + + @callback + def async_update_from_websocket_event(self, event): + """Update the entity with the provided websocket event data.""" + if event.event_type == EVENT_LOCK_LOCKED: + self._is_locked = True + else: + self._is_locked = False diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index f7f6fce0c74735..e44f39265cbec4 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==6.0.0"], + "requirements": ["simplisafe-python==8.1.1"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 5df0cf400d4bed..3043bd79104c3e 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -14,6 +14,9 @@ "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" + }, + "abort": { + "already_configured": "This SimpliSafe account is already in use." } } } diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 1c4b98c29110cb..a56fe7ab151901 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -2,7 +2,7 @@ "domain": "sma", "name": "SMA Solar", "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.3.4"], + "requirements": ["pysma==0.3.5"], "dependencies": [], "codeowners": ["@kellerza"] } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 8caebb4f8715ac..40ec4179cd1cee 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -16,6 +16,7 @@ CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, ) +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -210,6 +211,7 @@ def poll(self): """SMA sensors are updated & don't poll.""" return False + @callback def async_update_values(self): """Update this sensor.""" update = False diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index d34653e60e7b88..c31ab97cb95adb 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -199,7 +199,7 @@ def get_consumption(self, location_id, aggregation, delta): try: return self._smappy.get_consumption(location_id, start, end, aggregation) except RequestException as error: - _LOGGER.error("Error getting comsumption from Smappee cloud. (%s)", error) + _LOGGER.error("Error getting consumption from Smappee cloud. (%s)", error) def get_sensor_consumption(self, location_id, sensor_id, aggregation, delta): """Update data from Smappee.""" @@ -221,7 +221,7 @@ def get_sensor_consumption(self, location_id, sensor_id, aggregation, delta): location_id, sensor_id, start, end, aggregation ) except RequestException as error: - _LOGGER.error("Error getting comsumption from Smappee cloud. (%s)", error) + _LOGGER.error("Error getting consumption from Smappee cloud. (%s)", error) def actuator_on(self, location_id, actuator_id, is_remote_switch, duration=None): """Turn on actuator.""" diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index c61d28bbaacbae..4ff0bb5b853d8e 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, VOLUME_CUBIC_METERS from homeassistant.helpers.entity import Entity from . import DATA_SMAPPEE @@ -43,8 +43,20 @@ ENERGY_KILO_WATT_HOUR, "consumption", ], - "water_sensor_1": ["Water Sensor 1", "mdi:water", "water", "m3", "value1"], - "water_sensor_2": ["Water Sensor 2", "mdi:water", "water", "m3", "value2"], + "water_sensor_1": [ + "Water Sensor 1", + "mdi:water", + "water", + VOLUME_CUBIC_METERS, + "value1", + ], + "water_sensor_2": [ + "Water Sensor 2", + "mdi:water", + "water", + VOLUME_CUBIC_METERS, + "value2", + ], "water_sensor_temperature": [ "Water Sensor Temperature", "mdi:temperature-celsius", diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py index ef2da4e9a1dc71..778b5171ae47f7 100644 --- a/homeassistant/components/smarthab/__init__.py +++ b/homeassistant/components/smarthab/__init__.py @@ -1,9 +1,4 @@ -""" -Support for SmartHab device integration. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/smarthab/ -""" +"""Support for SmartHab device integration.""" import logging import pysmarthab diff --git a/homeassistant/components/smarthab/cover.py b/homeassistant/components/smarthab/cover.py index 9bcb89b7ab4df5..af55f2de7f94f9 100644 --- a/homeassistant/components/smarthab/cover.py +++ b/homeassistant/components/smarthab/cover.py @@ -1,9 +1,4 @@ -""" -Support for SmartHab device integration. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/smarthab/ -""" +"""Support for SmartHab device integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/smarthab/light.py b/homeassistant/components/smarthab/light.py index bc6eb31fd0415d..469d89011b8b2b 100644 --- a/homeassistant/components/smarthab/light.py +++ b/homeassistant/components/smarthab/light.py @@ -1,9 +1,4 @@ -""" -Support for SmartHab device integration. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/smarthab/ -""" +"""Support for SmartHab device integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/smartthings/.translations/sv.json b/homeassistant/components/smartthings/.translations/sv.json index 6da4624fa39681..725957682ad8bd 100644 --- a/homeassistant/components/smartthings/.translations/sv.json +++ b/homeassistant/components/smartthings/.translations/sv.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "access_token": "\u00c5tkomsttoken" + "access_token": "\u00c5tkomstnyckel" }, "description": "V\u00e4nligen ange en [personlig \u00e5tkomsttoken]({token_url}) f\u00f6r SmartThings som har skapats enligt [instruktionerna]({component_url}).", "title": "Ange personlig \u00e5tkomsttoken" diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 33f9558023db6d..1539fa076e4c28 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -146,7 +146,7 @@ async def retrieve_device_status(device): except ClientResponseError as ex: if ex.status in (401, 403): _LOGGER.exception( - "Unable to setup config entry '%s' - please reconfigure the integration", + "Unable to setup configuration entry '%s' - please reconfigure the integration", entry.title, ) remove_entry = True @@ -183,7 +183,7 @@ async def async_get_entry_scenes(entry: ConfigEntry, api): except ClientResponseError as ex: if ex.status == 403: _LOGGER.exception( - "Unable to load scenes for config entry '%s' because the access token does not have the required access", + "Unable to load scenes for configuration entry '%s' because the access token does not have the required access", entry.title, ) else: @@ -230,7 +230,7 @@ async def async_remove_entry(hass: HomeAssistantType, entry: ConfigEntry) -> Non app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) if app_count > 1: _LOGGER.debug( - "App %s was not removed because it is in use by other config entries", + "App %s was not removed because it is in use by other configuration entries", app_id, ) return diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 19a9e20cd6b0de..232540ee47b039 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -406,7 +406,7 @@ async def async_update(self): self._device.device_id, mode, ) - self._hvac_modes = modes + self._hvac_modes = list(modes) @property def current_temperature(self): diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 38e32e90b85007..fb04c01c682d73 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,6 +5,7 @@ from pysmartthings import Attribute, Capability from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -42,13 +43,23 @@ Map(Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, None) ], Capability.carbon_dioxide_measurement: [ - Map(Attribute.carbon_dioxide, "Carbon Dioxide Measurement", "ppm", None) + Map( + Attribute.carbon_dioxide, + "Carbon Dioxide Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.carbon_monoxide_detector: [ Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None) ], Capability.carbon_monoxide_measurement: [ - Map(Attribute.carbon_monoxide_level, "Carbon Monoxide Measurement", "ppm", None) + Map( + Attribute.carbon_monoxide_level, + "Carbon Monoxide Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.dishwasher_operating_state: [ Map(Attribute.machine_state, "Dishwasher Machine State", None, None), @@ -82,12 +93,17 @@ Map( Attribute.equivalent_carbon_dioxide_measurement, "Equivalent Carbon Dioxide Measurement", - "ppm", + CONCENTRATION_PARTS_PER_MILLION, None, ) ], Capability.formaldehyde_measurement: [ - Map(Attribute.formaldehyde_level, "Formaldehyde Measurement", "ppm", None) + Map( + Attribute.formaldehyde_level, + "Formaldehyde Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.illuminance_measurement: [ Map(Attribute.illuminance, "Illuminance", "lux", DEVICE_CLASS_ILLUMINANCE) @@ -203,7 +219,12 @@ Capability.three_axis: [], Capability.tv_channel: [Map(Attribute.tv_channel, "Tv Channel", None, None)], Capability.tvoc_measurement: [ - Map(Attribute.tvoc_level, "Tvoc Measurement", "ppm", None) + Map( + Attribute.tvoc_level, + "Tvoc Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.ultraviolet_index: [ Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None) diff --git a/homeassistant/components/smhi/.translations/pl.json b/homeassistant/components/smhi/.translations/pl.json index 21973cd54b6007..818f27853ffff7 100644 --- a/homeassistant/components/smhi/.translations/pl.json +++ b/homeassistant/components/smhi/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Nazwa ju\u017c istnieje", + "name_exists": "Nazwa ju\u017c istnieje.", "wrong_location": "Lokalizacja w Szwecji" }, "step": { diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py new file mode 100644 index 00000000000000..4897ef2844b391 --- /dev/null +++ b/homeassistant/components/sms/__init__.py @@ -0,0 +1,33 @@ +"""The sms component.""" +import logging + +import gammu # pylint: disable=import-error, no-member +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.isdevice})}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Configure Gammu state machine.""" + conf = config[DOMAIN] + device = conf.get(CONF_DEVICE) + gateway = gammu.StateMachine() # pylint: disable=no-member + try: + gateway.SetConfig(0, dict(Device=device, Connection="at")) + gateway.Init() + except gammu.GSMError as exc: # pylint: disable=no-member + _LOGGER.error("Failed to initialize, error %s", exc) + return False + else: + hass.data[DOMAIN] = gateway + return True diff --git a/homeassistant/components/sms/const.py b/homeassistant/components/sms/const.py new file mode 100644 index 00000000000000..aff2b704e057d4 --- /dev/null +++ b/homeassistant/components/sms/const.py @@ -0,0 +1,3 @@ +"""Constants for sms Component.""" + +DOMAIN = "sms" diff --git a/homeassistant/components/sms/manifest.json b/homeassistant/components/sms/manifest.json new file mode 100644 index 00000000000000..c58139993bbf24 --- /dev/null +++ b/homeassistant/components/sms/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "sms", + "name": "SMS notifications via GSM-modem", + "documentation": "https://www.home-assistant.io/integrations/sms", + "requirements": ["python-gammu==2.12"], + "dependencies": [], + "codeowners": ["@ocalvo"] +} diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py new file mode 100644 index 00000000000000..0a47e0aad251dc --- /dev/null +++ b/homeassistant/components/sms/notify.py @@ -0,0 +1,47 @@ +"""Support for SMS notification services.""" +import logging + +import gammu # pylint: disable=import-error, no-member +import voluptuous as vol + +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_NAME, CONF_RECIPIENT +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_NAME): cv.string} +) + + +def get_service(hass, config, discovery_info=None): + """Get the SMS notification service.""" + gateway = hass.data[DOMAIN] + number = config[CONF_RECIPIENT] + return SMSNotificationService(gateway, number) + + +class SMSNotificationService(BaseNotificationService): + """Implement the notification service for SMS.""" + + def __init__(self, gateway, number): + """Initialize the service.""" + self.gateway = gateway + self.number = number + + def send_message(self, message="", **kwargs): + """Send SMS message.""" + # Prepare message data + # We tell that we want to use first SMSC number stored in phone + gammu_message = { + "Text": message, + "SMSC": {"Location": 1}, + "Number": self.number, + } + try: + self.gateway.SendSMS(gammu_message) + except gammu.GSMError as exc: # pylint: disable=no-member + _LOGGER.error("Sending to %s failed: %s", self.number, exc) diff --git a/homeassistant/components/socialblade/manifest.json b/homeassistant/components/socialblade/manifest.json index 2ce7fbabf0f5e9..540febe7f2e6a5 100644 --- a/homeassistant/components/socialblade/manifest.json +++ b/homeassistant/components/socialblade/manifest.json @@ -2,7 +2,7 @@ "domain": "socialblade", "name": "Social Blade", "documentation": "https://www.home-assistant.io/integrations/socialblade", - "requirements": ["socialbladeclient==0.2"], + "requirements": ["socialbladeclient==0.5"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/solaredge/.translations/hu.json b/homeassistant/components/solaredge/.translations/hu.json new file mode 100644 index 00000000000000..ae8f51983ea234 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Ennek az install\u00e1ci\u00f3nak a neve" + }, + "title": "Az API param\u00e9terek megad\u00e1sa ehhez a telep\u00edt\u00e9shez" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/pl.json b/homeassistant/components/solaredge/.translations/pl.json index 376a81219b0c81..5e80c1563f4811 100644 --- a/homeassistant/components/solaredge/.translations/pl.json +++ b/homeassistant/components/solaredge/.translations/pl.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "site_exists": "Ten site_id jest ju\u017c skonfigurowany" + "site_exists": "To site_id jest ju\u017c skonfigurowane." }, "error": { - "site_exists": "Ten site_id jest ju\u017c skonfigurowany" + "site_exists": "To site_id jest ju\u017c skonfigurowane." }, "step": { "user": { diff --git a/homeassistant/components/solaredge/.translations/sv.json b/homeassistant/components/solaredge/.translations/sv.json new file mode 100644 index 00000000000000..25bb0f325a15a2 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Denna site_id \u00e4r redan konfigurerad" + }, + "error": { + "site_exists": "Denna site_id \u00e4r redan konfigurerad" + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckeln f\u00f6r den h\u00e4r webbplatsen", + "name": "Namnet p\u00e5 den h\u00e4r installationen", + "site_id": "SolarEdge webbplats-id" + }, + "title": "Definiera API-parametrarna f\u00f6r den h\u00e4r installationen" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py index 7c8c9380522b20..62bf99ab3832a4 100644 --- a/homeassistant/components/solaredge/config_flow.py +++ b/homeassistant/components/solaredge/config_flow.py @@ -54,7 +54,7 @@ def _check_site(self, site_id, api_key) -> bool: return True async def async_step_user(self, user_input=None): - """Step when user intializes a integration.""" + """Step when user initializes a integration.""" self._errors = {} if user_input is not None: name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) diff --git a/homeassistant/components/solarlog/.translations/hu.json b/homeassistant/components/solarlog/.translations/hu.json new file mode 100644 index 00000000000000..e52cebefda687b --- /dev/null +++ b/homeassistant/components/solarlog/.translations/hu.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solarlog/.translations/pl.json b/homeassistant/components/solarlog/.translations/pl.json index 251d183b361c79..fdbf21feb920fc 100644 --- a/homeassistant/components/solarlog/.translations/pl.json +++ b/homeassistant/components/solarlog/.translations/pl.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." }, "error": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, sprawd\u017a adres hosta" }, "step": { diff --git a/homeassistant/components/solarlog/.translations/ru.json b/homeassistant/components/solarlog/.translations/ru.json index b64496c45919fa..3333d5c0d5f203 100644 --- a/homeassistant/components/solarlog/.translations/ru.json +++ b/homeassistant/components/solarlog/.translations/ru.json @@ -11,7 +11,7 @@ "user": { "data": { "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", - "name": "\u041f\u0440\u0435\u0444\u0438\u043a\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0434\u043b\u044f \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 Solar-Log" + "name": "\u041f\u0440\u0435\u0444\u0438\u043a\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0434\u043b\u044f \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 Solar-Log" }, "title": "Solar-Log" } diff --git a/homeassistant/components/solarlog/.translations/sv.json b/homeassistant/components/solarlog/.translations/sv.json new file mode 100644 index 00000000000000..981bd9fb167b3d --- /dev/null +++ b/homeassistant/components/solarlog/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "cannot_connect": "Det gick inte att ansluta, kontrollera v\u00e4rdadressen" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rdnamnet eller ip-adressen f\u00f6r din Solar-Log-enhet", + "name": "Prefixet som ska anv\u00e4ndas f\u00f6r dina Solar-Log sensorer" + }, + "title": "Definiera din Solar-Log-anslutning" + } + }, + "title": "Solar-Log" + } +} \ No newline at end of file diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 5cb2d5deec1a63..111155b27b65f8 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -54,7 +54,7 @@ async def _test_connection(self, host): return False async def async_step_user(self, user_input=None): - """Step when user intializes a integration.""" + """Step when user initializes a integration.""" self._errors = {} if user_input is not None: # set some defaults in case we need to return to the form diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 9331628e027893..b626da456a9654 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -2,7 +2,7 @@ "domain": "solarlog", "name": "Solar-Log", "config_flow": true, - "documentation": "https://www.home-assistant.io/integration/solarlog", + "documentation": "https://www.home-assistant.io/integrations/solarlog", "dependencies": [], "codeowners": ["@Ernst79"], "requirements": ["sunwatcher==0.2.1"] diff --git a/homeassistant/components/soma/.translations/ca.json b/homeassistant/components/soma/.translations/ca.json index a1a5b9489faf8d..00bc3eef39c104 100644 --- a/homeassistant/components/soma/.translations/ca.json +++ b/homeassistant/components/soma/.translations/ca.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_setup": "Nom\u00e9s pots configurar un compte de Soma.", + "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte amb Soma.", "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "connection_error": "No s'ha pogut connectar amb SOMA Connect.", "missing_configuration": "El component Soma no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/soma/.translations/cs.json b/homeassistant/components/soma/.translations/cs.json index b3922b67795b4b..42a8bddf841330 100644 --- a/homeassistant/components/soma/.translations/cs.json +++ b/homeassistant/components/soma/.translations/cs.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/soma/.translations/hu.json b/homeassistant/components/soma/.translations/hu.json new file mode 100644 index 00000000000000..797cfa1b2d8638 --- /dev/null +++ b/homeassistant/components/soma/.translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "connection_error": "Nem siker\u00fclt csatlakozni a SOMA Connecthez.", + "missing_configuration": "A Soma \u00f6sszetev\u0151 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", + "result_error": "A SOMA Connect hiba\u00e1llapottal v\u00e1laszolt." + }, + "create_entry": { + "default": "Soma sikeresen hiteles\u00edtett." + }, + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "port": "Port" + }, + "description": "K\u00e9rj\u00fck, adja meg a SOMA Connect csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sait.", + "title": "SOMA csatlakoz\u00e1s" + } + }, + "title": "Soma" + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/.translations/sv.json b/homeassistant/components/soma/.translations/sv.json new file mode 100644 index 00000000000000..bb3ce895fd5753 --- /dev/null +++ b/homeassistant/components/soma/.translations/sv.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bara konfigurera ett Soma-konto.", + "authorize_url_timeout": "Timeout vid generering av auktoriserings-url.", + "connection_error": "Det gick inte att ansluta till SOMA Connect.", + "missing_configuration": "Soma-komponenten \u00e4r inte konfigurerad. F\u00f6lj dokumentationen.", + "result_error": "SOMA Connect svarade med felstatus." + }, + "create_entry": { + "default": "Lyckad autentisering med Soma." + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "port": "Port" + }, + "description": "Ange anslutningsinst\u00e4llningar f\u00f6r din SOMA Connect.", + "title": "SOMA Connect" + } + }, + "title": "Soma" + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/manifest.json b/homeassistant/components/soma/manifest.json index 397531562b1017..a724a3d4485a91 100644 --- a/homeassistant/components/soma/manifest.json +++ b/homeassistant/components/soma/manifest.json @@ -2,7 +2,7 @@ "domain": "soma", "name": "Soma Connect", "config_flow": true, - "documentation": "", + "documentation": "https://www.home-assistant.io/integrations/soma", "dependencies": [], "codeowners": ["@ratsept"], "requirements": ["pysoma==0.0.10"] diff --git a/homeassistant/components/somfy/.translations/ca.json b/homeassistant/components/somfy/.translations/ca.json index b3095cd4e9c4da..58b8853cd51313 100644 --- a/homeassistant/components/somfy/.translations/ca.json +++ b/homeassistant/components/somfy/.translations/ca.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_setup": "Nom\u00e9s pots configurar un compte de Somfy.", - "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte amb Somfy.", + "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "missing_configuration": "El component Somfy no est\u00e0 configurat. Mira'n la documentaci\u00f3." }, "create_entry": { diff --git a/homeassistant/components/somfy/.translations/cs.json b/homeassistant/components/somfy/.translations/cs.json index 7ba035f562e6de..bf8a3bf916ea03 100644 --- a/homeassistant/components/somfy/.translations/cs.json +++ b/homeassistant/components/somfy/.translations/cs.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el" + }, "step": { "pick_implementation": { "title": "Vyberte metodu ov\u011b\u0159en\u00ed" diff --git a/homeassistant/components/somfy/.translations/hu.json b/homeassistant/components/somfy/.translations/hu.json new file mode 100644 index 00000000000000..3df2fb30477a0a --- /dev/null +++ b/homeassistant/components/somfy/.translations/hu.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9si m\u00f3dszert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/somfy/.translations/sv.json b/homeassistant/components/somfy/.translations/sv.json index 390cd1f4d80dcc..982b32a90a10b4 100644 --- a/homeassistant/components/somfy/.translations/sv.json +++ b/homeassistant/components/somfy/.translations/sv.json @@ -8,6 +8,11 @@ "create_entry": { "default": "Lyckad autentisering med Somfy." }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + } + }, "title": "Somfy" } } \ No newline at end of file diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index 365c68393002cf..aa288e13ac7875 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -1,9 +1,4 @@ -""" -Support for Somfy hubs. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/somfy/ -""" +"""Support for Somfy hubs.""" import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 82bcdad6ef4599..c0781b37603ab3 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -14,6 +14,15 @@ CONF_MONITORED_CONDITIONS, CONF_PORT, CONF_SSL, + DATA_BYTES, + DATA_EXABYTES, + DATA_GIGABYTES, + DATA_KILOBYTES, + DATA_MEGABYTES, + DATA_PETABYTES, + DATA_TERABYTES, + DATA_YOTTABYTES, + DATA_ZETTABYTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -29,10 +38,10 @@ DEFAULT_PORT = 8989 DEFAULT_URLBASE = "" DEFAULT_DAYS = "1" -DEFAULT_UNIT = "GB" +DEFAULT_UNIT = DATA_GIGABYTES SENSOR_TYPES = { - "diskspace": ["Disk Space", "GB", "mdi:harddisk"], + "diskspace": ["Disk Space", DATA_GIGABYTES, "mdi:harddisk"], "queue": ["Queue", "Episodes", "mdi:download"], "upcoming": ["Upcoming", "Episodes", "mdi:television"], "wanted": ["Wanted", "Episodes", "mdi:television"], @@ -52,7 +61,17 @@ } # Support to Yottabytes for the future, why not -BYTE_SIZES = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] +BYTE_SIZES = [ + DATA_BYTES, + DATA_KILOBYTES, + DATA_MEGABYTES, + DATA_GIGABYTES, + DATA_TERABYTES, + DATA_PETABYTES, + DATA_EXABYTES, + DATA_ZETTABYTES, + DATA_YOTTABYTES, +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, diff --git a/homeassistant/components/sonos/.translations/zh-Hans.json b/homeassistant/components/sonos/.translations/zh-Hans.json index 17c1e78d3e8922..de2609f4a7152b 100644 --- a/homeassistant/components/sonos/.translations/zh-Hans.json +++ b/homeassistant/components/sonos/.translations/zh-Hans.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Sonos \u8bbe\u5907\u3002", - "single_instance_allowed": "\u53ea\u6709\u4e00\u6b21 Sonos \u914d\u7f6e\u662f\u5fc5\u8981\u7684\u3002" + "single_instance_allowed": "\u53ea\u9700\u8bbe\u7f6e\u4e00\u6b21 Sonos \u5373\u53ef\u3002" }, "step": { "confirm": { diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index bcdb74ad4383b5..37b479a90b1419 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -107,7 +107,7 @@ def __init__(self, hass): 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" + "Loading Sonos by media_player platform configuration is no longer supported" ) @@ -174,6 +174,7 @@ def _discovered_player(soco): platform = entity_platform.current_platform.get() + @service.verify_domain_control(hass, SONOS_DOMAIN) async def async_service_handle(service_call: ServiceCall): """Handle dispatched services.""" entities = await platform.async_extract_from_service(service_call) @@ -201,16 +202,14 @@ async def async_service_handle(service_call: ServiceCall): hass, entities, service_call.data[ATTR_WITH_GROUP] ) - service.async_register_admin_service( - hass, + hass.services.async_register( SONOS_DOMAIN, SERVICE_JOIN, async_service_handle, cv.make_entity_service_schema({vol.Required(ATTR_MASTER): cv.entity_id}), ) - service.async_register_admin_service( - hass, + hass.services.async_register( SONOS_DOMAIN, SERVICE_UNJOIN, async_service_handle, @@ -221,12 +220,12 @@ async def async_service_handle(service_call: ServiceCall): {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean} ) - service.async_register_admin_service( - hass, SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema + hass.services.async_register( + SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema ) - service.async_register_admin_service( - hass, SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema + hass.services.async_register( + SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema ) platform.async_register_entity_service( @@ -763,6 +762,7 @@ async def _async_extract_group(event): return await self.hass.async_add_executor_job(_get_soco_group) + @callback def _async_regroup(group): """Rebuild internal group layout.""" sonos_group = [] diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 72677995a9d6b3..71592e92c17342 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -241,57 +241,46 @@ def supported_features(self): def turn_off(self): """Turn off media player.""" self._device.power_off() - self._status = self._device.status() def turn_on(self): """Turn on media player.""" self._device.power_on() - self._status = self._device.status() def volume_up(self): """Volume up the media player.""" self._device.volume_up() - self._volume = self._device.volume() def volume_down(self): """Volume down media player.""" self._device.volume_down() - self._volume = self._device.volume() def set_volume_level(self, volume): """Set volume level, range 0..1.""" self._device.set_volume(int(volume * 100)) - self._volume = self._device.volume() def mute_volume(self, mute): """Send mute command.""" self._device.mute() - self._volume = self._device.volume() def media_play_pause(self): """Simulate play pause media player.""" self._device.play_pause() - self._status = self._device.status() def media_play(self): """Send play command.""" self._device.play() - self._status = self._device.status() def media_pause(self): """Send media pause command to media player.""" self._device.pause() - self._status = self._device.status() def media_next_track(self): """Send next track command.""" self._device.next_track() - self._status = self._device.status() def media_previous_track(self): """Send the previous track command.""" self._device.previous_track() - self._status = self._device.status() @property def media_image_url(self): diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index b5ff14ce01d435..34689c4dccfd42 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -19,6 +19,7 @@ def _get_device_class(zone_type): ZoneType.ALARM: "motion", ZoneType.ENTRY_EXIT: "opening", ZoneType.FIRE: "smoke", + ZoneType.TECHNICAL: "power", }.get(zone_type) diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 69aadb7ac6c8d2..2fed2609fb356a 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,10 +1,12 @@ """Consts used by Speedtest.net.""" +from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS + DOMAIN = "speedtestdotnet" DATA_UPDATED = f"{DOMAIN}_data_updated" SENSOR_TYPES = { - "ping": ["Ping", "ms"], - "download": ["Download", "Mbit/s"], - "upload": ["Upload", "Mbit/s"], + "ping": ["Ping", TIME_MILLISECONDS], + "download": ["Download", DATA_RATE_MEGABITS_PER_SECOND], + "upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND], } diff --git a/homeassistant/components/spotify/.translations/ca.json b/homeassistant/components/spotify/.translations/ca.json new file mode 100644 index 00000000000000..fa0fa734353767 --- /dev/null +++ b/homeassistant/components/spotify/.translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte amb Spotify.", + "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", + "missing_configuration": "La integraci\u00f3 Spotify no est\u00e0 configurada. Mira'n la documentaci\u00f3." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Spotify." + }, + "step": { + "pick_implementation": { + "title": "Selecci\u00f3 del m\u00e8tode d'autenticaci\u00f3" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/cs.json b/homeassistant/components/spotify/.translations/cs.json new file mode 100644 index 00000000000000..bcb73eb66b0b34 --- /dev/null +++ b/homeassistant/components/spotify/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "M\u016f\u017eete nakonfigurovat pouze jeden \u00fa\u010det Spotify.", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", + "missing_configuration": "Integrace Spotify nen\u00ed nakonfigurov\u00e1na. Postupujte podle n\u00e1vodu." + }, + "create_entry": { + "default": "\u00dasp\u011b\u0161n\u011b ov\u011b\u0159eno pomoc\u00ed Spotify." + }, + "step": { + "pick_implementation": { + "title": "Vyberte metodu ov\u011b\u0159en\u00ed" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/da.json b/homeassistant/components/spotify/.translations/da.json new file mode 100644 index 00000000000000..f4f4950317a433 --- /dev/null +++ b/homeassistant/components/spotify/.translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en enkelt Spotify-konto.", + "authorize_url_timeout": "Timeout ved generering af godkendelses-url.", + "missing_configuration": "Spotify-integrationen er ikke konfigureret. F\u00f8lg venligst dokumentationen." + }, + "create_entry": { + "default": "Godkendt med Spotify." + }, + "step": { + "pick_implementation": { + "title": "V\u00e6lg godkendelsesmetode" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/de.json b/homeassistant/components/spotify/.translations/de.json new file mode 100644 index 00000000000000..49670e77285d08 --- /dev/null +++ b/homeassistant/components/spotify/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Sie k\u00f6nnen nur ein Spotify-Konto konfigurieren.", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", + "missing_configuration": "Die Spotify-Integration ist nicht konfiguriert. Bitte folgen Sie der Dokumentation." + }, + "create_entry": { + "default": "Erfolgreich mit Spotify authentifiziert." + }, + "step": { + "pick_implementation": { + "title": "Authentifizierungsmethode ausw\u00e4hlen" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/en.json b/homeassistant/components/spotify/.translations/en.json new file mode 100644 index 00000000000000..b26b2b6daf5099 --- /dev/null +++ b/homeassistant/components/spotify/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Spotify account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Spotify integration is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Spotify." + }, + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/es.json b/homeassistant/components/spotify/.translations/es.json new file mode 100644 index 00000000000000..1e8a90246eb48e --- /dev/null +++ b/homeassistant/components/spotify/.translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "S\u00f3lo puedes configurar una cuenta de Spotify.", + "authorize_url_timeout": "Tiempo de espera agotado para la autorizaci\u00f3n de la url.", + "missing_configuration": "La integraci\u00f3n de Spotify no est\u00e1 configurada. Por favor, siga la documentaci\u00f3n." + }, + "create_entry": { + "default": "Autentificado con \u00e9xito con Spotify." + }, + "step": { + "pick_implementation": { + "title": "Elija el m\u00e9todo de autenticaci\u00f3n" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/fr.json b/homeassistant/components/spotify/.translations/fr.json new file mode 100644 index 00000000000000..b6ec983df7611f --- /dev/null +++ b/homeassistant/components/spotify/.translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Spotify.", + "missing_configuration": "L'int\u00e9gration Spotify n'est pas configur\u00e9e. Veuillez suivre la documentation." + }, + "create_entry": { + "default": "Authentification r\u00e9ussie avec Spotify." + }, + "step": { + "pick_implementation": { + "title": "Choisissez la m\u00e9thode d'authentification" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/hu.json b/homeassistant/components/spotify/.translations/hu.json new file mode 100644 index 00000000000000..414c82751b54a4 --- /dev/null +++ b/homeassistant/components/spotify/.translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Csak egy Spotify-fi\u00f3kot konfigur\u00e1lhat.", + "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", + "missing_configuration": "A Spotify integr\u00e1ci\u00f3 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t." + }, + "create_entry": { + "default": "A Spotify sikeresen hiteles\u00edtett." + }, + "step": { + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9si m\u00f3dszert" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/it.json b/homeassistant/components/spotify/.translations/it.json new file mode 100644 index 00000000000000..ffe78aa0c02a91 --- /dev/null +++ b/homeassistant/components/spotify/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare un solo account di Spotify.", + "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione", + "missing_configuration": "L'integrazione di Spotify non \u00e8 configurata. Si prega di seguire la documentazione." + }, + "create_entry": { + "default": "Autenticato con successo con Spotify." + }, + "step": { + "pick_implementation": { + "title": "Scegli il metodo di autenticazione" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/ko.json b/homeassistant/components/spotify/.translations/ko.json new file mode 100644 index 00000000000000..af151ecc2d0840 --- /dev/null +++ b/homeassistant/components/spotify/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Spotify \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "Spotify \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694." + }, + "create_entry": { + "default": "Spotify \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/lb.json b/homeassistant/components/spotify/.translations/lb.json new file mode 100644 index 00000000000000..b7d555cbce1af6 --- /dev/null +++ b/homeassistant/components/spotify/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Spotify Kont konfigur\u00e9ieren.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "missing_configuration": "Spotifiy Integratioun ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Spotify authentifiz\u00e9iert." + }, + "step": { + "pick_implementation": { + "title": "Wielt Authentifikatiouns Method aus" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/nl.json b/homeassistant/components/spotify/.translations/nl.json new file mode 100644 index 00000000000000..abe5985404413e --- /dev/null +++ b/homeassistant/components/spotify/.translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n Spotify-account configureren.", + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "De Spotify integratie is niet geconfigureerd. Gelieve de documentatie te volgen." + }, + "create_entry": { + "default": "Succesvol geauthenticeerd met Spotify." + }, + "step": { + "pick_implementation": { + "title": "Kies Authenticatiemethode" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/no.json b/homeassistant/components/spotify/.translations/no.json new file mode 100644 index 00000000000000..69b046cad0c69e --- /dev/null +++ b/homeassistant/components/spotify/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bare konfigurere en Spotify-konto.", + "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", + "missing_configuration": "Spotify-integrasjonen er ikke konfigurert. F\u00f8lg dokumentasjonen." + }, + "create_entry": { + "default": "Vellykket autentisering med Spotify." + }, + "step": { + "pick_implementation": { + "title": "Velg autentiseringsmetode" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/pl.json b/homeassistant/components/spotify/.translations/pl.json new file mode 100644 index 00000000000000..1f2e1213882db7 --- /dev/null +++ b/homeassistant/components/spotify/.translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Spotify.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", + "missing_configuration": "Integracja ze Spotify nie jest skonfigurowana. Post\u0119puj zgodnie z dokumentacj\u0105." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Spotify" + }, + "step": { + "pick_implementation": { + "title": "Wybierz metod\u0119 uwierzytelnienia" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/ru.json b/homeassistant/components/spotify/.translations/ru.json new file mode 100644 index 00000000000000..b19f226d8bbcd7 --- /dev/null +++ b/homeassistant/components/spotify/.translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "missing_configuration": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Spotify \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438." + }, + "create_entry": { + "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "step": { + "pick_implementation": { + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/sl.json b/homeassistant/components/spotify/.translations/sl.json new file mode 100644 index 00000000000000..6ab0b0a40a6b42 --- /dev/null +++ b/homeassistant/components/spotify/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Konfigurirate lahko samo en ra\u010dun Spotify.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "missing_configuration": "Integracija Spotify ni konfigurirana. Prosimo, upo\u0161tevajte dokumentacijo." + }, + "create_entry": { + "default": "Uspe\u0161no overjena s Spotify." + }, + "step": { + "pick_implementation": { + "title": "Izberite na\u010din preverjanja pristnosti" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/sv.json b/homeassistant/components/spotify/.translations/sv.json new file mode 100644 index 00000000000000..5c720a1f26ef9f --- /dev/null +++ b/homeassistant/components/spotify/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan endast konfigurera ett Spotify-konto.", + "authorize_url_timeout": "Skapandet av en auktoriseringsadress \u00f6verskred tidsgr\u00e4nsen.", + "missing_configuration": "Spotify-integrationen \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen." + }, + "create_entry": { + "default": "Lyckad autentisering med Spotify." + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod." + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/tr.json b/homeassistant/components/spotify/.translations/tr.json new file mode 100644 index 00000000000000..88755b800f4dae --- /dev/null +++ b/homeassistant/components/spotify/.translations/tr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Yaln\u0131zca bir Spotify hesab\u0131 ayarlayabilirsin.", + "authorize_url_timeout": "Kimlik do\u011frulama URL'sini olu\u015ftururken zaman a\u015f\u0131m\u0131 ger\u00e7ekle\u015fti.", + "missing_configuration": "Spotify entegrasyonu ayarlanmam\u0131\u015f. L\u00fctfen dok\u00fcmentasyonu takip et." + }, + "create_entry": { + "default": "Spotify ile kimlik ba\u015far\u0131yla do\u011fruland\u0131." + }, + "step": { + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/zh-Hant.json b/homeassistant/components/spotify/.translations/zh-Hant.json new file mode 100644 index 00000000000000..c4ba3d46343e53 --- /dev/null +++ b/homeassistant/components/spotify/.translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Spotify \u5e33\u865f\u3002", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", + "missing_configuration": "Spotify \u6574\u5408\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Spotify\u3002" + }, + "step": { + "pick_implementation": { + "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" + } + }, + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index fdfce7e498bac7..9e5feb1c5821c1 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -1 +1,97 @@ -"""The spotify component.""" +"""The spotify integration.""" + +from spotipy import Spotify, SpotifyException +import voluptuous as vol + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.spotify import config_flow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CREDENTIALS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DATA_SPOTIFY_CLIENT, + DATA_SPOTIFY_ME, + DATA_SPOTIFY_SESSION, + DOMAIN, +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Inclusive(CONF_CLIENT_ID, ATTR_CREDENTIALS): cv.string, + vol.Inclusive(CONF_CLIENT_SECRET, ATTR_CREDENTIALS): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Spotify integration.""" + if DOMAIN not in config: + return True + + if CONF_CLIENT_ID in config[DOMAIN]: + config_flow.SpotifyFlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + "https://accounts.spotify.com/authorize", + "https://accounts.spotify.com/api/token", + ), + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Spotify from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + await session.async_ensure_token_valid() + spotify = Spotify(auth=session.token["access_token"]) + + try: + current_user = await hass.async_add_executor_job(spotify.me) + except SpotifyException: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_SPOTIFY_CLIENT: spotify, + DATA_SPOTIFY_ME: current_user, + DATA_SPOTIFY_SESSION: session, + } + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN) + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Spotify config entry.""" + # Unload entities for this entry/device. + await hass.config_entries.async_forward_entry_unload(entry, MEDIA_PLAYER_DOMAIN) + + # Cleanup + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + + return True diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py new file mode 100644 index 00000000000000..d619d3b2b10d21 --- /dev/null +++ b/homeassistant/components/spotify/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for Spotify.""" +import logging + +from spotipy import Spotify + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SpotifyFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Spotify OAuth2 authentication.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + scopes = [ + # Needed to be able to control playback + "user-modify-playback-state", + # Needed in order to read available devices + "user-read-playback-state", + # Needed to determine if the user has Spotify Premium + "user-read-private", + ] + return {"scope": ",".join(scopes)} + + async def async_oauth_create_entry(self, data: dict) -> dict: + """Create an entry for Spotify.""" + spotify = Spotify(auth=data["token"]["access_token"]) + + try: + current_user = await self.hass.async_add_executor_job(spotify.current_user) + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="connection_error") + + name = data["id"] = current_user["id"] + + if current_user.get("display_name"): + name = current_user["display_name"] + data["name"] = name + + await self.async_set_unique_id(current_user["id"]) + + return self.async_create_entry(title=name, data=data) diff --git a/homeassistant/components/spotify/const.py b/homeassistant/components/spotify/const.py new file mode 100644 index 00000000000000..37bd1a2bf81293 --- /dev/null +++ b/homeassistant/components/spotify/const.py @@ -0,0 +1,10 @@ +"""Define constants for the Spotify integration.""" + +DOMAIN = "spotify" + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" + +DATA_SPOTIFY_CLIENT = "spotify_client" +DATA_SPOTIFY_ME = "spotify_me" +DATA_SPOTIFY_SESSION = "spotify_session" diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index ab41becea65c59..be58d2bab400cf 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -2,7 +2,10 @@ "domain": "spotify", "name": "Spotify", "documentation": "https://www.home-assistant.io/integrations/spotify", - "requirements": ["spotipy-homeassistant==2.4.4.dev1"], - "dependencies": ["configurator", "http"], - "codeowners": [] + "requirements": ["spotipy==2.7.1"], + "zeroconf": ["_spotify-connect._tcp.local."], + "dependencies": ["http"], + "codeowners": ["@frenck"], + "config_flow": true, + "quality_scale": "silver" } diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index ba0c725eb7fb67..9588f428a66bc9 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -1,16 +1,15 @@ """Support for interacting with Spotify Connect.""" +from asyncio import run_coroutine_threadsafe +import datetime as dt from datetime import timedelta import logging -import random +from typing import Any, Callable, Dict, List, Optional -import spotipy -import spotipy.oauth2 -import voluptuous as vol +from aiohttp import ClientError +from spotipy import Spotify, SpotifyException -from homeassistant.components.http import HomeAssistantView -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_CONTENT_ID, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK, @@ -18,374 +17,328 @@ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_VOLUME_SET, ) -from homeassistant.const import CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -AUTH_CALLBACK_NAME = "api:spotify" -AUTH_CALLBACK_PATH = "/api/spotify" - -CONF_ALIASES = "aliases" -CONF_CACHE_PATH = "cache_path" -CONF_CLIENT_ID = "client_id" -CONF_CLIENT_SECRET = "client_secret" - -CONFIGURATOR_DESCRIPTION = ( - "To link your Spotify account, click the link, login, and authorize:" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ID, + CONF_NAME, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, ) -CONFIGURATOR_LINK_NAME = "Link Spotify account" -CONFIGURATOR_SUBMIT_CAPTION = "I authorized successfully" - -DEFAULT_CACHE_PATH = ".spotify-token-cache" -DEFAULT_NAME = "Spotify" -DOMAIN = "spotify" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.util.dt import utc_from_timestamp -SERVICE_PLAY_PLAYLIST = "play_playlist" -ATTR_RANDOM_SONG = "random_song" +from .const import DATA_SPOTIFY_CLIENT, DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN -PLAY_PLAYLIST_SCHEMA = vol.Schema( - { - vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, - vol.Optional(ATTR_RANDOM_SONG, default=False): cv.boolean, - } -) +_LOGGER = logging.getLogger(__name__) ICON = "mdi:spotify" SCAN_INTERVAL = timedelta(seconds=30) -SCOPE = "user-read-playback-state user-modify-playback-state user-read-private" - SUPPORT_SPOTIFY = ( - SUPPORT_VOLUME_SET + SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PLAY - | SUPPORT_NEXT_TRACK + | SUPPORT_PLAY_MEDIA | SUPPORT_PREVIOUS_TRACK + | SUPPORT_SEEK | SUPPORT_SELECT_SOURCE - | SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_CACHE_PATH): cv.string, - vol.Optional(CONF_ALIASES, default={}): {cv.string: cv.string}, - } + | SUPPORT_VOLUME_SET ) -def request_configuration(hass, config, add_entities, oauth): - """Request Spotify authorization.""" - configurator = hass.components.configurator - hass.data[DOMAIN] = configurator.request_config( - DEFAULT_NAME, - lambda _: None, - link_name=CONFIGURATOR_LINK_NAME, - link_url=oauth.get_authorize_url(), - description=CONFIGURATOR_DESCRIPTION, - submit_caption=CONFIGURATOR_SUBMIT_CAPTION, +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up Spotify based on a config entry.""" + spotify = SpotifyMediaPlayer( + hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_SESSION], + hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_CLIENT], + hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_ME], + entry.data[CONF_ID], + entry.data[CONF_NAME], ) + async_add_entities([spotify], True) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spotify platform.""" +def spotify_exception_handler(func): + """Decorate Spotify calls to handle Spotify exception. - callback_url = f"{hass.config.api.base_url}{AUTH_CALLBACK_PATH}" - cache = config.get(CONF_CACHE_PATH, hass.config.path(DEFAULT_CACHE_PATH)) - oauth = spotipy.oauth2.SpotifyOAuth( - config.get(CONF_CLIENT_ID), - config.get(CONF_CLIENT_SECRET), - callback_url, - scope=SCOPE, - cache_path=cache, - ) - token_info = oauth.get_cached_token() - if not token_info: - _LOGGER.info("no token; requesting authorization") - hass.http.register_view(SpotifyAuthCallbackView(config, add_entities, oauth)) - request_configuration(hass, config, add_entities, oauth) - return - if hass.data.get(DOMAIN): - configurator = hass.components.configurator - configurator.request_done(hass.data.get(DOMAIN)) - del hass.data[DOMAIN] - player = SpotifyMediaPlayer( - oauth, config.get(CONF_NAME, DEFAULT_NAME), config[CONF_ALIASES] - ) - add_entities([player], True) - - def play_playlist_service(service): - media_content_id = service.data[ATTR_MEDIA_CONTENT_ID] - random_song = service.data.get(ATTR_RANDOM_SONG) - player.play_playlist(media_content_id, random_song) - - hass.services.register( - DOMAIN, - SERVICE_PLAY_PLAYLIST, - play_playlist_service, - schema=PLAY_PLAYLIST_SCHEMA, - ) + A decorator that wraps the passed in function, catches Spotify errors, + aiohttp exceptions and handles the availability of the media player. + """ + def wrapper(self, *args, **kwargs): + try: + result = func(self, *args, **kwargs) + self.player_available = True + return result + except (SpotifyException, ClientError): + self.player_available = False -class SpotifyAuthCallbackView(HomeAssistantView): - """Spotify Authorization Callback View.""" - - requires_auth = False - url = AUTH_CALLBACK_PATH - name = AUTH_CALLBACK_NAME - - def __init__(self, config, add_entities, oauth): - """Initialize.""" - self.config = config - self.add_entities = add_entities - self.oauth = oauth - - @callback - def get(self, request): - """Receive authorization token.""" - hass = request.app["hass"] - self.oauth.get_access_token(request.query["code"]) - hass.async_add_job(setup_platform, hass, self.config, self.add_entities) + return wrapper class SpotifyMediaPlayer(MediaPlayerDevice): """Representation of a Spotify controller.""" - def __init__(self, oauth, name, aliases): + def __init__(self, session, spotify: Spotify, me: dict, user_id: str, name: str): """Initialize.""" - self._name = name - self._oauth = oauth - self._album = None - self._title = None - self._artist = None - self._uri = None - self._image_url = None - self._state = None - self._current_device = None - self._devices = {} - self._volume = None - self._shuffle = False - self._player = None - self._user = None - self._aliases = aliases - self._token_info = self._oauth.get_cached_token() - - def refresh_spotify_instance(self): - """Fetch a new spotify instance.""" - - token_refreshed = False - need_token = self._token_info is None or self._oauth.is_token_expired( - self._token_info - ) - if need_token: - new_token = self._oauth.refresh_access_token( - self._token_info["refresh_token"] - ) - # skip when refresh failed - if new_token is None: - return + self._id = user_id + self._me = me + self._name = f"Spotify {name}" + self._session = session + self._spotify = spotify - self._token_info = new_token - token_refreshed = True - if self._player is None or token_refreshed: - self._player = spotipy.Spotify(auth=self._token_info.get("access_token")) - self._user = self._player.me() + self._currently_playing: Optional[dict] = {} + self._devices: Optional[List[dict]] = [] + self._playlist: Optional[dict] = None + self._spotify: Spotify = None - def update(self): - """Update state and attributes.""" - self.refresh_spotify_instance() + self.player_available = False - # Don't true update when token is expired - if self._oauth.is_token_expired(self._token_info): - _LOGGER.warning("Spotify failed to update, token expired.") - return - - # Available devices - player_devices = self._player.devices() - if player_devices is not None: - devices = player_devices.get("devices") - if devices is not None: - old_devices = self._devices - self._devices = { - self._aliases.get(device.get("id"), device.get("name")): device.get( - "id" - ) - for device in devices - } - device_diff = { - name: id - for name, id in self._devices.items() - if old_devices.get(name, None) is None - } - if device_diff: - _LOGGER.info("New Devices: %s", str(device_diff)) - # Current playback state - current = self._player.current_playback() - if current is None: - self._state = STATE_IDLE - return - # Track metadata - item = current.get("item") - if item: - self._album = item.get("album").get("name") - self._title = item.get("name") - self._artist = ", ".join( - [artist.get("name") for artist in item.get("artists")] - ) - self._uri = item.get("uri") - images = item.get("album").get("images") - self._image_url = images[0].get("url") if images else None - # Playing state - self._state = STATE_PAUSED - if current.get("is_playing"): - self._state = STATE_PLAYING - self._shuffle = current.get("shuffle_state") - device = current.get("device") - if device is None: - self._state = STATE_IDLE - else: - if device.get("volume_percent"): - self._volume = device.get("volume_percent") / 100 - if device.get("name"): - self._current_device = device.get("name") + @property + def name(self) -> str: + """Return the name.""" + return self._name - def set_volume_level(self, volume): - """Set the volume level.""" - self._player.volume(int(volume * 100)) + @property + def icon(self) -> str: + """Return the icon.""" + return ICON - def set_shuffle(self, shuffle): - """Enable/Disable shuffle mode.""" - self._player.shuffle(shuffle) + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.player_available - def media_next_track(self): - """Skip to next track.""" - self._player.next_track() + @property + def unique_id(self) -> str: + """Return the unique ID.""" + return self._id - def media_previous_track(self): - """Skip to previous track.""" - self._player.previous_track() + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this entity.""" + if self._me is not None: + model = self._me["product"] + + return { + "identifiers": {(DOMAIN, self._id)}, + "manufacturer": "Spotify AB", + "model": f"Spotify {model}".rstrip(), + "name": self._name, + } - def media_play(self): - """Start or resume playback.""" - self._player.start_playback() + @property + def state(self) -> Optional[str]: + """Return the playback state.""" + if not self._currently_playing: + return STATE_IDLE + if self._currently_playing["is_playing"]: + return STATE_PLAYING + return STATE_PAUSED - def media_pause(self): - """Pause playback.""" - self._player.pause_playback() + @property + def volume_level(self) -> Optional[float]: + """Return the device volume.""" + return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100 - def select_source(self, source): - """Select playback device.""" - if self._devices: - self._player.transfer_playback( - self._devices[source], self._state == STATE_PLAYING - ) + @property + def media_content_id(self) -> Optional[str]: + """Return the media URL.""" + item = self._currently_playing.get("item") or {} + return item.get("name") - def play_media(self, media_type, media_id, **kwargs): - """Play media.""" - kwargs = {} - if media_type == MEDIA_TYPE_MUSIC: - kwargs["uris"] = [media_id] - elif media_type == MEDIA_TYPE_PLAYLIST: - kwargs["context_uri"] = media_id - else: - _LOGGER.error("media type %s is not supported", media_type) - return - if not media_id.startswith("spotify:"): - _LOGGER.error("media id must be spotify uri") - return - self._player.start_playback(**kwargs) + @property + def media_content_type(self) -> Optional[str]: + """Return the media type.""" + return MEDIA_TYPE_MUSIC - def play_playlist(self, media_id, random_song): - """Play random music in a playlist.""" - if not media_id.startswith("spotify:"): - _LOGGER.error("media id must be spotify playlist uri") - return - kwargs = {"context_uri": media_id} - if random_song: - results = self._player.user_playlist_tracks("me", media_id) - position = random.randint(0, results["total"] - 1) - kwargs["offset"] = {"position": position} - self._player.start_playback(**kwargs) + @property + def media_duration(self) -> Optional[int]: + """Duration of current playing media in seconds.""" + if self._currently_playing.get("item") is None: + return None + return self._currently_playing["item"]["duration_ms"] / 1000 @property - def name(self): - """Return the name.""" - return self._name + def media_position(self) -> Optional[str]: + """Position of current playing media in seconds.""" + if not self._currently_playing: + return None + return self._currently_playing["progress_ms"] / 1000 @property - def icon(self): - """Return the icon.""" - return ICON + def media_position_updated_at(self) -> Optional[dt.datetime]: + """When was the position of the current playing media valid.""" + if not self._currently_playing: + return None + return utc_from_timestamp(self._currently_playing["timestamp"] / 1000) @property - def state(self): - """Return the playback state.""" - return self._state + def media_image_url(self) -> Optional[str]: + """Return the media image URL.""" + if ( + self._currently_playing.get("item") is None + or not self._currently_playing["item"]["album"]["images"] + ): + return None + return self._currently_playing["item"]["album"]["images"][0]["url"] @property - def volume_level(self): - """Return the device volume.""" - return self._volume + def media_image_remotely_accessible(self) -> bool: + """If the image url is remotely accessible.""" + return False @property - def shuffle(self): - """Shuffling state.""" - return self._shuffle + def media_title(self) -> Optional[str]: + """Return the media title.""" + item = self._currently_playing.get("item") or {} + return item.get("name") @property - def source_list(self): - """Return a list of source devices.""" - if self._devices: - return list(self._devices.keys()) + def media_artist(self) -> Optional[str]: + """Return the media artist.""" + if self._currently_playing.get("item") is None: + return None + return ", ".join( + [artist["name"] for artist in self._currently_playing["item"]["artists"]] + ) @property - def source(self): - """Return the current playback device.""" - return self._current_device + def media_album_name(self) -> Optional[str]: + """Return the media album.""" + if self._currently_playing.get("item") is None: + return None + return self._currently_playing["item"]["album"]["name"] @property - def media_content_id(self): - """Return the media URL.""" - return self._uri + def media_track(self) -> Optional[int]: + """Track number of current playing media, music track only.""" + item = self._currently_playing.get("item") or {} + return item.get("track_number") @property - def media_image_url(self): - """Return the media image URL.""" - return self._image_url + def media_playlist(self): + """Title of Playlist currently playing.""" + if self._playlist is None: + return None + return self._playlist["name"] @property - def media_artist(self): - """Return the media artist.""" - return self._artist + def source(self) -> Optional[str]: + """Return the current playback device.""" + return self._currently_playing.get("device", {}).get("name") @property - def media_album_name(self): - """Return the media album.""" - return self._album + def source_list(self) -> Optional[List[str]]: + """Return a list of source devices.""" + if not self._devices: + return None + return [device["name"] for device in self._devices] @property - def media_title(self): - """Return the media title.""" - return self._title + def shuffle(self) -> bool: + """Shuffling state.""" + return bool(self._currently_playing.get("shuffle_state")) @property - def supported_features(self): + def supported_features(self) -> int: """Return the media player features that are supported.""" - if self._user is not None and self._user["product"] == "premium": - return SUPPORT_SPOTIFY - return None + if self._me["product"] != "premium": + return 0 + return SUPPORT_SPOTIFY - @property - def media_content_type(self): - """Return the media type.""" - return MEDIA_TYPE_MUSIC + @spotify_exception_handler + def set_volume_level(self, volume: int) -> None: + """Set the volume level.""" + self._spotify.volume(int(volume * 100)) + + @spotify_exception_handler + def media_play(self) -> None: + """Start or resume playback.""" + self._spotify.start_playback() + + @spotify_exception_handler + def media_pause(self) -> None: + """Pause playback.""" + self._spotify.pause_playback() + + @spotify_exception_handler + def media_previous_track(self) -> None: + """Skip to previous track.""" + self._spotify.previous_track() + + @spotify_exception_handler + def media_next_track(self) -> None: + """Skip to next track.""" + self._spotify.next_track() + + @spotify_exception_handler + def media_seek(self, position): + """Send seek command.""" + self._spotify.seek_track(int(position * 1000)) + + @spotify_exception_handler + def play_media(self, media_type: str, media_id: str, **kwargs) -> None: + """Play media.""" + kwargs = {} + + if media_type == MEDIA_TYPE_MUSIC: + kwargs["uris"] = [media_id] + elif media_type == MEDIA_TYPE_PLAYLIST: + kwargs["context_uri"] = media_id + else: + _LOGGER.error("Media type %s is not supported", media_type) + return + + self._spotify.start_playback(**kwargs) + + @spotify_exception_handler + def select_source(self, source: str) -> None: + """Select playback device.""" + for device in self._devices: + if device["name"] == source: + self._spotify.transfer_playback( + device["id"], self.state == STATE_PLAYING + ) + return + + @spotify_exception_handler + def set_shuffle(self, shuffle: bool) -> None: + """Enable/Disable shuffle mode.""" + self._spotify.shuffle(shuffle) + + @spotify_exception_handler + def update(self) -> None: + """Update state and attributes.""" + if not self.enabled: + return + + if not self._session.valid_token or self._spotify is None: + run_coroutine_threadsafe( + self._session.async_ensure_token_valid(), self.hass.loop + ).result() + self._spotify = Spotify(auth=self._session.token["access_token"]) + + current = self._spotify.current_playback() + self._currently_playing = current or {} + + self._playlist = None + context = self._currently_playing.get("context") + if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST: + self._playlist = self._spotify.playlist(current["context"]["uri"]) + + devices = self._spotify.devices() or {} + self._devices = devices.get("devices", []) diff --git a/homeassistant/components/spotify/services.yaml b/homeassistant/components/spotify/services.yaml deleted file mode 100644 index e532f7366527f2..00000000000000 --- a/homeassistant/components/spotify/services.yaml +++ /dev/null @@ -1,9 +0,0 @@ -play_playlist: - description: Play a Spotify playlist. - fields: - media_content_id: - description: Spotify URI of the playlist. - example: 'spotify:playlist:0IpRnqCHSjun48oQRX1Dy7' - random_song: - description: True to select random song at start, False to start from beginning. - example: true \ No newline at end of file diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json new file mode 100644 index 00000000000000..316fbd946dbf86 --- /dev/null +++ b/homeassistant/components/spotify/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "abort": { + "already_setup": "You can only configure one Spotify account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Spotify integration is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Spotify." + }, + "title": "Spotify" + } +} diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 94c497e4db6f97..0610d4d9cf283e 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -281,12 +281,9 @@ def state(self): return STATE_IDLE return None - def async_query(self, *parameters): - """Send a command to the LMS. - - This method must be run in the event loop and returns a coroutine. - """ - return self._lms.async_query(*parameters, player=self._id) + async def async_query(self, *parameters): + """Send a command to the LMS.""" + return await self._lms.async_query(*parameters, player=self._id) async def async_update(self): """Retrieve the current state of the player.""" @@ -420,121 +417,85 @@ def supported_features(self): """Flag media player features that are supported.""" return SUPPORT_SQUEEZEBOX - def async_turn_off(self): - """Turn off media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("power", "0") - - def async_volume_up(self): - """Volume up media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("mixer", "volume", "+5") - - def async_volume_down(self): - """Volume down media player. + async def async_turn_off(self): + """Turn off media player.""" + await self.async_query("power", "0") - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("mixer", "volume", "-5") + async def async_volume_up(self): + """Volume up media player.""" + await self.async_query("mixer", "volume", "+5") - def async_set_volume_level(self, volume): - """Set volume level, range 0..1. + async def async_volume_down(self): + """Volume down media player.""" + await self.async_query("mixer", "volume", "-5") - This method must be run in the event loop and returns a coroutine. - """ + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" volume_percent = str(int(volume * 100)) - return self.async_query("mixer", "volume", volume_percent) - - def async_mute_volume(self, mute): - """Mute (true) or unmute (false) media player. + await self.async_query("mixer", "volume", volume_percent) - This method must be run in the event loop and returns a coroutine. - """ + async def async_mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" mute_numeric = "1" if mute else "0" - return self.async_query("mixer", "muting", mute_numeric) + await self.async_query("mixer", "muting", mute_numeric) - def async_media_play_pause(self): - """Send pause command to media player. + async def async_media_play_pause(self): + """Send pause command to media player.""" + await self.async_query("pause") - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("pause") + async def async_media_play(self): + """Send play command to media player.""" + await self.async_query("play") - def async_media_play(self): - """Send play command to media player. + async def async_media_pause(self): + """Send pause command to media player.""" + await self.async_query("pause", "1") - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("play") + async def async_media_next_track(self): + """Send next track command.""" + await self.async_query("playlist", "index", "+1") - def async_media_pause(self): - """Send pause command to media player. + async def async_media_previous_track(self): + """Send next track command.""" + await self.async_query("playlist", "index", "-1") - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("pause", "1") - - def async_media_next_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("playlist", "index", "+1") - - def async_media_previous_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("playlist", "index", "-1") + async def async_media_seek(self, position): + """Send seek command.""" + await self.async_query("time", position) - def async_media_seek(self, position): - """Send seek command. + async def async_turn_on(self): + """Turn the media player on.""" + await self.async_query("power", "1") - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("time", position) - - def async_turn_on(self): - """Turn the media player on. - - This method must be run in the event loop and returns a coroutine. - """ - return self.async_query("power", "1") - - def async_play_media(self, media_type, media_id, **kwargs): + async def async_play_media(self, media_type, media_id, **kwargs): """ Send the play_media command to the media player. If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist. - This method must be run in the event loop and returns a coroutine. """ if kwargs.get(ATTR_MEDIA_ENQUEUE): - return self._add_uri_to_playlist(media_id) + await self._add_uri_to_playlist(media_id) + return - return self._play_uri(media_id) + await self._play_uri(media_id) - def _play_uri(self, media_id): + async def _play_uri(self, media_id): """Replace the current play list with the uri.""" - return self.async_query("playlist", "play", media_id) + await self.async_query("playlist", "play", media_id) - def _add_uri_to_playlist(self, media_id): + async def _add_uri_to_playlist(self, media_id): """Add an item to the existing playlist.""" - return self.async_query("playlist", "add", media_id) + await self.async_query("playlist", "add", media_id) - def async_set_shuffle(self, shuffle): + async def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - return self.async_query("playlist", "shuffle", int(shuffle)) + await self.async_query("playlist", "shuffle", int(shuffle)) - def async_clear_playlist(self): + async def async_clear_playlist(self): """Send the media player the command for clear playlist.""" - return self.async_query("playlist", "clear") + await self.async_query("playlist", "clear") - def async_call_method(self, command, parameters=None): + async def async_call_method(self, command, parameters=None): """ Call Squeezebox JSON/RPC method. @@ -545,4 +506,4 @@ def async_call_method(self, command, parameters=None): if parameters: for parameter in parameters: all_params.append(parameter) - return self.async_query(*all_params) + await self.async_query(*all_params) diff --git a/homeassistant/components/starline/.translations/hu.json b/homeassistant/components/starline/.translations/hu.json new file mode 100644 index 00000000000000..ccc5b7983d093a --- /dev/null +++ b/homeassistant/components/starline/.translations/hu.json @@ -0,0 +1,42 @@ +{ + "config": { + "error": { + "error_auth_app": "Helytelen alkalmaz\u00e1sazonos\u00edt\u00f3 vagy jelsz\u00f3", + "error_auth_mfa": "Helytelen k\u00f3d", + "error_auth_user": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v vagy jelsz\u00f3" + }, + "step": { + "auth_app": { + "data": { + "app_id": "App ID", + "app_secret": "Titok" + }, + "description": "Alkalmaz\u00e1s azonos\u00edt\u00f3ja \u00e9s titkos k\u00f3dja a StarLine fejleszt\u0151i fi\u00f3kb\u00f3l ", + "title": "Alkalmaz\u00e1si hiteles\u00edt\u0151 adatok" + }, + "auth_captcha": { + "data": { + "captcha_code": "K\u00f3d a k\u00e9pr\u0151l" + }, + "description": "{captcha_img}", + "title": "Captcha" + }, + "auth_mfa": { + "data": { + "mfa_code": "SMS k\u00f3d" + }, + "description": "Adja meg a {phone_number} telefonra k\u00fcld\u00f6tt k\u00f3dot.", + "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s" + }, + "auth_user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "A StarLine fi\u00f3k e-mail c\u00edme \u00e9s jelszava", + "title": "Felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok" + } + }, + "title": "Starline" + } +} \ No newline at end of file diff --git a/homeassistant/components/starline/.translations/sv.json b/homeassistant/components/starline/.translations/sv.json index 42d01b5675377a..83f2300892d83f 100644 --- a/homeassistant/components/starline/.translations/sv.json +++ b/homeassistant/components/starline/.translations/sv.json @@ -1,11 +1,42 @@ { "config": { + "error": { + "error_auth_app": "Fel applikations-id eller hemlighet", + "error_auth_mfa": "Felaktig kod", + "error_auth_user": "Felaktigt anv\u00e4ndarnamn eller l\u00f6senord" + }, "step": { "auth_app": { "data": { + "app_id": "App-ID", "app_secret": "Hemlighet" - } + }, + "description": "Applikations-ID och hemlig kod fr\u00e5n StarLine-utvecklarkonto", + "title": "Autentiseringsuppgifter f\u00f6r applikation" + }, + "auth_captcha": { + "data": { + "captcha_code": "Kod fr\u00e5n bild" + }, + "description": "{captcha_img}", + "title": "Captcha" + }, + "auth_mfa": { + "data": { + "mfa_code": "SMS-kod" + }, + "description": "Ange koden som skickas till telefonen {phone_number}", + "title": "Tv\u00e5faktorautentisering" + }, + "auth_user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + }, + "description": "StarLine-kontots e-postadress och l\u00f6senord", + "title": "Anv\u00e4ndaruppgifter" } - } + }, + "title": "StarLine" } } \ No newline at end of file diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index fa559f6291350d..34415e9dca4087 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -4,7 +4,7 @@ from starline import StarlineAuth import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, core from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import ( # pylint: disable=unused-import @@ -85,6 +85,7 @@ async def async_step_auth_captcha(self, user_input=None, error=None): return await self._async_authenticate_user(error) return self._async_form_auth_captcha(error) + @core.callback def _async_form_auth_app(self, error=None): """Authenticate application form.""" errors = {} @@ -106,6 +107,7 @@ def _async_form_auth_app(self, error=None): errors=errors, ) + @core.callback def _async_form_auth_user(self, error=None): """Authenticate user form.""" errors = {} @@ -127,6 +129,7 @@ def _async_form_auth_user(self, error=None): errors=errors, ) + @core.callback def _async_form_auth_mfa(self, error=None): """Authenticate mfa form.""" errors = {} @@ -146,6 +149,7 @@ def _async_form_auth_mfa(self, error=None): description_placeholders={"phone_number": self._phone_number}, ) + @core.callback def _async_form_auth_captcha(self, error=None): """Captcha verification form.""" errors = {} diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index e07f21e5d60486..82106c2da57d88 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -8,7 +8,12 @@ import xmltodict from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME +from homeassistant.const import ( + CONF_API_KEY, + CONF_MONITORED_VARIABLES, + CONF_NAME, + DATA_GIGABYTES, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -19,7 +24,6 @@ DEFAULT_NAME = "Start.ca" CONF_TOTAL_BANDWIDTH = "total_bandwidth" -GIGABYTES = "GB" PERCENT = "%" MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) @@ -27,17 +31,17 @@ SENSOR_TYPES = { "usage": ["Usage Ratio", PERCENT, "mdi:percent"], - "usage_gb": ["Usage", GIGABYTES, "mdi:download"], - "limit": ["Data limit", GIGABYTES, "mdi:download"], - "used_download": ["Used Download", GIGABYTES, "mdi:download"], - "used_upload": ["Used Upload", GIGABYTES, "mdi:upload"], - "used_total": ["Used Total", GIGABYTES, "mdi:download"], - "grace_download": ["Grace Download", GIGABYTES, "mdi:download"], - "grace_upload": ["Grace Upload", GIGABYTES, "mdi:upload"], - "grace_total": ["Grace Total", GIGABYTES, "mdi:download"], - "total_download": ["Total Download", GIGABYTES, "mdi:download"], - "total_upload": ["Total Upload", GIGABYTES, "mdi:download"], - "used_remaining": ["Remaining", GIGABYTES, "mdi:download"], + "usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"], + "limit": ["Data limit", DATA_GIGABYTES, "mdi:download"], + "used_download": ["Used Download", DATA_GIGABYTES, "mdi:download"], + "used_upload": ["Used Upload", DATA_GIGABYTES, "mdi:upload"], + "used_total": ["Used Total", DATA_GIGABYTES, "mdi:download"], + "grace_download": ["Grace Download", DATA_GIGABYTES, "mdi:download"], + "grace_upload": ["Grace Upload", DATA_GIGABYTES, "mdi:upload"], + "grace_total": ["Grace Total", DATA_GIGABYTES, "mdi:download"], + "total_download": ["Total Download", DATA_GIGABYTES, "mdi:download"], + "total_upload": ["Total Upload", DATA_GIGABYTES, "mdi:download"], + "used_remaining": ["Remaining", DATA_GIGABYTES, "mdi:download"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 865fda93a3ebdf..d85b6b079ae05b 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -294,6 +294,7 @@ def _scheduled_update(now): """Timer callback for sensor update.""" _LOGGER.debug("%s: executing scheduled update", self.entity_id) self.async_schedule_update_ha_state(True) + self._update_listener = None self._update_listener = async_track_point_in_utc_time( self.hass, _scheduled_update, next_to_purge_timestamp diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 99ffd833eb3c4c..6cd07c7f926b2e 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -95,7 +95,7 @@ def stream_worker(hass, stream, quit_event): if packet.is_keyframe: # Calculate the segment duration by multiplying the presentation # timestamp by the time base, which gets us total seconds. - # By then dividing by the seqence, we can calculate how long + # By then dividing by the sequence, we can calculate how long # each segment is, assuming the stream starts from 0. segment_duration = (packet.pts * packet.time_base) / sequence # Save segment to outputs diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 213952bead349c..9529a9c0cadafb 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -77,7 +77,7 @@ async def async_setup(hass, config): if config.get(CONF_ELEVATION) is not None: _LOGGER.warning( "Elevation is now configured in Home Assistant core. " - "See https://home-assistant.io/docs/configuration/basic/" + "See https://www.home-assistant.io/docs/configuration/basic/" ) Sun(hass) return True diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index fd60254cd0adf4..6c9bfb8d16ead2 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -18,8 +18,10 @@ SUPLA_FUNCTION_HA_CMP_MAP = { "CONTROLLINGTHEROLLERSHUTTER": "cover", + "CONTROLLINGTHEGATE": "cover", "LIGHTSWITCH": "switch", } +SUPLA_FUNCTION_NONE = "NONE" SUPLA_CHANNELS = "supla_channels" SUPLA_SERVERS = "supla_servers" @@ -86,6 +88,14 @@ def discover_devices(hass, hass_config): for channel in server.get_channels(include=["iodevice"]): channel_function = channel["function"]["name"] + if channel_function == SUPLA_FUNCTION_NONE: + _LOGGER.debug( + "Ignored function: %s, channel id: %s", + channel_function, + channel["id"], + ) + continue + component_name = SUPLA_FUNCTION_HA_CMP_MAP.get(channel_function) if component_name is None: @@ -130,6 +140,16 @@ def name(self) -> Optional[str]: """Return the name of the device.""" return self.channel_data["caption"] + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.channel_data is None: + return False + state = self.channel_data.get("state") + if state is None: + return False + return state.get("connected") + def action(self, action, **add_pars): """ Run server action. diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 3182aa8c1363e7..659b78cc41a6ee 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -1,12 +1,19 @@ -"""Support for Supla cover - curtains, rollershutters etc.""" +"""Support for Supla cover - curtains, rollershutters, entry gate etc.""" import logging from pprint import pformat -from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_GARAGE, + CoverDevice, +) from homeassistant.components.supla import SuplaChannel _LOGGER = logging.getLogger(__name__) +SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER" +SUPLA_GATE = "CONTROLLINGTHEGATE" + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Supla covers.""" @@ -15,7 +22,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Discovery: %s", pformat(discovery_info)) - add_entities([SuplaCover(device) for device in discovery_info]) + entities = [] + for device in discovery_info: + device_name = device["function"]["name"] + if device_name == SUPLA_SHUTTER: + entities.append(SuplaCover(device)) + elif device_name == SUPLA_GATE: + entities.append(SuplaGateDoor(device)) + add_entities(entities) class SuplaCover(SuplaChannel, CoverDevice): @@ -51,3 +65,38 @@ def close_cover(self, **kwargs): def stop_cover(self, **kwargs): """Stop the cover.""" self.action("STOP") + + +class SuplaGateDoor(SuplaChannel, CoverDevice): + """Representation of a Supla gate door.""" + + @property + def is_closed(self): + """Return if the gate is closed or not.""" + state = self.channel_data.get("state") + if state and "hi" in state: + return state.get("hi") + return None + + def open_cover(self, **kwargs) -> None: + """Open the gate.""" + if self.is_closed: + self.action("OPEN_CLOSE") + + def close_cover(self, **kwargs) -> None: + """Close the gate.""" + if not self.is_closed: + self.action("OPEN_CLOSE") + + def stop_cover(self, **kwargs) -> None: + """Stop the gate.""" + self.action("OPEN_CLOSE") + + def toggle(self, **kwargs) -> None: + """Toggle the gate.""" + self.action("OPEN_CLOSE") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_GARAGE diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 725771e21e8036..556c1b69a5325f 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -1,4 +1,4 @@ -"""Support for Supla cover - curtains, rollershutters etc.""" +"""Support for Supla switch.""" import logging from pprint import pformat diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 450d7eb9a15c8b..a22ba4a1335799 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -1,17 +1,17 @@ """Support for Sure Petcare cat/pet flaps.""" import logging +from typing import Any, Dict, List from surepy import ( SurePetcare, SurePetcareAuthenticationError, SurePetcareError, - SureThingID, + SureProductID, ) import voluptuous as vol from homeassistant.const import ( CONF_ID, - CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TYPE, @@ -23,9 +23,11 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( + CONF_FEEDERS, CONF_FLAPS, - CONF_HOUSEHOLD_ID, + CONF_PARENT, CONF_PETS, + CONF_PRODUCT_ID, DATA_SURE_PETCARE, DEFAULT_SCAN_INTERVAL, DOMAIN, @@ -36,23 +38,19 @@ _LOGGER = logging.getLogger(__name__) -FLAP_SCHEMA = vol.Schema( - {vol.Required(CONF_ID): cv.positive_int, vol.Required(CONF_NAME): cv.string} -) - -PET_SCHEMA = vol.Schema( - {vol.Required(CONF_ID): cv.positive_int, vol.Required(CONF_NAME): cv.string} -) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_HOUSEHOLD_ID): cv.positive_int, - vol.Required(CONF_FLAPS): vol.All(cv.ensure_list, [FLAP_SCHEMA]), - vol.Required(CONF_PETS): vol.All(cv.ensure_list, [PET_SCHEMA]), + vol.Optional(CONF_FEEDERS, default=[]): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_FLAPS, default=[]): vol.All( + cv.ensure_list, [cv.positive_int] + ), + vol.Optional(CONF_PETS): vol.All(cv.ensure_list, [cv.positive_int]), vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.time_period, @@ -63,7 +61,7 @@ ) -async def async_setup(hass, config): +async def async_setup(hass, config) -> bool: """Initialize the Sure Petcare component.""" conf = config[DOMAIN] @@ -78,11 +76,10 @@ async def async_setup(hass, config): surepy = SurePetcare( conf[CONF_USERNAME], conf[CONF_PASSWORD], - conf[CONF_HOUSEHOLD_ID], hass.loop, async_get_clientsession(hass), ) - await surepy.refresh_token() + await surepy.get_data() except SurePetcareAuthenticationError: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") return False @@ -90,32 +87,44 @@ async def async_setup(hass, config): _LOGGER.error("Unable to connect to surepetcare.io: Wrong %s!", error) return False - # add flaps + # add feeders things = [ - { - CONF_NAME: flap[CONF_NAME], - CONF_ID: flap[CONF_ID], - CONF_TYPE: SureThingID.FLAP.name, - } - for flap in conf[CONF_FLAPS] + {CONF_ID: feeder, CONF_TYPE: SureProductID.FEEDER} + for feeder in conf[CONF_FEEDERS] ] - # add pets + # add flaps (don't differentiate between CAT and PET for now) things.extend( [ - { - CONF_NAME: pet[CONF_NAME], - CONF_ID: pet[CONF_ID], - CONF_TYPE: SureThingID.PET.name, - } - for pet in conf[CONF_PETS] + {CONF_ID: flap, CONF_TYPE: SureProductID.PET_FLAP} + for flap in conf[CONF_FLAPS] ] ) - spc = hass.data[DATA_SURE_PETCARE][SPC] = SurePetcareAPI( - hass, surepy, things, conf[CONF_HOUSEHOLD_ID] + # discover hubs the flaps/feeders are connected to + for device in things.copy(): + device_data = await surepy.device(device[CONF_ID]) + if ( + CONF_PARENT in device_data + and device_data[CONF_PARENT][CONF_PRODUCT_ID] == SureProductID.HUB + and device_data[CONF_PARENT][CONF_ID] not in things + ): + things.append( + { + CONF_ID: device_data[CONF_PARENT][CONF_ID], + CONF_TYPE: SureProductID.HUB, + } + ) + + # add pets + things.extend( + [{CONF_ID: pet, CONF_TYPE: SureProductID.PET} for pet in conf[CONF_PETS]] ) + _LOGGER.debug("Devices and Pets to setup: %s", things) + + spc = hass.data[DATA_SURE_PETCARE][SPC] = SurePetcareAPI(hass, surepy, things) + # initial update await spc.async_update() @@ -135,16 +144,18 @@ async def async_setup(hass, config): class SurePetcareAPI: """Define a generic Sure Petcare object.""" - def __init__(self, hass, surepy, ids, household_id): + def __init__(self, hass, surepy: SurePetcare, ids: List[Dict[str, Any]]) -> None: """Initialize the Sure Petcare object.""" self.hass = hass self.surepy = surepy - self.household_id = household_id self.ids = ids - self.states = {} + self.states: Dict[str, Any] = {} - async def async_update(self, args=None): + async def async_update(self, arg: Any = None) -> None: """Refresh Sure Petcare data.""" + + await self.surepy.get_data() + for thing in self.ids: sure_id = thing[CONF_ID] sure_type = thing[CONF_TYPE] @@ -152,10 +163,15 @@ async def async_update(self, args=None): try: type_state = self.states.setdefault(sure_type, {}) - if sure_type == SureThingID.FLAP.name: - type_state[sure_id] = await self.surepy.get_flap_data(sure_id) - elif sure_type == SureThingID.PET.name: - type_state[sure_id] = await self.surepy.get_pet_data(sure_id) + if sure_type in [ + SureProductID.CAT_FLAP, + SureProductID.PET_FLAP, + SureProductID.FEEDER, + SureProductID.HUB, + ]: + type_state[sure_id] = await self.surepy.device(sure_id) + elif sure_type == SureProductID.PET: + type_state[sure_id] = await self.surepy.pet(sure_id) except SurePetcareError as error: _LOGGER.error("Unable to retrieve data from surepetcare.io: %s", error) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 100da5cb790455..5b3ac492137d3a 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -1,23 +1,28 @@ """Support for Sure PetCare Flaps/Pets binary sensors.""" +from datetime import datetime import logging +from typing import Any, Dict, Optional -from surepy import SureLocationID, SureLockStateID, SureThingID +from surepy import SureLocationID, SureProductID from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_LOCK, + DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_PRESENCE, BinarySensorDevice, ) -from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ID, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DATA_SURE_PETCARE, DEFAULT_DEVICE_CLASS, SPC, TOPIC_UPDATE +from . import SurePetcareAPI +from .const import DATA_SURE_PETCARE, SPC, TOPIC_UPDATE _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None +) -> None: """Set up Sure PetCare Flaps sensors based on a config entry.""" if discovery_info is None: return @@ -30,10 +35,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sure_id = thing[CONF_ID] sure_type = thing[CONF_TYPE] - if sure_type == SureThingID.FLAP.name: - entity = Flap(sure_id, thing[CONF_NAME], spc) - elif sure_type == SureThingID.PET.name: - entity = Pet(sure_id, thing[CONF_NAME], spc) + # connectivity + if sure_type in [ + SureProductID.CAT_FLAP, + SureProductID.PET_FLAP, + SureProductID.FEEDER, + ]: + entities.append(DeviceConnectivity(sure_id, sure_type, spc)) + + if sure_type == SureProductID.PET: + entity = Pet(sure_id, spc) + elif sure_type == SureProductID.HUB: + entity = Hub(sure_id, spc) + else: + continue entities.append(entity) @@ -44,57 +59,67 @@ class SurePetcareBinarySensor(BinarySensorDevice): """A binary sensor implementation for Sure Petcare Entities.""" def __init__( - self, _id: int, name: str, spc, device_class: str, sure_type: SureThingID + self, + _id: int, + spc: SurePetcareAPI, + device_class: str, + sure_type: SureProductID, ): """Initialize a Sure Petcare binary sensor.""" self._id = _id - self._name = name - self._spc = spc - self._device_class = device_class self._sure_type = sure_type - self._state = {} + self._device_class = device_class + + self._spc: SurePetcareAPI = spc + self._spc_data: Dict[str, Any] = self._spc.states[self._sure_type].get(self._id) + self._state: Dict[str, Any] = {} + + # cover special case where a device has no name set + if "name" in self._spc_data: + name = self._spc_data["name"] + else: + name = f"Unnamed {self._sure_type.name.capitalize()}" + + self._name = f"{self._sure_type.name.capitalize()} {name.capitalize()}" self._async_unsub_dispatcher_connect = None @property - def is_on(self): + def is_on(self) -> Optional[bool]: """Return true if entity is on/unlocked.""" return bool(self._state) @property - def should_poll(self): + def should_poll(self) -> bool: """Return true.""" return False @property - def name(self): + def name(self) -> str: """Return the name of the device if any.""" return self._name @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - return self._state - - @property - def device_class(self): + def device_class(self) -> str: """Return the device class.""" - return DEFAULT_DEVICE_CLASS if not self._device_class else self._device_class + return None if not self._device_class else self._device_class @property - def unique_id(self): + def unique_id(self: BinarySensorDevice) -> str: """Return an unique ID.""" - return f"{self._spc.household_id}-{self._id}" + return f"{self._spc_data['household_id']}-{self._id}" - async def async_update(self): + async def async_update(self) -> None: """Get the latest data and update the state.""" - self._state = self._spc.states[self._sure_type][self._id].get("data") + self._spc_data = self._spc.states[self._sure_type].get(self._id) + self._state = self._spc_data.get("status") + _LOGGER.debug("%s -> self._state: %s", self._name, self._state) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" self.async_schedule_update_ha_state(True) @@ -102,54 +127,38 @@ def update(): self.hass, TOPIC_UPDATE, update ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" if self._async_unsub_dispatcher_connect: self._async_unsub_dispatcher_connect() -class Flap(SurePetcareBinarySensor): - """Sure Petcare Flap.""" +class Hub(SurePetcareBinarySensor): + """Sure Petcare Pet.""" - def __init__(self, _id: int, name: str, spc): - """Initialize a Sure Petcare Flap.""" - super().__init__( - _id, - f"Flap {name.capitalize()}", - spc, - DEVICE_CLASS_LOCK, - SureThingID.FLAP.name, - ) + def __init__(self, _id: int, spc: SurePetcareAPI) -> None: + """Initialize a Sure Petcare Hub.""" + super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY, SureProductID.HUB) @property - def is_on(self): - """Return true if entity is on/unlocked.""" - try: - return bool(self._state["locking"]["mode"] == SureLockStateID.UNLOCKED) - except (KeyError, TypeError): - return None + def available(self) -> bool: + """Return true if entity is available.""" + return bool(self._state["online"]) + + @property + def is_on(self) -> bool: + """Return true if entity is online.""" + return self.available @property - def device_state_attributes(self): + def device_state_attributes(self) -> Optional[Dict[str, Any]]: """Return the state attributes of the device.""" attributes = None if self._state: - try: - attributes = { - "battery_voltage": self._state["battery"] / 4, - "locking_mode": self._state["locking"]["mode"], - "device_rssi": self._state["signal"]["device_rssi"], - "hub_rssi": self._state["signal"]["hub_rssi"], - } - - except (KeyError, TypeError) as error: - _LOGGER.error( - "Error getting device state attributes from %s: %s\n\n%s", - self._name, - error, - self._state, - ) - attributes = self._state + attributes = { + "led_mode": int(self._state["led_mode"]), + "pairing_mode": bool(self._state["pairing_mode"]), + } return attributes @@ -157,20 +166,76 @@ def device_state_attributes(self): class Pet(SurePetcareBinarySensor): """Sure Petcare Pet.""" - def __init__(self, _id: int, name: str, spc): + def __init__(self, _id: int, spc: SurePetcareAPI) -> None: """Initialize a Sure Petcare Pet.""" - super().__init__( - _id, - f"Pet {name.capitalize()}", - spc, - DEVICE_CLASS_PRESENCE, - SureThingID.PET.name, - ) + super().__init__(_id, spc, DEVICE_CLASS_PRESENCE, SureProductID.PET) @property - def is_on(self): + def is_on(self) -> bool: """Return true if entity is at home.""" try: - return bool(self._state["where"] == SureLocationID.INSIDE) + return bool(SureLocationID(self._state["where"]) == SureLocationID.INSIDE) except (KeyError, TypeError): return False + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the device.""" + attributes = None + if self._state: + attributes = { + "since": str( + datetime.fromisoformat(self._state["since"]).replace(tzinfo=None) + ), + "where": SureLocationID(self._state["where"]).name.capitalize(), + } + + return attributes + + async def async_update(self) -> None: + """Get the latest data and update the state.""" + self._spc_data = self._spc.states[self._sure_type].get(self._id) + self._state = self._spc_data.get("position") + _LOGGER.debug("%s -> self._state: %s", self._name, self._state) + + +class DeviceConnectivity(SurePetcareBinarySensor): + """Sure Petcare Pet.""" + + def __init__( + self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI, + ) -> None: + """Initialize a Sure Petcare Device.""" + super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY, sure_type) + + @property + def name(self) -> str: + """Return the name of the device if any.""" + return f"{self._name}_connectivity" + + @property + def unique_id(self: BinarySensorDevice) -> str: + """Return an unique ID.""" + return f"{self._spc_data['household_id']}-{self._id}-connectivity" + + @property + def available(self) -> bool: + """Return true if entity is available.""" + return bool(self._state) + + @property + def is_on(self) -> bool: + """Return true if entity is online.""" + return self.available + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the device.""" + attributes = None + if self._state: + attributes = { + "device_rssi": f'{self._state["signal"]["device_rssi"]:.2f}', + "hub_rssi": f'{self._state["signal"]["hub_rssi"]:.2f}', + } + + return attributes diff --git a/homeassistant/components/surepetcare/const.py b/homeassistant/components/surepetcare/const.py index 731bfba07e6934..d534398784fe4f 100644 --- a/homeassistant/components/surepetcare/const.py +++ b/homeassistant/components/surepetcare/const.py @@ -11,8 +11,11 @@ SUREPY = "surepy" CONF_HOUSEHOLD_ID = "household_id" +CONF_FEEDERS = "feeders" CONF_FLAPS = "flaps" +CONF_PARENT = "parent" CONF_PETS = "pets" +CONF_PRODUCT_ID = "product_id" CONF_DATA = "data" SURE_IDS = "sure_ids" diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index b4879932714eb5..b1efa4ce639b1a 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/surepetcare", "dependencies": [], "codeowners": ["@benleb"], - "requirements": ["surepy==0.1.10"] + "requirements": ["surepy==0.2.3"] } diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index dd7fdcb0316125..8dc9cf30e3ceb8 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -1,19 +1,15 @@ """Support for Sure PetCare Flaps/Pets sensors.""" import logging +from typing import Any, Dict, Optional -from surepy import SureThingID +from surepy import SureLockStateID, SureProductID -from homeassistant.const import ( - ATTR_VOLTAGE, - CONF_ID, - CONF_NAME, - CONF_TYPE, - DEVICE_CLASS_BATTERY, -) +from homeassistant.const import ATTR_VOLTAGE, CONF_ID, CONF_TYPE, DEVICE_CLASS_BATTERY from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from . import SurePetcareAPI from .const import ( DATA_SURE_PETCARE, SPC, @@ -30,105 +26,160 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if discovery_info is None: return + entities = [] + spc = hass.data[DATA_SURE_PETCARE][SPC] - async_add_entities( - [ - FlapBattery(entity[CONF_ID], entity[CONF_NAME], spc) - for entity in spc.ids - if entity[CONF_TYPE] == SureThingID.FLAP.name - ], - True, - ) + for entity in spc.ids: + sure_type = entity[CONF_TYPE] -class FlapBattery(Entity): - """Sure Petcare Flap.""" + if sure_type in [ + SureProductID.CAT_FLAP, + SureProductID.PET_FLAP, + SureProductID.FEEDER, + ]: + entities.append(SureBattery(entity[CONF_ID], sure_type, spc)) + + if sure_type in [ + SureProductID.CAT_FLAP, + SureProductID.PET_FLAP, + ]: + entities.append(Flap(entity[CONF_ID], sure_type, spc)) + + async_add_entities(entities, True) + + +class SurePetcareSensor(Entity): + """A binary sensor implementation for Sure Petcare Entities.""" + + def __init__( + self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI, + ): + """Initialize a Sure Petcare sensor.""" - def __init__(self, _id: int, name: str, spc): - """Initialize a Sure Petcare Flap battery sensor.""" self._id = _id - self._name = f"Flap {name.capitalize()} Battery Level" + self._sure_type = sure_type + self._spc = spc - self._state = self._spc.states[SureThingID.FLAP.name][self._id].get("data") + self._spc_data: Dict[str, Any] = self._spc.states[self._sure_type].get(self._id) + self._state: Dict[str, Any] = {} + + self._name = ( + f"{self._sure_type.name.capitalize()} " + f"{self._spc_data['name'].capitalize()}" + ) self._async_unsub_dispatcher_connect = None @property - def should_poll(self): + def name(self) -> str: + """Return the name of the device if any.""" + return self._name + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return f"{self._spc_data['household_id']}-{self._id}" + + @property + def available(self) -> bool: + """Return true if entity is available.""" + return bool(self._state) + + @property + def should_poll(self) -> bool: """Return true.""" return False + async def async_update(self) -> None: + """Get the latest data and update the state.""" + self._spc_data = self._spc.states[self._sure_type].get(self._id) + self._state = self._spc_data.get("status") + _LOGGER.debug("%s -> self._state: %s", self._name, self._state) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + +class Flap(SurePetcareSensor): + """Sure Petcare Flap.""" + + @property + def state(self) -> Optional[int]: + """Return battery level in percent.""" + return SureLockStateID(self._state["locking"]["mode"]).name.capitalize() + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the device.""" + attributes = None + if self._state: + attributes = { + "learn_mode": bool(self._state["learn_mode"]), + } + + return attributes + + +class SureBattery(SurePetcareSensor): + """Sure Petcare Flap.""" + @property - def name(self): + def name(self) -> str: """Return the name of the device if any.""" - return self._name + return f"{self._name} Battery Level" @property - def state(self): + def state(self) -> Optional[int]: """Return battery level in percent.""" + battery_percent: Optional[int] try: per_battery_voltage = self._state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW - battery_percent = int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100) + battery_percent = min(int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100) except (KeyError, TypeError): battery_percent = None return battery_percent @property - def unique_id(self): + def unique_id(self) -> str: """Return an unique ID.""" - return f"{self._spc.household_id}-{self._id}" + return f"{self._spc_data['household_id']}-{self._id}-battery" @property - def device_class(self): + def device_class(self) -> str: """Return the device class.""" return DEVICE_CLASS_BATTERY @property - def device_state_attributes(self): + def device_state_attributes(self) -> Optional[Dict[str, Any]]: """Return state attributes.""" attributes = None if self._state: - try: - voltage_per_battery = float(self._state["battery"]) / 4 - attributes = { - ATTR_VOLTAGE: f"{float(self._state['battery']):.2f}", - f"{ATTR_VOLTAGE}_per_battery": f"{voltage_per_battery:.2f}", - } - except (KeyError, TypeError) as error: - attributes = self._state - _LOGGER.error( - "Error getting device state attributes from %s: %s\n\n%s", - self._name, - error, - self._state, - ) + voltage_per_battery = float(self._state["battery"]) / 4 + attributes = { + ATTR_VOLTAGE: f"{float(self._state['battery']):.2f}", + f"{ATTR_VOLTAGE}_per_battery": f"{voltage_per_battery:.2f}", + } return attributes @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "%" - - async def async_update(self): - """Get the latest data and update the state.""" - self._state = self._spc.states[SureThingID.FLAP.name][self._id].get("data") - - 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_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/switch/.translations/sv.json b/homeassistant/components/switch/.translations/sv.json new file mode 100644 index 00000000000000..3ec36265e52c25 --- /dev/null +++ b/homeassistant/components/switch/.translations/sv.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "V\u00e4xla {entity_name}", + "turn_off": "St\u00e4ng av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e4r avst\u00e4ngd", + "is_on": "{entity_name} \u00e4r p\u00e5", + "turn_off": "{entity_name} st\u00e4ngdes av", + "turn_on": "{entity_name} slogs p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} st\u00e4ngdes av", + "turned_on": "{entity_name} slogs p\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/zh-Hant.json b/homeassistant/components/switch/.translations/zh-Hant.json index 517d48354dcc2d..3eaac8404975fb 100644 --- a/homeassistant/components/switch/.translations/zh-Hant.json +++ b/homeassistant/components/switch/.translations/zh-Hant.json @@ -1,19 +1,19 @@ { "device_automation": { "action_type": { - "toggle": "\u5207\u63db {entity_name}", - "turn_off": "\u95dc\u9589 {entity_name}", - "turn_on": "\u958b\u555f {entity_name}" + "toggle": "\u5207\u63db{entity_name}", + "turn_off": "\u95dc\u9589{entity_name}", + "turn_on": "\u958b\u555f{entity_name}" }, "condition_type": { - "is_off": "{entity_name} \u5df2\u95dc\u9589", - "is_on": "{entity_name} \u5df2\u958b\u555f", - "turn_off": "{entity_name} \u5df2\u95dc\u9589", - "turn_on": "{entity_name} \u5df2\u958b\u555f" + "is_off": "{entity_name}\u5df2\u95dc\u9589", + "is_on": "{entity_name}\u5df2\u958b\u555f", + "turn_off": "{entity_name}\u5df2\u95dc\u9589", + "turn_on": "{entity_name}\u5df2\u958b\u555f" }, "trigger_type": { - "turned_off": "{entity_name} \u5df2\u95dc\u9589", - "turned_on": "{entity_name} \u5df2\u958b\u555f" + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" } } } \ No newline at end of file diff --git a/homeassistant/components/switch/device_condition.py b/homeassistant/components/switch/device_condition.py index 87aefdb616d5a0..c928deef01abe6 100644 --- a/homeassistant/components/switch/device_condition.py +++ b/homeassistant/components/switch/device_condition.py @@ -5,7 +5,7 @@ from homeassistant.components.device_automation import toggle_entity from homeassistant.const import CONF_DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.condition import ConditionCheckerType from homeassistant.helpers.typing import ConfigType @@ -16,6 +16,7 @@ ) +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> ConditionCheckerType: diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index c8eaddcb5bd7aa..8c9c9e1a6fa377 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -124,17 +124,11 @@ async def async_update_data(self, device_data: "SwitcherV2Device") -> None: 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. - """ + """Turn the entity on.""" 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. - """ + """Turn the entity off.""" await self._control_device(False) async def _control_device(self, send_on: bool) -> None: diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 36306efa93e244..577a01c5148c4a 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -1,8 +1,4 @@ -"""Device tracker for Synology SRM routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.synology_srm/ -""" +"""Device tracker for Synology SRM routers.""" import logging import synology_srm diff --git a/homeassistant/components/synologydsm/manifest.json b/homeassistant/components/synologydsm/manifest.json index d9405b3ee68088..586fe75c6979e9 100644 --- a/homeassistant/components/synologydsm/manifest.json +++ b/homeassistant/components/synologydsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synologydsm", "name": "SynologyDSM", "documentation": "https://www.home-assistant.io/integrations/synologydsm", - "requirements": ["python-synology==0.3.0"], + "requirements": ["python-synology==0.4.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py index 3f459af9887246..d10ecaa15edb6a 100644 --- a/homeassistant/components/synologydsm/sensor.py +++ b/homeassistant/components/synologydsm/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, + CONF_API_VERSION, CONF_DISKS, CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -16,6 +17,8 @@ CONF_PORT, CONF_SSL, CONF_USERNAME, + DATA_MEGABYTES, + DATA_RATE_KILOBYTES_PER_SECOND, EVENT_HOMEASSISTANT_START, TEMP_CELSIUS, ) @@ -42,14 +45,14 @@ "cpu_5min_load": ["CPU Load (5 min)", "%", "mdi:chip"], "cpu_15min_load": ["CPU Load (15 min)", "%", "mdi:chip"], "memory_real_usage": ["Memory Usage (Real)", "%", "mdi:memory"], - "memory_size": ["Memory Size", "Mb", "mdi:memory"], - "memory_cached": ["Memory Cached", "Mb", "mdi:memory"], - "memory_available_swap": ["Memory Available (Swap)", "Mb", "mdi:memory"], - "memory_available_real": ["Memory Available (Real)", "Mb", "mdi:memory"], - "memory_total_swap": ["Memory Total (Swap)", "Mb", "mdi:memory"], - "memory_total_real": ["Memory Total (Real)", "Mb", "mdi:memory"], - "network_up": ["Network Up", "Kbps", "mdi:upload"], - "network_down": ["Network Down", "Kbps", "mdi:download"], + "memory_size": ["Memory Size", DATA_MEGABYTES, "mdi:memory"], + "memory_cached": ["Memory Cached", DATA_MEGABYTES, "mdi:memory"], + "memory_available_swap": ["Memory Available (Swap)", DATA_MEGABYTES, "mdi:memory"], + "memory_available_real": ["Memory Available (Real)", DATA_MEGABYTES, "mdi:memory"], + "memory_total_swap": ["Memory Total (Swap)", DATA_MEGABYTES, "mdi:memory"], + "memory_total_real": ["Memory Total (Real)", DATA_MEGABYTES, "mdi:memory"], + "network_up": ["Network Up", DATA_RATE_KILOBYTES_PER_SECOND, "mdi:upload"], + "network_down": ["Network Down", DATA_RATE_KILOBYTES_PER_SECOND, "mdi:download"], } _STORAGE_VOL_MON_COND = { "volume_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], @@ -82,6 +85,7 @@ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=True): cv.boolean, + vol.Optional(CONF_API_VERSION): cv.positive_int, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All( @@ -110,8 +114,9 @@ def run_setup(event): use_ssl = config.get(CONF_SSL) unit = hass.config.units.temperature_unit monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + api_version = config.get(CONF_API_VERSION) - api = SynoApi(host, port, username, password, unit, use_ssl) + api = SynoApi(host, port, username, password, unit, use_ssl, api_version) sensors = [ SynoNasUtilSensor(api, name, variable, _UTILISATION_MON_COND[variable]) @@ -150,13 +155,21 @@ def run_setup(event): class SynoApi: """Class to interface with Synology DSM API.""" - def __init__(self, host, port, username, password, temp_unit, use_ssl): + def __init__(self, host, port, username, password, temp_unit, use_ssl, api_version): """Initialize the API wrapper class.""" self.temp_unit = temp_unit try: - self._api = SynologyDSM(host, port, username, password, use_https=use_ssl) + self._api = SynologyDSM( + host, + port, + username, + password, + use_https=use_ssl, + debugmode=False, + dsm_version=api_version, + ) except: # noqa: E722 pylint: disable=bare-except _LOGGER.error("Error setting up Synology DSM") diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 44ff9c49a0190e..0c4270eaeef5ff 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -99,6 +99,7 @@ class LogEntry: def __init__(self, record, stack, source): """Initialize a log entry.""" self.first_occured = self.timestamp = record.created + self.name = record.name self.level = record.levelname self.message = record.getMessage() self.exception = "" @@ -114,7 +115,7 @@ def __init__(self, record, stack, source): def hash(self): """Calculate a key for DedupStore.""" - return frozenset([self.message, self.root_cause]) + return frozenset([self.name, self.message, self.root_cause]) def to_dict(self): """Convert object into dict to maintain backward compatibility.""" diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index b1a337360835cd..1ea8a409052ee2 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -7,7 +7,15 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_RESOURCES, CONF_TYPE, STATE_OFF, STATE_ON +from homeassistant.const import ( + CONF_RESOURCES, + CONF_TYPE, + DATA_GIBIBYTES, + DATA_MEBIBYTES, + DATA_RATE_MEGABYTES_PER_SECOND, + STATE_OFF, + STATE_ON, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -19,8 +27,8 @@ CONF_ARG = "arg" SENSOR_TYPES = { - "disk_free": ["Disk free", "GiB", "mdi:harddisk", None], - "disk_use": ["Disk use", "GiB", "mdi:harddisk", None], + "disk_free": ["Disk free", DATA_GIBIBYTES, "mdi:harddisk", None], + "disk_use": ["Disk use", DATA_GIBIBYTES, "mdi:harddisk", None], "disk_use_percent": ["Disk use (percent)", "%", "mdi:harddisk", None], "ipv4_address": ["IPv4 address", "", "mdi:server-network", None], "ipv6_address": ["IPv6 address", "", "mdi:server-network", None], @@ -28,29 +36,29 @@ "load_15m": ["Load (15m)", " ", "mdi:memory", None], "load_1m": ["Load (1m)", " ", "mdi:memory", None], "load_5m": ["Load (5m)", " ", "mdi:memory", None], - "memory_free": ["Memory free", "MiB", "mdi:memory", None], - "memory_use": ["Memory use", "MiB", "mdi:memory", None], + "memory_free": ["Memory free", DATA_MEBIBYTES, "mdi:memory", None], + "memory_use": ["Memory use", DATA_MEBIBYTES, "mdi:memory", None], "memory_use_percent": ["Memory use (percent)", "%", "mdi:memory", None], - "network_in": ["Network in", "MiB", "mdi:server-network", None], - "network_out": ["Network out", "MiB", "mdi:server-network", None], + "network_in": ["Network in", DATA_MEBIBYTES, "mdi:server-network", None], + "network_out": ["Network out", DATA_MEBIBYTES, "mdi:server-network", None], "packets_in": ["Packets in", " ", "mdi:server-network", None], "packets_out": ["Packets out", " ", "mdi:server-network", None], "throughput_network_in": [ "Network throughput in", - "MB/s", + DATA_RATE_MEGABYTES_PER_SECOND, "mdi:server-network", None, ], "throughput_network_out": [ "Network throughput out", - "MB/s", + DATA_RATE_MEGABYTES_PER_SECOND, "mdi:server-network", None, ], "process": ["Process", " ", "mdi:memory", None], "processor_use": ["Processor use", "%", "mdi:memory", None], - "swap_free": ["Swap free", "MiB", "mdi:harddisk", None], - "swap_use": ["Swap use", "MiB", "mdi:harddisk", None], + "swap_free": ["Swap free", DATA_MEBIBYTES, "mdi:harddisk", None], + "swap_use": ["Swap use", DATA_MEBIBYTES, "mdi:harddisk", None], "swap_use_percent": ["Swap use (percent)", "%", "mdi:harddisk", None], } diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index ebf605bdc75015..727fb868a333ee 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.util import Throttle -from .const import CONF_FALLBACK +from .const import CONF_FALLBACK, DATA _LOGGER = logging.getLogger(__name__) @@ -20,19 +20,22 @@ SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}" -TADO_COMPONENTS = ["sensor", "climate"] +TADO_COMPONENTS = ["sensor", "climate", "water_heater"] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) SCAN_INTERVAL = timedelta(seconds=15) CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_FALLBACK, default=True): cv.boolean, - } + DOMAIN: vol.All( + cv.ensure_list, + [ + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FALLBACK, default=True): cv.boolean, + } + ], ) }, extra=vol.ALLOW_EXTRA, @@ -41,45 +44,54 @@ def setup(hass, config): """Set up of the Tado component.""" - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] + acc_list = config[DOMAIN] - tadoconnector = TadoConnector(hass, username, password) - if not tadoconnector.setup(): - return False + api_data_list = [] - hass.data[DOMAIN] = tadoconnector + for acc in acc_list: + username = acc[CONF_USERNAME] + password = acc[CONF_PASSWORD] + fallback = acc[CONF_FALLBACK] - # Do first update - tadoconnector.update() + tadoconnector = TadoConnector(hass, username, password, fallback) + if not tadoconnector.setup(): + continue + + # Do first update + tadoconnector.update() + + api_data_list.append(tadoconnector) + # Poll for updates in the background + hass.helpers.event.track_time_interval( + # we're using here tadoconnector as a parameter of lambda + # to capture actual value instead of closuring of latest value + lambda now, tc=tadoconnector: tc.update(), + SCAN_INTERVAL, + ) + + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA] = api_data_list # Load components for component in TADO_COMPONENTS: load_platform( - hass, - component, - DOMAIN, - {CONF_FALLBACK: config[DOMAIN][CONF_FALLBACK]}, - config, + hass, component, DOMAIN, {}, config, ) - # Poll for updates in the background - hass.helpers.event.track_time_interval( - lambda now: tadoconnector.update(), SCAN_INTERVAL - ) - return True class TadoConnector: """An object to store the Tado data.""" - def __init__(self, hass, username, password): + def __init__(self, hass, username, password, fallback): """Initialize Tado Connector.""" self.hass = hass self._username = username self._password = password + self._fallback = fallback + self.device_id = None self.tado = None self.zones = None self.devices = None @@ -88,6 +100,11 @@ def __init__(self, hass, username, password): "device": {}, } + @property + def fallback(self): + """Return fallback flag to Smart Schedule.""" + return self._fallback + def setup(self): """Connect to Tado and fetch the zones.""" try: @@ -101,7 +118,7 @@ def setup(self): # Load zones and devices self.zones = self.tado.getZones() self.devices = self.tado.getMe()["homes"] - + self.device_id = self.devices[0]["id"] return True @Throttle(MIN_TIME_BETWEEN_UPDATES) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 88433db09914b9..b92a54edd5e40c 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -25,13 +25,16 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import CONF_FALLBACK, DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED +from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, + DATA, TYPE_AIR_CONDITIONING, + TYPE_HEATING, ) _LOGGER = logging.getLogger(__name__) @@ -39,25 +42,25 @@ FAN_MAP_TADO = {"HIGH": FAN_HIGH, "MIDDLE": FAN_MIDDLE, "LOW": FAN_LOW} HVAC_MAP_TADO_HEAT = { - "MANUAL": HVAC_MODE_HEAT, - "TIMER": HVAC_MODE_HEAT, - "TADO_MODE": HVAC_MODE_HEAT, - "SMART_SCHEDULE": HVAC_MODE_AUTO, - "OFF": HVAC_MODE_OFF, + CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT, + CONST_OVERLAY_TIMER: HVAC_MODE_HEAT, + CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT, + CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, + CONST_MODE_OFF: HVAC_MODE_OFF, } HVAC_MAP_TADO_COOL = { - "MANUAL": HVAC_MODE_COOL, - "TIMER": HVAC_MODE_COOL, - "TADO_MODE": HVAC_MODE_COOL, - "SMART_SCHEDULE": HVAC_MODE_AUTO, - "OFF": HVAC_MODE_OFF, + CONST_OVERLAY_MANUAL: HVAC_MODE_COOL, + CONST_OVERLAY_TIMER: HVAC_MODE_COOL, + CONST_OVERLAY_TADO_MODE: HVAC_MODE_COOL, + CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, + CONST_MODE_OFF: HVAC_MODE_OFF, } HVAC_MAP_TADO_HEAT_COOL = { - "MANUAL": HVAC_MODE_HEAT_COOL, - "TIMER": HVAC_MODE_HEAT_COOL, - "TADO_MODE": HVAC_MODE_HEAT_COOL, - "SMART_SCHEDULE": HVAC_MODE_AUTO, - "OFF": HVAC_MODE_OFF, + CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT_COOL, + CONST_OVERLAY_TIMER: HVAC_MODE_HEAT_COOL, + CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT_COOL, + CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, + CONST_MODE_OFF: HVAC_MODE_OFF, } SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE @@ -70,21 +73,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tado climate platform.""" - tado = hass.data[DOMAIN] + if discovery_info is None: + return + api_list = hass.data[DOMAIN][DATA] entities = [] - for zone in tado.zones: - entity = create_climate_entity( - tado, zone["name"], zone["id"], discovery_info[CONF_FALLBACK] - ) - if entity: - entities.append(entity) + + for tado in api_list: + for zone in tado.zones: + if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: + entity = create_climate_entity(tado, zone["name"], zone["id"]) + if entity: + entities.append(entity) if entities: add_entities(entities, True) -def create_climate_entity(tado, name: str, zone_id: int, fallback: bool): +def create_climate_entity(tado, name: str, zone_id: int): """Create a Tado climate entity.""" capabilities = tado.get_capabilities(zone_id) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) @@ -112,15 +118,7 @@ def create_climate_entity(tado, name: str, zone_id: int, fallback: bool): step = temperatures["celsius"].get("step", PRECISION_TENTHS) entity = TadoClimate( - tado, - name, - zone_id, - zone_type, - min_temp, - max_temp, - step, - ac_support_heat, - fallback, + tado, name, zone_id, zone_type, min_temp, max_temp, step, ac_support_heat, ) return entity @@ -138,7 +136,6 @@ def __init__( max_temp, step, ac_support_heat, - fallback, ): """Initialize of Tado climate entity.""" self._tado = tado @@ -146,6 +143,7 @@ def __init__( self.zone_name = zone_name self.zone_id = zone_id self.zone_type = zone_type + self._unique_id = f"{zone_type} {zone_id} {tado.device_id}" self._ac_device = zone_type == TYPE_AIR_CONDITIONING self._ac_support_heat = ac_support_heat @@ -162,12 +160,10 @@ def __init__( self._step = step self._target_temp = None - if fallback: - _LOGGER.debug("Default overlay is set to TADO MODE") + if tado.fallback: # Fallback to Smart Schedule at next Schedule switch self._default_overlay = CONST_OVERLAY_TADO_MODE else: - _LOGGER.debug("Default overlay is set to MANUAL MODE") # Don't fallback to Smart Schedule, but keep in manual mode self._default_overlay = CONST_OVERLAY_MANUAL @@ -199,6 +195,11 @@ def name(self): """Return the name of the entity.""" return self.zone_name + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + @property def should_poll(self) -> bool: """Do not poll.""" @@ -358,11 +359,7 @@ def max_temp(self): def update(self): """Handle update callbacks.""" _LOGGER.debug("Updating climate platform for zone %d", self.zone_id) - try: - data = self._tado.data["zone"][self.zone_id] - except KeyError: - _LOGGER.debug("No data") - return + data = self._tado.data["zone"][self.zone_id] if "sensorDataPoints" in data: sensor_data = data["sensorDataPoints"] @@ -375,13 +372,13 @@ def update(self): humidity = float(sensor_data["humidity"]["percentage"]) self._cur_humidity = humidity - # temperature setting will not exist when device is off - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None - ): - setting = float(data["setting"]["temperature"]["celsius"]) - self._target_temp = setting + # temperature setting will not exist when device is off + if ( + "temperature" in data["setting"] + and data["setting"]["temperature"] is not None + ): + setting = float(data["setting"]["temperature"]["celsius"]) + self._target_temp = setting if "tadoMode" in data: mode = data["tadoMode"] diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 3c0232c8ba2119..8d67e3bf9f8577 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -2,6 +2,7 @@ # Configuration CONF_FALLBACK = "fallback" +DATA = "data" # Types TYPE_AIR_CONDITIONING = "AIR_CONDITIONING" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 7539988d42e515..e51cc53caa5b86 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -2,7 +2,11 @@ "domain": "tado", "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", - "requirements": ["python-tado==0.2.9"], + "requirements": [ + "python-tado==0.3.0" + ], "dependencies": [], - "codeowners": ["@michaelarnauts"] + "codeowners": [ + "@michaelarnauts" + ] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index a928b61a50867f..f5f32a6ed1a803 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -6,7 +6,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED +from . import DATA, DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER _LOGGER = logging.getLogger(__name__) @@ -40,26 +40,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the sensor platform.""" - tado = hass.data[DOMAIN] + api_list = hass.data[DOMAIN][DATA] - # Create zone sensors entities = [] - for zone in tado.zones: - entities.extend( - [ - create_zone_sensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS.get(zone["type"]) - ] - ) - # Create device sensors - for home in tado.devices: - entities.extend( - [ - create_device_sensor(tado, home["name"], home["id"], variable) - for variable in DEVICE_SENSORS - ] - ) + for tado in api_list: + # Create zone sensors + + for zone in tado.zones: + entities.extend( + [ + create_zone_sensor(tado, zone["name"], zone["id"], variable) + for variable in ZONE_SENSORS.get(zone["type"]) + ] + ) + + # Create device sensors + for home in tado.devices: + entities.extend( + [ + create_device_sensor(tado, home["name"], home["id"], variable) + for variable in DEVICE_SENSORS + ] + ) add_entities(entities, True) @@ -86,7 +89,7 @@ def __init__(self, tado, zone_name, sensor_type, zone_id, zone_variable): self.zone_variable = zone_variable self.sensor_type = sensor_type - self._unique_id = f"{zone_variable} {zone_id}" + self._unique_id = f"{zone_variable} {zone_id} {tado.device_id}" self._state = None self._state_attributes = None @@ -227,23 +230,16 @@ def update(self): self._state = data["tadoMode"] elif self.zone_variable == "overlay": - if "overlay" in data and data["overlay"] is not None: - self._state = True - self._state_attributes = { - "termination": data["overlay"]["termination"]["type"] - } - else: - self._state = False - self._state_attributes = {} + self._state = "overlay" in data and data["overlay"] is not None + self._state_attributes = ( + {"termination": data["overlay"]["termination"]["type"]} + if self._state + else {} + ) elif self.zone_variable == "early start": - if "preparation" in data and data["preparation"] is not None: - self._state = True - else: - self._state = False + self._state = "preparation" in data and data["preparation"] is not None elif self.zone_variable == "open window": - if "openWindowDetected" in data: - self._state = data["openWindowDetected"] - else: - self._state = False + self._state = "openWindow" in data and data["openWindow"] is not None + self._state_attributes = data["openWindow"] if self._state else {} diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py new file mode 100644 index 00000000000000..fc3a9ce9cf496c --- /dev/null +++ b/homeassistant/components/tado/water_heater.py @@ -0,0 +1,302 @@ +"""Support for Tado hot water zones.""" +import logging + +from homeassistant.components.water_heater import ( + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED +from .const import ( + CONST_MODE_OFF, + CONST_MODE_SMART_SCHEDULE, + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, + DATA, + TYPE_HOT_WATER, +) + +_LOGGER = logging.getLogger(__name__) + +MODE_AUTO = "auto" +MODE_HEAT = "heat" +MODE_OFF = "off" + +OPERATION_MODES = [MODE_AUTO, MODE_HEAT, MODE_OFF] + +WATER_HEATER_MAP_TADO = { + CONST_OVERLAY_MANUAL: MODE_HEAT, + CONST_OVERLAY_TIMER: MODE_HEAT, + CONST_OVERLAY_TADO_MODE: MODE_HEAT, + CONST_MODE_SMART_SCHEDULE: MODE_AUTO, + CONST_MODE_OFF: MODE_OFF, +} + +SUPPORT_FLAGS_HEATER = SUPPORT_OPERATION_MODE + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Tado water heater platform.""" + if discovery_info is None: + return + + api_list = hass.data[DOMAIN][DATA] + entities = [] + + for tado in api_list: + for zone in tado.zones: + if zone["type"] in [TYPE_HOT_WATER]: + entity = create_water_heater_entity(tado, zone["name"], zone["id"]) + entities.append(entity) + + if entities: + add_entities(entities, True) + + +def create_water_heater_entity(tado, name: str, zone_id: int): + """Create a Tado water heater device.""" + capabilities = tado.get_capabilities(zone_id) + supports_temperature_control = capabilities["canSetTemperature"] + + if supports_temperature_control and "temperatures" in capabilities: + temperatures = capabilities["temperatures"] + min_temp = float(temperatures["celsius"]["min"]) + max_temp = float(temperatures["celsius"]["max"]) + else: + min_temp = None + max_temp = None + + entity = TadoWaterHeater( + tado, name, zone_id, supports_temperature_control, min_temp, max_temp + ) + + return entity + + +class TadoWaterHeater(WaterHeaterDevice): + """Representation of a Tado water heater.""" + + def __init__( + self, + tado, + zone_name, + zone_id, + supports_temperature_control, + min_temp, + max_temp, + ): + """Initialize of Tado water heater entity.""" + self._tado = tado + + self.zone_name = zone_name + self.zone_id = zone_id + self._unique_id = f"{zone_id} {tado.device_id}" + + self._device_is_active = False + self._is_away = False + + self._supports_temperature_control = supports_temperature_control + self._min_temperature = min_temp + self._max_temperature = max_temp + + self._target_temp = None + + self._supported_features = SUPPORT_FLAGS_HEATER + if self._supports_temperature_control: + self._supported_features |= SUPPORT_TARGET_TEMPERATURE + + if tado.fallback: + # Fallback to Smart Schedule at next Schedule switch + self._default_overlay = CONST_OVERLAY_TADO_MODE + else: + # Don't fallback to Smart Schedule, but keep in manual mode + self._default_overlay = CONST_OVERLAY_MANUAL + + self._current_operation = CONST_MODE_SMART_SCHEDULE + self._overlay_mode = CONST_MODE_SMART_SCHEDULE + + async def async_added_to_hass(self): + """Register for sensor updates.""" + + @callback + def async_update_callback(): + """Schedule an entity update.""" + self.async_schedule_update_ha_state(True) + + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), + async_update_callback, + ) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + + @property + def name(self): + """Return the name of the entity.""" + return self.zone_name + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def current_operation(self): + """Return current readable operation mode.""" + return WATER_HEATER_MAP_TADO.get(self._current_operation) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._is_away + + @property + def operation_list(self): + """Return the list of available operation modes (readable).""" + return OPERATION_MODES + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._min_temperature + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._max_temperature + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + mode = None + + if operation_mode == MODE_OFF: + mode = CONST_MODE_OFF + elif operation_mode == MODE_AUTO: + mode = CONST_MODE_SMART_SCHEDULE + elif operation_mode == MODE_HEAT: + mode = self._default_overlay + + self._current_operation = mode + self._overlay_mode = None + + # Set a target temperature if we don't have any + if mode == CONST_OVERLAY_TADO_MODE and self._target_temp is None: + self._target_temp = self.min_temp + + self._control_heater() + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if not self._supports_temperature_control or temperature is None: + return + + self._current_operation = self._default_overlay + self._overlay_mode = None + self._target_temp = temperature + self._control_heater() + + def update(self): + """Handle update callbacks.""" + _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) + data = self._tado.data["zone"][self.zone_id] + + if "tadoMode" in data: + mode = data["tadoMode"] + self._is_away = mode == "AWAY" + + if "setting" in data: + power = data["setting"]["power"] + if power == "OFF": + self._current_operation = CONST_MODE_OFF + # There is no overlay, the mode will always be + # "SMART_SCHEDULE" + self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._device_is_active = False + else: + self._device_is_active = True + + # temperature setting will not exist when device is off + if ( + "temperature" in data["setting"] + and data["setting"]["temperature"] is not None + ): + setting = float(data["setting"]["temperature"]["celsius"]) + self._target_temp = setting + + overlay = False + overlay_data = None + termination = CONST_MODE_SMART_SCHEDULE + + if "overlay" in data: + overlay_data = data["overlay"] + overlay = overlay_data is not None + + if overlay: + termination = overlay_data["termination"]["type"] + + if self._device_is_active: + # If you set mode manually to off, there will be an overlay + # and a termination, but we want to see the mode "OFF" + self._overlay_mode = termination + self._current_operation = termination + + def _control_heater(self): + """Send new target temperature.""" + if self._current_operation == CONST_MODE_SMART_SCHEDULE: + _LOGGER.debug( + "Switching to SMART_SCHEDULE for zone %s (%d)", + self.zone_name, + self.zone_id, + ) + self._tado.reset_zone_overlay(self.zone_id) + self._overlay_mode = self._current_operation + return + + if self._current_operation == CONST_MODE_OFF: + _LOGGER.debug( + "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id + ) + self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) + self._overlay_mode = self._current_operation + return + + _LOGGER.debug( + "Switching to %s for zone %s (%d) with temperature %s", + self._current_operation, + self.zone_name, + self.zone_id, + self._target_temp, + ) + self._tado.set_zone_overlay( + self.zone_id, + self._current_operation, + self._target_temp, + None, + TYPE_HOT_WATER, + ) + self._overlay_mode = self._current_operation diff --git a/homeassistant/components/tahoma/__init__.py b/homeassistant/components/tahoma/__init__.py index 0d74d6018a5028..f14e3019ac0f04 100644 --- a/homeassistant/components/tahoma/__init__.py +++ b/homeassistant/components/tahoma/__init__.py @@ -31,7 +31,7 @@ extra=vol.ALLOW_EXTRA, ) -TAHOMA_COMPONENTS = ["scene", "sensor", "cover", "switch", "binary_sensor"] +TAHOMA_COMPONENTS = ["binary_sensor", "cover", "lock", "scene", "sensor", "switch"] TAHOMA_TYPES = { "io:AwningValanceIOComponent": "cover", @@ -52,6 +52,7 @@ "io:VerticalExteriorAwningIOComponent": "cover", "io:VerticalInteriorBlindVeluxIOComponent": "cover", "io:WindowOpenerVeluxIOComponent": "cover", + "opendoors:OpenDoorsSmartLockComponent": "lock", "rtds:RTDSContactSensor": "sensor", "rtds:RTDSMotionSensor": "sensor", "rtds:RTDSSmokeSensor": "smoke", diff --git a/homeassistant/components/tahoma/binary_sensor.py b/homeassistant/components/tahoma/binary_sensor.py index 81078ab480babd..7621a542838b4d 100644 --- a/homeassistant/components/tahoma/binary_sensor.py +++ b/homeassistant/components/tahoma/binary_sensor.py @@ -14,6 +14,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tahoma controller devices.""" + if discovery_info is None: + return _LOGGER.debug("Setup Tahoma Binary sensor platform") controller = hass.data[TAHOMA_DOMAIN]["controller"] devices = [] diff --git a/homeassistant/components/tahoma/cover.py b/homeassistant/components/tahoma/cover.py index fb2bedc746cf52..7692e9bedf74fd 100644 --- a/homeassistant/components/tahoma/cover.py +++ b/homeassistant/components/tahoma/cover.py @@ -51,6 +51,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tahoma covers.""" + if discovery_info is None: + return controller = hass.data[TAHOMA_DOMAIN]["controller"] devices = [] for device in hass.data[TAHOMA_DOMAIN]["devices"]["cover"]: diff --git a/homeassistant/components/tahoma/lock.py b/homeassistant/components/tahoma/lock.py new file mode 100644 index 00000000000000..0b02975fc7e3e3 --- /dev/null +++ b/homeassistant/components/tahoma/lock.py @@ -0,0 +1,89 @@ +"""Support for Tahoma lock.""" +from datetime import timedelta +import logging + +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED + +from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=120) +TAHOMA_STATE_LOCKED = "locked" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Tahoma lock.""" + if discovery_info is None: + return + controller = hass.data[TAHOMA_DOMAIN]["controller"] + devices = [] + for device in hass.data[TAHOMA_DOMAIN]["devices"]["lock"]: + devices.append(TahomaLock(device, controller)) + add_entities(devices, True) + + +class TahomaLock(TahomaDevice, LockDevice): + """Representation a Tahoma lock.""" + + def __init__(self, tahoma_device, controller): + """Initialize the device.""" + super().__init__(tahoma_device, controller) + self._lock_status = None + self._available = False + self._battery_level = None + self._name = None + + def update(self): + """Update method.""" + self.controller.get_states([self.tahoma_device]) + self._battery_level = self.tahoma_device.active_states["core:BatteryState"] + self._name = self.tahoma_device.active_states["core:NameState"] + if ( + self.tahoma_device.active_states.get("core:LockedUnlockedState") + == TAHOMA_STATE_LOCKED + ): + self._lock_status = STATE_LOCKED + else: + self._lock_status = STATE_UNLOCKED + self._available = ( + self.tahoma_device.active_states.get("core:AvailabilityState") + == "available" + ) + + def unlock(self, **kwargs): + """Unlock method.""" + _LOGGER.debug("Unlocking %s", self._name) + self.apply_action("unlock") + + def lock(self, **kwargs): + """Lock method.""" + _LOGGER.debug("Locking %s", self._name) + self.apply_action("lock") + + @property + def name(self): + """Return the name of the lock.""" + return self._name + + @property + def available(self): + """Return True if the lock is available.""" + return self._available + + @property + def is_locked(self): + """Return True if the lock is locked.""" + return self._lock_status == STATE_LOCKED + + @property + def device_state_attributes(self): + """Return the lock state attributes.""" + attr = { + ATTR_BATTERY_LEVEL: self._battery_level, + } + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + return attr diff --git a/homeassistant/components/tahoma/scene.py b/homeassistant/components/tahoma/scene.py index e54ff91a0f6e90..c60f245fc50859 100644 --- a/homeassistant/components/tahoma/scene.py +++ b/homeassistant/components/tahoma/scene.py @@ -10,6 +10,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tahoma scenes.""" + if discovery_info is None: + return controller = hass.data[TAHOMA_DOMAIN]["controller"] scenes = [] for scene in hass.data[TAHOMA_DOMAIN]["scenes"]: diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py index 85ccb55761da07..fb8c61607c7977 100644 --- a/homeassistant/components/tahoma/sensor.py +++ b/homeassistant/components/tahoma/sensor.py @@ -16,6 +16,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tahoma controller devices.""" + if discovery_info is None: + return controller = hass.data[TAHOMA_DOMAIN]["controller"] devices = [] for device in hass.data[TAHOMA_DOMAIN]["devices"]["sensor"]: diff --git a/homeassistant/components/tahoma/switch.py b/homeassistant/components/tahoma/switch.py index 1612120f3136bf..9f98e711ac96af 100644 --- a/homeassistant/components/tahoma/switch.py +++ b/homeassistant/components/tahoma/switch.py @@ -13,6 +13,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tahoma switches.""" + if discovery_info is None: + return controller = hass.data[TAHOMA_DOMAIN]["controller"] devices = [] for switch in hass.data[TAHOMA_DOMAIN]["devices"]["switch"]: diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py index fe183129eaadbf..f340f4a3971e85 100644 --- a/homeassistant/components/teksavvy/sensor.py +++ b/homeassistant/components/teksavvy/sensor.py @@ -6,7 +6,12 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME +from homeassistant.const import ( + CONF_API_KEY, + CONF_MONITORED_VARIABLES, + CONF_NAME, + DATA_GIGABYTES, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -17,7 +22,6 @@ DEFAULT_NAME = "TekSavvy" CONF_TOTAL_BANDWIDTH = "total_bandwidth" -GIGABYTES = "GB" PERCENT = "%" MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) @@ -25,15 +29,15 @@ SENSOR_TYPES = { "usage": ["Usage Ratio", PERCENT, "mdi:percent"], - "usage_gb": ["Usage", GIGABYTES, "mdi:download"], - "limit": ["Data limit", GIGABYTES, "mdi:download"], - "onpeak_download": ["On Peak Download", GIGABYTES, "mdi:download"], - "onpeak_upload": ["On Peak Upload", GIGABYTES, "mdi:upload"], - "onpeak_total": ["On Peak Total", GIGABYTES, "mdi:download"], - "offpeak_download": ["Off Peak download", GIGABYTES, "mdi:download"], - "offpeak_upload": ["Off Peak Upload", GIGABYTES, "mdi:upload"], - "offpeak_total": ["Off Peak Total", GIGABYTES, "mdi:download"], - "onpeak_remaining": ["Remaining", GIGABYTES, "mdi:download"], + "usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"], + "limit": ["Data limit", DATA_GIGABYTES, "mdi:download"], + "onpeak_download": ["On Peak Download", DATA_GIGABYTES, "mdi:download"], + "onpeak_upload": ["On Peak Upload", DATA_GIGABYTES, "mdi:upload"], + "onpeak_total": ["On Peak Total", DATA_GIGABYTES, "mdi:download"], + "offpeak_download": ["Off Peak download", DATA_GIGABYTES, "mdi:download"], + "offpeak_upload": ["Off Peak Upload", DATA_GIGABYTES, "mdi:upload"], + "offpeak_total": ["Off Peak Total", DATA_GIGABYTES, "mdi:download"], + "onpeak_remaining": ["Remaining", DATA_GIGABYTES, "mdi:download"], } API_HA_MAP = ( diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 9b56201f8c74b0..277f91086639f2 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -25,7 +25,6 @@ ATTR_LONGITUDE, CONF_API_KEY, CONF_PLATFORM, - CONF_TIMEOUT, CONF_URL, HTTP_DIGEST_AUTHENTICATION, ) @@ -67,6 +66,7 @@ ATTR_USER_ID = "user_id" ATTR_USERNAME = "username" ATTR_VERIFY_SSL = "verify_ssl" +ATTR_TIMEOUT = "timeout" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_PROXY_URL = "proxy_url" @@ -135,7 +135,7 @@ vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - vol.Optional(CONF_TIMEOUT): vol.Coerce(float), + vol.Optional(ATTR_TIMEOUT): cv.positive_int, }, extra=vol.ALLOW_EXTRA, ) @@ -499,15 +499,15 @@ def _make_row_inline_keyboard(row_keyboard): ATTR_DISABLE_WEB_PREV: None, ATTR_REPLY_TO_MSGID: None, ATTR_REPLYMARKUP: None, - CONF_TIMEOUT: None, + ATTR_TIMEOUT: None, } if data is not None: if ATTR_PARSER in data: params[ATTR_PARSER] = self._parsers.get( data[ATTR_PARSER], self._parse_mode ) - if CONF_TIMEOUT in data: - params[CONF_TIMEOUT] = data[CONF_TIMEOUT] + if ATTR_TIMEOUT in data: + params[ATTR_TIMEOUT] = data[ATTR_TIMEOUT] if ATTR_DISABLE_NOTIF in data: params[ATTR_DISABLE_NOTIF] = data[ATTR_DISABLE_NOTIF] if ATTR_DISABLE_WEB_PREV in data: diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index ed8720c5877074..e3d303a2c528e7 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -21,6 +21,9 @@ send_message: disable_web_page_preview: description: Disables link previews for links in the message. example: true + timeout: + description: Timeout for send message. Will help with timeout errors (poor internet connection, etc) + example: '1000' 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"]' @@ -55,6 +58,9 @@ send_photo: verify_ssl: description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. example: false + timeout: + description: Timeout for send photo. Will help with timeout errors (poor internet connection, etc) + example: '1000' keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' @@ -86,6 +92,9 @@ send_sticker: verify_ssl: description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. example: false + timeout: + description: Timeout for send sticker. Will help with timeout errors (poor internet connection, etc) + example: '1000' keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' @@ -120,6 +129,9 @@ send_video: verify_ssl: description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. example: false + timeout: + description: Timeout for send video. Will help with timeout errors (poor internet connection, etc) + example: '1000' keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' @@ -154,6 +166,9 @@ send_document: verify_ssl: description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. example: false + timeout: + description: Timeout for send document. Will help with timeout errors (poor internet connection, etc) + example: '1000' keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' @@ -176,6 +191,9 @@ send_location: disable_notification: description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. example: true + timeout: + description: Timeout for send photo. Will help with timeout errors (poor internet connection, etc) + example: '1000' keyboard: description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' diff --git a/homeassistant/components/tellduslive/.translations/ca.json b/homeassistant/components/tellduslive/.translations/ca.json index a337474c96bef6..6f337d9a4d3509 100644 --- a/homeassistant/components/tellduslive/.translations/ca.json +++ b/homeassistant/components/tellduslive/.translations/ca.json @@ -3,7 +3,7 @@ "abort": { "already_setup": "TelldusLive ja est\u00e0 configurat", "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", - "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "unknown": "S'ha produ\u00eft un error desconegut" }, "error": { diff --git a/homeassistant/components/tellduslive/.translations/cs.json b/homeassistant/components/tellduslive/.translations/cs.json new file mode 100644 index 00000000000000..bab99c321249c6 --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/pl.json b/homeassistant/components/tellduslive/.translations/pl.json index 01d3c7125c3cf8..68e53df57f1380 100644 --- a/homeassistant/components/tellduslive/.translations/pl.json +++ b/homeassistant/components/tellduslive/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_setup": "TelldusLive jest ju\u017c skonfigurowany", + "already_setup": "TelldusLive jest ju\u017c skonfigurowany.", "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 7d9f940f391986..7aafa38c94f4b7 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -7,7 +7,9 @@ DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, POWER_WATT, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, + TIME_HOURS, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -36,11 +38,11 @@ DEVICE_CLASS_TEMPERATURE, ], SENSOR_TYPE_HUMIDITY: ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_RAINRATE: ["Rain rate", "mm/h", "mdi:water", None], + SENSOR_TYPE_RAINRATE: ["Rain rate", f"mm/{TIME_HOURS}", "mdi:water", None], SENSOR_TYPE_RAINTOTAL: ["Rain total", "mm", "mdi:water", None], SENSOR_TYPE_WINDDIRECTION: ["Wind direction", "", "", None], - SENSOR_TYPE_WINDAVERAGE: ["Wind average", "m/s", "", None], - SENSOR_TYPE_WINDGUST: ["Wind gust", "m/s", "", None], + SENSOR_TYPE_WINDAVERAGE: ["Wind average", SPEED_METERS_PER_SECOND, "", None], + SENSOR_TYPE_WINDGUST: ["Wind gust", SPEED_METERS_PER_SECOND, "", None], SENSOR_TYPE_UV: ["UV", "UV", "", None], SENSOR_TYPE_WATT: ["Power", POWER_WATT, "", None], SENSOR_TYPE_LUMINANCE: ["Luminance", "lx", None, DEVICE_CLASS_ILLUMINANCE], diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 7de43ea0702beb..8991ce4c65b705 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -103,12 +103,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= attribute_templates, ) ) - if not sensors: - _LOGGER.error("No sensors added") - return False async_add_entities(sensors) - return True class BinarySensorTemplate(BinarySensorDevice): diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 13828b960fdf0c..14fc6996378731 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -65,30 +65,33 @@ | SUPPORT_SET_TILT_POSITION ) -COVER_SCHEMA = vol.Schema( - { - vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Exclusive( - CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE - ): cv.template, - vol.Exclusive( - CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE - ): cv.template, - vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, - vol.Optional(CONF_POSITION_TEMPLATE): cv.template, - vol.Optional(CONF_TILT_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, - vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - } +COVER_SCHEMA = vol.All( + vol.Schema( + { + vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Exclusive( + CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE + ): cv.template, + vol.Exclusive( + CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE + ): cv.template, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, + vol.Optional(CONF_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_TILT_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + } + ), + cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -118,12 +121,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= optimistic = device_config.get(CONF_OPTIMISTIC) tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) - if position_action is None and open_action is None: - _LOGGER.error( - "Must specify at least one of %s" or "%s", OPEN_ACTION, POSITION_ACTION - ) - continue - templates = { CONF_VALUE_TEMPLATE: state_template, CONF_POSITION_TEMPLATE: position_template, @@ -160,12 +157,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_ids, ) ) - if not covers: - _LOGGER.error("No covers added") - return False async_add_entities(covers) - return True class CoverTemplate(CoverDevice): @@ -229,19 +222,6 @@ def __init__( self._entities = entity_ids self._available = True - if self._template is not None: - self._template.hass = self.hass - if self._position_template is not None: - self._position_template.hass = self.hass - if self._tilt_template is not None: - self._tilt_template.hass = self.hass - if self._icon_template is not None: - self._icon_template.hass = self.hass - if self._entity_picture_template is not None: - self._entity_picture_template.hass = self.hass - if self._availability_template is not None: - self._availability_template.hass = self.hass - async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 89f54444376022..14381b82e62c2f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -189,18 +189,12 @@ def __init__( self._oscillating = None self._direction = None - self._template.hass = self.hass if self._speed_template: - self._speed_template.hass = self.hass self._supported_features |= SUPPORT_SET_SPEED if self._oscillating_template: - self._oscillating_template.hass = self.hass self._supported_features |= SUPPORT_OSCILLATE if self._direction_template: - self._direction_template.hass = self.hass self._supported_features |= SUPPORT_DIRECTION - if self._availability_template: - self._availability_template.hass = self.hass self._entities = entity_ids # List of valid speeds diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 0f70f8a358b900..7948782479b7f6 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -6,8 +6,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_HS_COLOR, ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light, ) @@ -42,6 +44,8 @@ CONF_LEVEL_TEMPLATE = "level_template" CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_TEMPERATURE_ACTION = "set_temperature" +CONF_COLOR_TEMPLATE = "color_template" +CONF_COLOR_ACTION = "set_color" LIGHT_SCHEMA = vol.Schema( { @@ -57,6 +61,8 @@ vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_COLOR_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, } ) @@ -76,14 +82,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) - level_template = device_config.get(CONF_LEVEL_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] + level_action = device_config.get(CONF_LEVEL_ACTION) + level_template = device_config.get(CONF_LEVEL_TEMPLATE) + temperature_action = device_config.get(CONF_TEMPERATURE_ACTION) temperature_template = device_config.get(CONF_TEMPERATURE_TEMPLATE) + color_action = device_config.get(CONF_COLOR_ACTION) + color_template = device_config.get(CONF_COLOR_TEMPLATE) + templates = { CONF_VALUE_TEMPLATE: state_template, CONF_ICON_TEMPLATE: icon_template, @@ -91,6 +102,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_AVAILABILITY_TEMPLATE: availability_template, CONF_LEVEL_TEMPLATE: level_template, CONF_TEMPERATURE_TEMPLATE: temperature_template, + CONF_COLOR_TEMPLATE: color_template, } initialise_templates(hass, templates) @@ -114,15 +126,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_ids, temperature_action, temperature_template, + color_action, + color_template, ) ) - if not lights: - _LOGGER.error("No lights added") - return False - async_add_entities(lights) - return True class LightTemplate(Light): @@ -144,6 +153,8 @@ def __init__( entity_ids, temperature_action, temperature_template, + color_action, + color_template, ): """Initialize the light.""" self.hass = hass @@ -165,28 +176,20 @@ def __init__( if temperature_action is not None: self._temperature_script = Script(hass, temperature_action) self._temperature_template = temperature_template + self._color_script = None + if color_action is not None: + self._color_script = Script(hass, color_action) + self._color_template = color_template self._state = False self._icon = None self._entity_picture = None self._brightness = None self._temperature = None + self._color = None self._entities = entity_ids self._available = True - if self._template is not None: - self._template.hass = self.hass - if self._level_template is not None: - self._level_template.hass = self.hass - if self._icon_template is not None: - self._icon_template.hass = self.hass - if self._entity_picture_template is not None: - self._entity_picture_template.hass = self.hass - if self._availability_template is not None: - self._availability_template.hass = self.hass - if self._temperature_template is not None: - self._temperature_template.hass = self.hass - @property def brightness(self): """Return the brightness of the light.""" @@ -197,6 +200,11 @@ def color_temp(self): """Return the CT color value in mireds.""" return self._temperature + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + return self._color + @property def name(self): """Return the display name of this light.""" @@ -210,6 +218,8 @@ def supported_features(self): supported_features |= SUPPORT_BRIGHTNESS if self._temperature_script is not None: supported_features |= SUPPORT_COLOR_TEMP + if self._color_script is not None: + supported_features |= SUPPORT_COLOR return supported_features @property @@ -252,6 +262,7 @@ def template_light_startup(event): self._template is not None or self._level_template is not None or self._temperature_template is not None + or self._color_template is not None or self._availability_template is not None ): async_track_state_change( @@ -295,6 +306,12 @@ async def async_turn_on(self, **kwargs): await self._temperature_script.async_run( {"color_temp": kwargs[ATTR_COLOR_TEMP]}, context=self._context ) + elif ATTR_HS_COLOR in kwargs and self._color_script: + hs_value = kwargs[ATTR_HS_COLOR] + await self._color_script.async_run( + {"hs": hs_value, "h": int(hs_value[0]), "s": int(hs_value[1])}, + context=self._context, + ) else: await self._on_script.async_run() @@ -316,6 +333,8 @@ async def async_update(self): self.update_temperature() + self.update_color() + for property_name, template in ( ("_icon", self._icon_template), ("_entity_picture", self._entity_picture_template), @@ -409,3 +428,34 @@ def update_temperature(self): except TemplateError: _LOGGER.error("Cannot evaluate temperature template", exc_info=True) self._temperature = None + + @callback + def update_color(self): + """Update the hs_color from the template.""" + if self._color_template is None: + return + + self._color = None + + try: + render = self._color_template.async_render() + h_str, s_str = map( + float, render.replace("(", "").replace(")", "").split(",", 1) + ) + if ( + h_str is not None + and s_str is not None + and 0 <= h_str <= 360 + and 0 <= s_str <= 100 + ): + self._color = (h_str, s_str) + elif h_str is not None and s_str is not None: + _LOGGER.error( + "Received invalid hs_color : (%s, %s). Expected: (0-360, 0-100)", + h_str, + s_str, + ) + else: + _LOGGER.error("Received invalid hs_color : (%s)", render) + except TemplateError as ex: + _LOGGER.error(ex) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index c2d8e8158c1d36..f96ed5479b9393 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -93,12 +93,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) ) - if not switches: - _LOGGER.error("No switches added") - return False - async_add_entities(switches) - return True class SwitchTemplate(SwitchDevice): diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index dee2a0218299b0..26cf0fed5e8606 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -4,7 +4,7 @@ import os import sys -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, UnidentifiedImageError import numpy as np import voluptuous as vol @@ -287,7 +287,11 @@ def process_image(self, image): inp = img[:, :, [2, 1, 0]] # BGR->RGB inp_expanded = inp.reshape(1, inp.shape[0], inp.shape[1], 3) except ImportError: - img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + try: + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + except UnidentifiedImageError: + _LOGGER.warning("Unable to process image, bad data") + return img.thumbnail((460, 460), Image.ANTIALIAS) img_width, img_height = img.size inp = ( diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index e34e9644381daa..024dc2b7bddc13 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -4,10 +4,10 @@ "documentation": "https://www.home-assistant.io/integrations/tensorflow", "requirements": [ "tensorflow==1.13.2", - "numpy==1.17.4", + "numpy==1.18.1", "protobuf==3.6.1", - "pillow==6.2.1" + "pillow==7.0.0" ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/tesla/.translations/pl.json b/homeassistant/components/tesla/.translations/pl.json index 5a8a3d2ebd3f88..89233646ef0ae8 100644 --- a/homeassistant/components/tesla/.translations/pl.json +++ b/homeassistant/components/tesla/.translations/pl.json @@ -2,7 +2,7 @@ "config": { "error": { "connection_error": "B\u0142\u0105d po\u0142\u0105czenia; sprawd\u017a sie\u0107 i spr\u00f3buj ponownie", - "identifier_exists": "Adres e-mail ju\u017c zarejestrowany", + "identifier_exists": "Adres e-mail jest ju\u017c zarejestrowany.", "invalid_credentials": "Nieprawid\u0142owe po\u015bwiadczenia", "unknown_error": "Nieznany b\u0142\u0105d, prosz\u0119 zg\u0142osi\u0107 dane z loga" }, diff --git a/homeassistant/components/tesla/.translations/sv.json b/homeassistant/components/tesla/.translations/sv.json new file mode 100644 index 00000000000000..46263ff64aeaf9 --- /dev/null +++ b/homeassistant/components/tesla/.translations/sv.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "connection_error": "Fel vid anslutning; kontrollera n\u00e4tverket och f\u00f6rs\u00f6k igen", + "identifier_exists": "E-post redan registrerad", + "invalid_credentials": "Ogiltiga autentiseringsuppgifter", + "unknown_error": "Ok\u00e4nt fel, var god att rapportera logginformation" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "E-postadress" + }, + "description": "V\u00e4nligen ange din information.", + "title": "Tesla - Konfiguration" + } + }, + "title": "Tesla" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekunder mellan skanningar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 1ae65f66821a88..df0664b8f4cd60 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( + ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, CONF_ACCESS_TOKEN, CONF_PASSWORD, @@ -26,7 +27,14 @@ configured_instances, validate_input, ) -from .const import DATA_LISTENER, DOMAIN, ICONS, TESLA_COMPONENTS +from .const import ( + DATA_LISTENER, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + ICONS, + MIN_SCAN_INTERVAL, + TESLA_COMPONENTS, +) _LOGGER = logging.getLogger(__name__) @@ -36,9 +44,9 @@ { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=300): vol.All( - cv.positive_int, vol.Clamp(min=300) - ), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), } ) }, @@ -63,7 +71,7 @@ async def async_setup(hass, base_config): def _update_entry(email, data=None, options=None): data = data or {} - options = options or {CONF_SCAN_INTERVAL: 300} + options = options or {CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL} for entry in hass.config_entries.async_entries(DOMAIN): if email != entry.title: continue @@ -118,7 +126,10 @@ async def async_setup_entry(hass, config_entry): controller = TeslaAPI( websession, refresh_token=config[CONF_TOKEN], - update_interval=config_entry.options.get(CONF_SCAN_INTERVAL, 300), + access_token=config[CONF_ACCESS_TOKEN], + update_interval=config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), ) (refresh_token, access_token) = await controller.connect() except TeslaException as ex: @@ -214,6 +225,7 @@ def device_state_attributes(self): attr = self._attributes if self.tesla_device.has_battery(): attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() + attr[ATTR_BATTERY_CHARGING] = self.tesla_device.battery_charging() return attr @property diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py index 3664cf6252db6e..8b60cd001637f0 100644 --- a/homeassistant/components/tesla/binary_sensor.py +++ b/homeassistant/components/tesla/binary_sensor.py @@ -55,3 +55,4 @@ async def async_update(self): _LOGGER.debug("Updating sensor: %s", self._name) await super().async_update() self._state = self.tesla_device.get_value() + self._attributes = self.tesla_device.attrs diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index 2d2bc0158d2b88..c719807da9fdb9 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -100,8 +100,10 @@ async def async_step_init(self, user_input=None): { vol.Optional( CONF_SCAN_INTERVAL, - default=self.config_entry.options.get(CONF_SCAN_INTERVAL, 300), - ): vol.All(cv.positive_int, vol.Clamp(min=300)) + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)) } ) return self.async_show_form(step_id="init", data_schema=data_schema) @@ -120,7 +122,7 @@ async def validate_input(hass: core.HomeAssistant, data): websession, email=data[CONF_USERNAME], password=data[CONF_PASSWORD], - update_interval=300, + update_interval=DEFAULT_SCAN_INTERVAL, ) (config[CONF_TOKEN], config[CONF_ACCESS_TOKEN]) = await controller.connect( test_login=True diff --git a/homeassistant/components/tesla/const.py b/homeassistant/components/tesla/const.py index be460a430ac333..54cb7a2e071b5a 100644 --- a/homeassistant/components/tesla/const.py +++ b/homeassistant/components/tesla/const.py @@ -1,6 +1,8 @@ """Const file for Tesla cars.""" DOMAIN = "tesla" DATA_LISTENER = "listener" +DEFAULT_SCAN_INTERVAL = 660 +MIN_SCAN_INTERVAL = 60 TESLA_COMPONENTS = [ "sensor", "lock", diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index e3392074679250..f536cdf96b4a90 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.2.3"], + "requirements": ["teslajsonpy==0.3.0"], "dependencies": [], "codeowners": ["@zabuldon", "@alandtse"] } diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py index 363cdc742d3ff6..9b06828693f8f3 100644 --- a/homeassistant/components/tesla/sensor.py +++ b/homeassistant/components/tesla/sensor.py @@ -37,6 +37,7 @@ def __init__(self, tesla_device, controller, config_entry, sensor_type=None): self.units = None self.last_changed_time = None self.type = sensor_type + self._device_class = tesla_device.device_class super().__init__(tesla_device, controller, config_entry) if self.type: @@ -59,6 +60,11 @@ def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return self.units + @property + def device_class(self): + """Return the device_class of the device.""" + return self._device_class + async def async_update(self): """Update the state from the sensor.""" _LOGGER.debug("Updating sensor: %s", self._name) diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index d5af021108a268..83a2fd12d24e58 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -2,9 +2,6 @@ Support for getting the state of a Thermoworks Smoke Thermometer. Requires Smoke Gateway Wifi with an internet connection. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.thermoworks_smoke/ """ import logging diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 23bc76ee6b59fa..9f8579e3e18e80 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.12.0"], + "requirements": ["pyTibber==0.12.2"], "dependencies": [], "codeowners": ["@danielhiversen"], "quality_scale": "silver" diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 6d8bdc7eac7d8e..8eb0673aa73251 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -72,7 +72,7 @@ def __init__(self, ibus_client, stop, line, name): self._stop = stop self._line = line.upper() self._name = name - self._unit = "minutes" + self._unit = TIME_MINUTES self._state = None @property diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index cab57c59ac8f06..72507b3d1481c3 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -126,14 +126,14 @@ def _naive_time_to_utc_datetime(self, naive_time): current_local_date = self.current_datetime.astimezone( self.hass.config.time_zone ).date() - # calcuate utc datetime corecponding to local time + # calculate utc datetime corecponding to local time utc_datetime = self.hass.config.time_zone.localize( datetime.combine(current_local_date, naive_time) ).astimezone(tz=pytz.UTC) return utc_datetime def _calculate_initial_boudary_time(self): - """Calculate internal absolute time boudaries.""" + """Calculate internal absolute time boundaries.""" nowutc = self.current_datetime # If after value is a sun event instead of absolute time if is_sun_event(self._after): diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 348826a12645d1..612561707b1d9e 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -137,7 +137,7 @@ def display_name(self): def update(self, now=None): """Update all Toon data and notify entities.""" - # Ignore the TTL meganism from client library + # Ignore the TTL mechanism from client library # It causes a lots of issues, hence we take control over caching self._toon._clear_cache() # pylint: disable=protected-access diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 020f2d9c07fb0f..e6cfbbc629aa26 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -24,7 +24,7 @@ extra=vol.ALLOW_EXTRA, ) -TOTALCONNECT_PLATFORMS = ["alarm_control_panel"] +TOTALCONNECT_PLATFORMS = ["alarm_control_panel", "binary_sensor"] def setup(hass, config): diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index ed77fc4eea0061..b255132a36582c 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -32,10 +32,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): client = hass.data[TOTALCONNECT_DOMAIN].client - for location in client.locations: - location_id = location.get("LocationID") - name = location.get("LocationName") - alarms.append(TotalConnectAlarm(name, location_id, client)) + for location_id, location in client.locations.items(): + location_name = location.location_name + alarms.append(TotalConnectAlarm(location_name, location_id, client)) add_entities(alarms) @@ -72,35 +71,35 @@ def device_state_attributes(self): def update(self): """Return the state of the device.""" - status = self._client.get_armed_status(self._name) + status = self._client.get_armed_status(self._location_id) attr = { "location_name": self._name, "location_id": self._location_id, - "ac_loss": self._client.ac_loss, - "low_battery": self._client.low_battery, + "ac_loss": self._client.locations[self._location_id].ac_loss, + "low_battery": self._client.locations[self._location_id].low_battery, + "cover_tampered": self._client.locations[ + self._location_id + ].is_cover_tampered, "triggered_source": None, "triggered_zone": None, } - if status == self._client.DISARMED: + if status in (self._client.DISARMED, self._client.DISARMED_BYPASS): state = STATE_ALARM_DISARMED - elif status == self._client.DISARMED_BYPASS: - state = STATE_ALARM_DISARMED - elif status == self._client.ARMED_STAY: - state = STATE_ALARM_ARMED_HOME - elif status == self._client.ARMED_STAY_INSTANT: - state = STATE_ALARM_ARMED_HOME - elif status == self._client.ARMED_STAY_INSTANT_BYPASS: + elif status in ( + self._client.ARMED_STAY, + self._client.ARMED_STAY_INSTANT, + self._client.ARMED_STAY_INSTANT_BYPASS, + ): state = STATE_ALARM_ARMED_HOME elif status == self._client.ARMED_STAY_NIGHT: state = STATE_ALARM_ARMED_NIGHT - elif status == self._client.ARMED_AWAY: - state = STATE_ALARM_ARMED_AWAY - elif status == self._client.ARMED_AWAY_BYPASS: - state = STATE_ALARM_ARMED_AWAY - elif status == self._client.ARMED_AWAY_INSTANT: - state = STATE_ALARM_ARMED_AWAY - elif status == self._client.ARMED_AWAY_INSTANT_BYPASS: + elif status in ( + self._client.ARMED_AWAY, + self._client.ARMED_AWAY_BYPASS, + self._client.ARMED_AWAY_INSTANT, + self._client.ARMED_AWAY_INSTANT_BYPASS, + ): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_CUSTOM_BYPASS: state = STATE_ALARM_ARMED_CUSTOM_BYPASS @@ -128,16 +127,16 @@ def update(self): def alarm_disarm(self, code=None): """Send disarm command.""" - self._client.disarm(self._name) + self._client.disarm(self._location_id) def alarm_arm_home(self, code=None): """Send arm home command.""" - self._client.arm_stay(self._name) + self._client.arm_stay(self._location_id) def alarm_arm_away(self, code=None): """Send arm away command.""" - self._client.arm_away(self._name) + self._client.arm_away(self._location_id) def alarm_arm_night(self, code=None): """Send arm night command.""" - self._client.arm_stay_night(self._name) + self._client.arm_stay_night(self._location_id) diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py new file mode 100644 index 00000000000000..28bd58cfff88b2 --- /dev/null +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -0,0 +1,90 @@ +"""Interfaces with TotalConnect sensors.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GAS, + DEVICE_CLASS_SMOKE, + BinarySensorDevice, +) + +from . import DOMAIN as TOTALCONNECT_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a sensor for a TotalConnect device.""" + if discovery_info is None: + return + + sensors = [] + + client_locations = hass.data[TOTALCONNECT_DOMAIN].client.locations + + for location_id, location in client_locations.items(): + for zone_id, zone in location.zones.items(): + sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone)) + add_entities(sensors, True) + + +class TotalConnectBinarySensor(BinarySensorDevice): + """Represent an TotalConnect zone.""" + + def __init__(self, zone_id, location_id, zone): + """Initialize the TotalConnect status.""" + self._zone_id = zone_id + self._location_id = location_id + self._zone = zone + self._name = self._zone.description + self._unique_id = f"{location_id} {zone_id}" + self._is_on = None + self._is_tampered = None + self._is_low_battery = None + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name of the device.""" + return self._name + + def update(self): + """Return the state of the device.""" + self._is_tampered = self._zone.is_tampered() + self._is_low_battery = self._zone.is_low_battery() + + if self._zone.is_faulted() or self._zone.is_triggered(): + self._is_on = True + else: + self._is_on = False + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._is_on + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + if self._zone.is_type_security(): + return DEVICE_CLASS_DOOR + if self._zone.is_type_fire(): + return DEVICE_CLASS_SMOKE + if self._zone.is_type_carbon_monoxide(): + return DEVICE_CLASS_GAS + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + "zone_id": self._zone_id, + "location_id": self._location_id, + "low_battery": self._is_low_battery, + "tampered": self._is_tampered, + } + return attributes diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 6b2119f1cf59c7..967115e721a26a 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -2,7 +2,7 @@ "domain": "totalconnect", "name": "Honeywell Total Connect Alarm", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==0.28"], + "requirements": ["total_connect_client==0.50"], "dependencies": [], - "codeowners": [] + "codeowners": ["@austinmroczek"] } diff --git a/homeassistant/components/traccar/.translations/pl.json b/homeassistant/components/traccar/.translations/pl.json index 95b7eb1af00b56..b7eaf7fe16e899 100644 --- a/homeassistant/components/traccar/.translations/pl.json +++ b/homeassistant/components/traccar/.translations/pl.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "Na pewno chcesz skonfigurowa\u0107 Traccar?", - "title": "Skonfiguruj Traccar" + "title": "Konfiguracja Traccar" } }, "title": "Traccar" diff --git a/homeassistant/components/traccar/.translations/sv.json b/homeassistant/components/traccar/.translations/sv.json new file mode 100644 index 00000000000000..ddd33235e01bf2 --- /dev/null +++ b/homeassistant/components/traccar/.translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans m\u00e5ste vara tillg\u00e4nglig fr\u00e5n internet f\u00f6r att ta emot meddelanden fr\u00e5n Traccar.", + "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig." + }, + "create_entry": { + "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du st\u00e4lla in webhook-funktionen i Traccar.\n\nAnv\u00e4nd f\u00f6ljande url: `{webhook_url}`\n\nMer information finns i [dokumentationen]({docs_url})." + }, + "step": { + "user": { + "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill st\u00e4lla in Traccar?", + "title": "St\u00e4ll in Traccar" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/de.json b/homeassistant/components/tradfri/.translations/de.json index 5dc2630556e934..68165dbb291491 100644 --- a/homeassistant/components/tradfri/.translations/de.json +++ b/homeassistant/components/tradfri/.translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Bridge ist bereits konfiguriert", + "already_configured": "Bridge ist bereits konfiguriert.", "already_in_progress": "Der Konfigurationsablauf f\u00fcr die Bridge wird bereits ausgef\u00fchrt." }, "error": { diff --git a/homeassistant/components/tradfri/.translations/pl.json b/homeassistant/components/tradfri/.translations/pl.json index fc1152940310f8..208687839ddf2d 100644 --- a/homeassistant/components/tradfri/.translations/pl.json +++ b/homeassistant/components/tradfri/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Mostek jest ju\u017c skonfigurowany", + "already_configured": "Mostek jest ju\u017c skonfigurowany.", "already_in_progress": "Konfiguracja mostka jest ju\u017c w toku." }, "error": { diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 0fe826be9af425..40fe7b01cb0a96 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -246,7 +246,7 @@ async def async_turn_on(self, **kwargs): color_command = self._device_control.set_hsb(**color_data) transition_time = None - # HSB can always be set, but color temp + brightness is bulb dependant + # HSB can always be set, but color temp + brightness is bulb dependent command = dimmer_command if command is not None: command += color_command diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 33f634e279fd9c..1458b717fc6d6c 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -2,7 +2,7 @@ "domain": "trafikverket_train", "name": "Trafikverket Train", "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", - "requirements": ["pytrafikverket==0.1.5.9"], + "requirements": ["pytrafikverket==0.1.6.1"], "dependencies": [], "codeowners": ["@endor-force"] -} +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 652cebf6730139..3224df25c3fa13 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -2,7 +2,7 @@ "domain": "trafikverket_weatherstation", "name": "Trafikverket Weather Station", "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", - "requirements": ["pytrafikverket==0.1.5.9"], + "requirements": ["pytrafikverket==0.1.6.1"], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 802bb897b961f9..78f5bbbb8ca97a 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -16,6 +16,7 @@ CONF_NAME, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -71,7 +72,13 @@ "mdi:flag-triangle", None, ], - "wind_speed": ["Wind speed", "m/s", "windforce", "mdi:weather-windy", None], + "wind_speed": [ + "Wind speed", + SPEED_METERS_PER_SECOND, + "windforce", + "mdi:weather-windy", + None, + ], "humidity": [ "Humidity", "%", diff --git a/homeassistant/components/transmission/.translations/hu.json b/homeassistant/components/transmission/.translations/hu.json new file mode 100644 index 00000000000000..14bf5c28bdfd46 --- /dev/null +++ b/homeassistant/components/transmission/.translations/hu.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Csak egyetlen p\u00e9ld\u00e1nyra van sz\u00fcks\u00e9g." + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni az \u00e1llom\u00e1shoz", + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik", + "wrong_credentials": "Rossz felhaszn\u00e1l\u00f3n\u00e9v vagy jelsz\u00f3" + }, + "step": { + "options": { + "data": { + "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g" + }, + "title": "Be\u00e1ll\u00edt\u00e1sok konfigur\u00e1l\u00e1sa" + }, + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "\u00c1tviteli \u00fcgyf\u00e9l be\u00e1ll\u00edt\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/.translations/pl.json b/homeassistant/components/transmission/.translations/pl.json index a85a3f9b006cf4..5aac538766bd69 100644 --- a/homeassistant/components/transmission/.translations/pl.json +++ b/homeassistant/components/transmission/.translations/pl.json @@ -6,7 +6,7 @@ }, "error": { "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z hostem", - "name_exists": "Nazwa ju\u017c istnieje", + "name_exists": "Nazwa ju\u017c istnieje.", "wrong_credentials": "Nieprawid\u0142owa nazwa u\u017cytkownika lub has\u0142o" }, "step": { diff --git a/homeassistant/components/transmission/.translations/sv.json b/homeassistant/components/transmission/.translations/sv.json index 30004af17dbf98..b2a00771e8581a 100644 --- a/homeassistant/components/transmission/.translations/sv.json +++ b/homeassistant/components/transmission/.translations/sv.json @@ -1,10 +1,12 @@ { "config": { "abort": { + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad.", "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig." }, "error": { "cannot_connect": "Det g\u00e5r inte att ansluta till v\u00e4rden", + "name_exists": "Namnet finns redan", "wrong_credentials": "Fel anv\u00e4ndarnamn eller l\u00f6senord" }, "step": { @@ -21,16 +23,20 @@ "password": "L\u00f6senord", "port": "Port", "username": "Anv\u00e4ndarnamn" - } + }, + "title": "St\u00e4ll in Transmission-klienten" } - } + }, + "title": "Transmission" }, "options": { "step": { "init": { "data": { "scan_interval": "Uppdateringsfrekvens" - } + }, + "description": "Konfigurera alternativ f\u00f6r Transmission", + "title": "Konfigurera alternativ f\u00f6r Transmission" } } } diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 9a9250dbed65ba..659ef97d9deb1b 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,13 +1,16 @@ """Constants for the Transmission Bittorent Client component.""" + +from homeassistant.const import DATA_RATE_MEGABYTES_PER_SECOND + DOMAIN = "transmission" SENSOR_TYPES = { "active_torrents": ["Active Torrents", "Torrents"], "current_status": ["Status", None], - "download_speed": ["Down Speed", "MB/s"], + "download_speed": ["Down Speed", DATA_RATE_MEGABYTES_PER_SECOND], "paused_torrents": ["Paused Torrents", "Torrents"], "total_torrents": ["Total Torrents", "Torrents"], - "upload_speed": ["Up Speed", "MB/s"], + "upload_speed": ["Up Speed", DATA_RATE_MEGABYTES_PER_SECOND], "completed_torrents": ["Completed Torrents", "Torrents"], "started_torrents": ["Started Torrents", "Torrents"], } diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 1756df7baee473..3d85a76f2bd31a 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -80,7 +80,7 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Turn the device off.""" if self.type == "on_off": - _LOGGING.debug("Stoping all torrents") + _LOGGING.debug("Stopping all torrents") self._tm_client.api.stop_torrents() if self.type == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission off") diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 7c6990de08560d..e877e2d2e436f0 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -6,7 +6,13 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_MODE, CONF_API_KEY, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_MODE, + CONF_API_KEY, + CONF_NAME, + TIME_MINUTES, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -101,7 +107,7 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index ba698c2b64d9d1..ffbe5239cc9eb9 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -12,6 +12,7 @@ CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -30,7 +31,7 @@ # sensor_type [ description, unit, icon ] SENSOR_TYPES = { "last_build_id": ["Last Build ID", "", "mdi:account-card-details"], - "last_build_duration": ["Last Build Duration", "sec", "mdi:timelapse"], + "last_build_duration": ["Last Build Duration", TIME_SECONDS, "mdi:timelapse"], "last_build_finished_at": ["Last Build Finished At", "", "mdi:timetable"], "last_build_started_at": ["Last Build Started At", "", "mdi:timetable"], "last_build_state": ["Last Build State", "", "mdi:github-circle"], diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 2b9e7a4eccf688..2026816c090510 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.17.4"], + "requirements": ["numpy==1.18.1"], "dependencies": [], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8ae06771618eaf..3a456dec531a38 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -22,7 +22,7 @@ MEDIA_TYPE_MUSIC, SERVICE_PLAY_MEDIA, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, ENTITY_MATCH_ALL +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery @@ -90,7 +90,7 @@ def _deprecated_platform(value): { vol.Required(ATTR_MESSAGE): cv.string, vol.Optional(ATTR_CACHE): cv.boolean, - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, + vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, vol.Optional(ATTR_LANGUAGE): cv.string, vol.Optional(ATTR_OPTIONS): dict, } @@ -148,7 +148,7 @@ async def async_setup_platform(p_type, p_config=None, discovery_info=None): async def async_say_handle(service): """Service handle for say.""" - entity_ids = service.data.get(ATTR_ENTITY_ID, ENTITY_MATCH_ALL) + entity_ids = service.data[ATTR_ENTITY_ID] message = service.data.get(ATTR_MESSAGE) cache = service.data.get(ATTR_CACHE) language = service.data.get(ATTR_LANGUAGE) @@ -501,14 +501,12 @@ def get_tts_audio(self, message, language, options=None): """Load tts audio file from provider.""" raise NotImplementedError() - def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options=None): """Load tts audio file from provider. Return a tuple of file extension and data as bytes. - - This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job( + return await self.hass.async_add_job( ft.partial(self.get_tts_audio, message, language, options=options) ) diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 215a16fd4cfa02..817ca00a818d89 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -5,5 +5,5 @@ "requirements": ["mutagen==1.43.0"], "dependencies": ["http"], "after_dependencies": ["media_player"], - "codeowners": ["@robbiet480"] + "codeowners": ["@pvizeli"] } diff --git a/homeassistant/components/twentemilieu/.translations/pl.json b/homeassistant/components/twentemilieu/.translations/pl.json index 042fcf0dda66d4..130672906ef523 100644 --- a/homeassistant/components/twentemilieu/.translations/pl.json +++ b/homeassistant/components/twentemilieu/.translations/pl.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "address_exists": "Adres ju\u017c skonfigurowany." + "address_exists": "Adres jest ju\u017c skonfigurowany." }, "error": { - "connection_error": "Po\u0142\u0105czenie nieudane.", + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", "invalid_address": "Nie znaleziono adresu w obszarze us\u0142ugi Twente Milieu." }, "step": { "user": { "data": { - "house_letter": "List domowy / dodatkowy", + "house_letter": "List domowy/dodatkowy", "house_number": "Numer domu", "post_code": "Kod pocztowy" }, diff --git a/homeassistant/components/twentemilieu/.translations/sv.json b/homeassistant/components/twentemilieu/.translations/sv.json new file mode 100644 index 00000000000000..ba2d874368164a --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Adressen har redan st\u00e4llts in." + }, + "error": { + "connection_error": "Det gick inte att ansluta.", + "invalid_address": "Adress hittades inte i serviceomr\u00e5det Twente Milieu." + }, + "step": { + "user": { + "data": { + "house_letter": "Husbrev/till\u00e4gg", + "house_number": "Husnummer", + "post_code": "Postnummer" + }, + "description": "St\u00e4ll in Twente Milieu som ger information om avfallshantering p\u00e5 din adress.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index f4276160d6c472..68b7d5dce2160b 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -13,6 +14,13 @@ ATTR_GAME = "game" ATTR_TITLE = "title" +ATTR_SUBSCRIPTION = "subscribed" +ATTR_SUBSCRIPTION_SINCE = "subscribed_since" +ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted" +ATTR_FOLLOW = "following" +ATTR_FOLLOW_SINCE = "following_since" +ATTR_FOLLOWING = "followers" +ATTR_VIEWS = "views" CONF_CHANNELS = "channels" CONF_CLIENT_ID = "client_id" @@ -26,6 +34,7 @@ { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_TOKEN): cv.string, } ) @@ -34,29 +43,35 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Twitch platform.""" channels = config[CONF_CHANNELS] client_id = config[CONF_CLIENT_ID] - client = TwitchClient(client_id=client_id) + oauth_token = config.get(CONF_TOKEN) + client = TwitchClient(client_id, oauth_token) try: client.ingests.get_server_list() except HTTPError: - _LOGGER.error("Client ID is not valid") + _LOGGER.error("Client ID or OAuth token is not valid") return - users = client.users.translate_usernames_to_ids(channels) + channel_ids = client.users.translate_usernames_to_ids(channels) - add_entities([TwitchSensor(user, client) for user in users], True) + add_entities([TwitchSensor(channel_id, client) for channel_id in channel_ids], True) class TwitchSensor(Entity): """Representation of an Twitch channel.""" - def __init__(self, user, client): + def __init__(self, channel, client): """Initialize the sensor.""" self._client = client - self._user = user - self._channel = self._user.name - self._id = self._user.id - self._state = self._preview = self._game = self._title = None + self._channel = channel + self._oauth_enabled = client._oauth_token is not None + self._state = None + self._preview = None + self._game = None + self._title = None + self._subscription = None + self._follow = None + self._statistics = None @property def should_poll(self): @@ -66,7 +81,7 @@ def should_poll(self): @property def name(self): """Return the name of the sensor.""" - return self._channel + return self._channel.display_name @property def state(self): @@ -81,28 +96,64 @@ def entity_picture(self): @property def device_state_attributes(self): """Return the state attributes.""" + attr = dict(self._statistics) + + if self._oauth_enabled: + attr.update(self._subscription) + attr.update(self._follow) + if self._state == STATE_STREAMING: - return {ATTR_GAME: self._game, ATTR_TITLE: self._title} + attr.update({ATTR_GAME: self._game, ATTR_TITLE: self._title}) + return attr @property def unique_id(self): """Return unique ID for this sensor.""" - return self._id + return self._channel.id @property def icon(self): """Icon to use in the frontend, if any.""" return ICON - # pylint: disable=no-member def update(self): """Update device state.""" - stream = self._client.streams.get_stream_by_user(self._id) + + channel = self._client.channels.get_by_id(self._channel.id) + + self._statistics = { + ATTR_FOLLOWING: channel.followers, + ATTR_VIEWS: channel.views, + } + if self._oauth_enabled: + user = self._client.users.get() + + try: + sub = self._client.users.check_subscribed_to_channel( + user.id, self._channel.id + ) + self._subscription = { + ATTR_SUBSCRIPTION: True, + ATTR_SUBSCRIPTION_SINCE: sub.created_at, + ATTR_SUBSCRIPTION_GIFTED: sub.is_gift, + } + except HTTPError: + self._subscription = {ATTR_SUBSCRIPTION: False} + + try: + follow = self._client.users.check_follows_channel( + user.id, self._channel.id + ) + self._follow = {ATTR_FOLLOW: True, ATTR_FOLLOW_SINCE: follow.created_at} + except HTTPError: + self._follow = {ATTR_FOLLOW: False} + + stream = self._client.streams.get_stream_by_user(self._channel.id) if stream: - self._game = stream.get("channel").get("game") - self._title = stream.get("channel").get("status") - self._preview = stream.get("preview").get("medium") + self._game = stream.channel.get("game") + self._title = stream.channel.get("status") + self._preview = stream.preview.get("medium") self._state = STATE_STREAMING else: - self._preview = self._client.users.get_by_id(self._id).get("logo") + self._preview = self._channel.logo self._state = STATE_OFFLINE diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index e3c5440c450472..77929436283d8a 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -1,8 +1,4 @@ -"""Support for UK public transport data provided by transportapi.com. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.uk_transport/ -""" +"""Support for UK public transport data provided by transportapi.com.""" from datetime import datetime, timedelta import logging import re @@ -11,7 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MODE +from homeassistant.const import CONF_MODE, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -123,7 +119,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/unifi/.translations/ca.json b/homeassistant/components/unifi/.translations/ca.json index 899b532290e929..89d299a28571dd 100644 --- a/homeassistant/components/unifi/.translations/ca.json +++ b/homeassistant/components/unifi/.translations/ca.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Temps (en segons) des de s'ha vist per \u00faltima vegada fins que es considera a fora", + "ssid_filter": "Selecciona els SSID's on fer-hi el seguiment de clients", "track_clients": "Segueix clients de la xarxa", "track_devices": "Segueix dispositius de la xarxa (dispositius Ubiquiti)", "track_wired_clients": "Inclou clients de xarxa per cable" - } + }, + "description": "Configuraci\u00f3 de seguiment de dispositius", + "title": "Opcions d'UniFi" }, "init": { "data": { @@ -42,7 +45,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Crea sensors d'\u00fas d'ample de banda per a clients de la xarxa" - } + }, + "description": "Configuraci\u00f3 dels sensors d\u2019estad\u00edstiques", + "title": "Opcions d'UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/da.json b/homeassistant/components/unifi/.translations/da.json index 46a94cc4047de1..1afd1ca96ce3d9 100644 --- a/homeassistant/components/unifi/.translations/da.json +++ b/homeassistant/components/unifi/.translations/da.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Tid i sekunder fra sidst set indtil betragtet som v\u00e6k", + "ssid_filter": "V\u00e6lg SSIDer, der skal spores tr\u00e5dl\u00f8se klienter p\u00e5", "track_clients": "Spor netv\u00e6rksklienter", "track_devices": "Spor netv\u00e6rksenheder (Ubiquiti-enheder)", "track_wired_clients": "Inkluder kablede netv\u00e6rksklienter" - } + }, + "description": "Konfigurer enhedssporing", + "title": "UniFi-indstillinger" }, "init": { "data": { @@ -41,8 +44,10 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Opret b\u00e5ndbredde-forbrugssensorer for netv\u00e6rksklienter" - } + "allow_bandwidth_sensors": "B\u00e5ndbreddeforbrugssensorer for netv\u00e6rksklienter" + }, + "description": "Konfigurer statistiksensorer", + "title": "UniFi-indstillinger" } } } diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json index 32a378b7c00c90..2f3db9d9b89a0c 100644 --- a/homeassistant/components/unifi/.translations/de.json +++ b/homeassistant/components/unifi/.translations/de.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Zeit in Sekunden vom letzten Gesehenen bis zur Entfernung", + "ssid_filter": "W\u00e4hlen Sie SSIDs zur Verfolgung von drahtlosen Clients aus", "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)", "track_wired_clients": "Einbinden von kabelgebundenen Netzwerk-Clients" - } + }, + "description": "Konfigurieren Sie die Ger\u00e4teverfolgung", + "title": "UniFi-Optionen" }, "init": { "data": { @@ -42,7 +45,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Erstellen von Bandbreiten-Nutzungssensoren f\u00fcr Netzwerk-Clients" - } + }, + "description": "Konfigurieren Sie Statistiksensoren", + "title": "UniFi-Optionen" } } } diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json index d9b65b6d1dab06..f1f96b3c363075 100644 --- a/homeassistant/components/unifi/.translations/en.json +++ b/homeassistant/components/unifi/.translations/en.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", + "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" - } + }, + "description": "Configure device tracking", + "title": "UniFi options" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Create bandwidth usage sensors for network clients" - } + "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients" + }, + "description": "Configure statistics sensors", + "title": "UniFi options" } } } diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json index 677899c0958fdc..6c5e9d677c23f7 100644 --- a/homeassistant/components/unifi/.translations/es.json +++ b/homeassistant/components/unifi/.translations/es.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Tiempo en segundos desde la \u00faltima vez que se vio hasta considerarlo desconectado", + "ssid_filter": "Seleccione los SSIDs para realizar seguimiento de clientes inal\u00e1mbricos", "track_clients": "Seguimiento de los clientes de red", "track_devices": "Rastree dispositivos de red (dispositivos Ubiquiti)", "track_wired_clients": "Incluir clientes de red cableada" - } + }, + "description": "Configurar dispositivo de seguimiento", + "title": "Opciones UniFi" }, "init": { "data": { @@ -42,7 +45,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Crear sensores para monitorizar uso de ancho de banda de clientes de red" - } + }, + "description": "Configurar estad\u00edsticas de los sensores", + "title": "Opciones UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/hu.json b/homeassistant/components/unifi/.translations/hu.json index b927e652ba7906..f6919f985dcd64 100644 --- a/homeassistant/components/unifi/.translations/hu.json +++ b/homeassistant/components/unifi/.translations/hu.json @@ -21,5 +21,14 @@ } }, "title": "UniFi Vez\u00e9rl\u0151" + }, + "options": { + "step": { + "statistics_sensors": { + "data": { + "allow_bandwidth_sensors": "S\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa a h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/it.json b/homeassistant/components/unifi/.translations/it.json index 80b546ebcf8cd8..c1aa9afe54fcec 100644 --- a/homeassistant/components/unifi/.translations/it.json +++ b/homeassistant/components/unifi/.translations/it.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Tempo in secondi dall'ultima volta che viene visto fino a quando non \u00e8 considerato lontano", + "ssid_filter": "Selezionare gli SSID su cui tracciare i client wireless", "track_clients": "Traccia i client di rete", "track_devices": "Tracciare i dispositivi di rete (dispositivi Ubiquiti)", "track_wired_clients": "Includi i client di rete cablata" - } + }, + "description": "Configurare il tracciamento del dispositivo", + "title": "Opzioni UniFi" }, "init": { "data": { @@ -41,8 +44,10 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Creare sensori di utilizzo della larghezza di banda per i client di rete" - } + "allow_bandwidth_sensors": "Sensori di utilizzo della larghezza di banda per i client di rete" + }, + "description": "Configurare i sensori delle statistiche", + "title": "Opzioni UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json index 295430b7284072..dbcd4d7feee8a5 100644 --- a/homeassistant/components/unifi/.translations/ko.json +++ b/homeassistant/components/unifi/.translations/ko.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ud655\uc778\ub41c \uc2dc\uac04\ubd80\ud130 \uc678\ucd9c \uc0c1\ud0dc\ub85c \uac04\uc8fc\ub418\ub294 \uc2dc\uac04 (\ucd08)", + "ssid_filter": "\ubb34\uc120 \ud074\ub77c\uc774\uc5b8\ud2b8\ub97c \ucd94\uc801\ud558\ub824\uba74 SSID\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", "track_clients": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ucd94\uc801 \ub300\uc0c1", "track_devices": "\ub124\ud2b8\uc6cc\ud06c \uae30\uae30 \ucd94\uc801 (Ubiquiti \uae30\uae30)", "track_wired_clients": "\uc720\uc120 \ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ud3ec\ud568" - } + }, + "description": "\uc7a5\uce58 \ucd94\uc801 \uad6c\uc131", + "title": "UniFi \uc635\uc158" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8\ub97c \uc704\ud55c \ub300\uc5ed\ud3ed \uc0ac\uc6a9\ub7c9 \uc13c\uc11c \uc0dd\uc131\ud558\uae30" - } + "allow_bandwidth_sensors": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ub300\uc5ed\ud3ed \uc0ac\uc6a9\ub7c9 \uc13c\uc11c" + }, + "description": "\ud1b5\uacc4 \uc13c\uc11c \uad6c\uc131", + "title": "UniFi \uc635\uc158" } } } diff --git a/homeassistant/components/unifi/.translations/lb.json b/homeassistant/components/unifi/.translations/lb.json index 4fa1f62c602b65..9707432540d104 100644 --- a/homeassistant/components/unifi/.translations/lb.json +++ b/homeassistant/components/unifi/.translations/lb.json @@ -31,7 +31,8 @@ "track_clients": "Netzwierk Cliente verfollegen", "track_devices": "Netzwierk Apparater (Ubiquiti Apparater) verfollegen", "track_wired_clients": "Kabel Netzwierk Cliente abez\u00e9ien" - } + }, + "title": "UniFi Optiounen" }, "init": { "data": { @@ -42,7 +43,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Bandbreet Benotzung Sensore fir Netzwierk Cliente erstellen" - } + }, + "description": "Statistik Sensoren konfigur\u00e9ieren", + "title": "UniFi Optiounen" } } } diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json index 9041f0184232ec..65730c7ab8bdbb 100644 --- a/homeassistant/components/unifi/.translations/no.json +++ b/homeassistant/components/unifi/.translations/no.json @@ -28,21 +28,20 @@ "device_tracker": { "data": { "detection_time": "Tid i sekunder fra sist sett til den ble ansett borte", + "ssid_filter": "Velg SSID-er for \u00e5 spore tr\u00e5dl\u00f8se klienter p\u00e5", "track_clients": "Spor nettverksklienter", "track_devices": "Spore nettverksenheter (Ubiquiti-enheter)", "track_wired_clients": "Inkluder kablede nettverksklienter" - } - }, - "init": { - "data": { - "one": "en", - "other": "andre" - } + }, + "description": "Konfigurere enhetssporing", + "title": "UniFi-alternativer" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Opprett b\u00e5ndbreddesensorer for nettverksklienter" - } + "allow_bandwidth_sensors": "B\u00e5ndbreddebrukssensorer for nettverksklienter" + }, + "description": "Konfigurer statistikk sensorer", + "title": "UniFi-alternativer" } } } diff --git a/homeassistant/components/unifi/.translations/pl.json b/homeassistant/components/unifi/.translations/pl.json index 5887460a8a5a56..e016fbc7cce594 100644 --- a/homeassistant/components/unifi/.translations/pl.json +++ b/homeassistant/components/unifi/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Witryna kontrolera jest ju\u017c skonfigurowana", + "already_configured": "Witryna kontrolera jest ju\u017c skonfigurowana.", "user_privilege": "U\u017cytkownik musi by\u0107 administratorem" }, "error": { @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Czas w sekundach od momentu, kiedy ostatnio widziano, a\u017c do momentu, kiedy uznano go za nieobecny.", + "ssid_filter": "Wybierz SSIDy do \u015bledzenia klient\u00f3w bezprzewodowych", "track_clients": "\u015aled\u017a klient\u00f3w sieciowych", "track_devices": "\u015aled\u017a urz\u0105dzenia sieciowe (urz\u0105dzenia Ubiquiti)", "track_wired_clients": "Uwzgl\u0119dnij klient\u00f3w sieci przewodowej" - } + }, + "description": "Konfiguracja \u015bledzenia urz\u0105dze\u0144", + "title": "Opcje UniFi" }, "init": { "data": { @@ -44,7 +47,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Stw\u00f3rz sensory wykorzystania przepustowo\u015bci przez klient\u00f3w sieciowych" - } + }, + "description": "Konfiguracja sensora statystyk", + "title": "Opcje UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index 3a67d483c0ce32..0080474cf64199 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "ssid_filter": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 SSID \u0434\u043b\u044f \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432", "track_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438", "track_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 (\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Ubiquiti)", "track_wired_clients": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438" - } + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi" }, "init": { "data": { @@ -43,8 +46,10 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\u0421\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u043e\u0441\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" - } + "allow_bandwidth_sensors": "\u0421\u0435\u043d\u0441\u043e\u0440\u044b \u043f\u043e\u043b\u043e\u0441\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0438", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/sv.json b/homeassistant/components/unifi/.translations/sv.json index 864c887d6fe8e3..dbf5373aa9a32f 100644 --- a/homeassistant/components/unifi/.translations/sv.json +++ b/homeassistant/components/unifi/.translations/sv.json @@ -22,5 +22,28 @@ } }, "title": "UniFi Controller" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "detection_time": "Tid i sekunder fr\u00e5n senast sett tills den anses borta", + "track_clients": "Sp\u00e5ra n\u00e4tverksklienter", + "track_devices": "Sp\u00e5ra n\u00e4tverksenheter (Ubiquiti-enheter)", + "track_wired_clients": "Inkludera tr\u00e5dbundna n\u00e4tverksklienter" + } + }, + "init": { + "data": { + "one": "Tom", + "other": "Tomma" + } + }, + "statistics_sensors": { + "data": { + "allow_bandwidth_sensors": "Skapa bandbreddsanv\u00e4ndningssensorer f\u00f6r n\u00e4tverksklienter" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/zh-Hans.json b/homeassistant/components/unifi/.translations/zh-Hans.json index 2bc6bda37e4b43..ebed653732fedf 100644 --- a/homeassistant/components/unifi/.translations/zh-Hans.json +++ b/homeassistant/components/unifi/.translations/zh-Hans.json @@ -28,10 +28,17 @@ "device_tracker": { "data": { "detection_time": "\u8ddd\u79bb\u4e0a\u6b21\u53d1\u73b0\u591a\u5c11\u79d2\u540e\u8ba4\u4e3a\u79bb\u5f00", + "ssid_filter": "\u9009\u62e9\u6240\u8981\u8ffd\u8e2a\u7684\u65e0\u7ebf\u7f51\u7edcSSID", "track_clients": "\u8ddf\u8e2a\u7f51\u7edc\u5ba2\u6237\u7aef", "track_devices": "\u8ddf\u8e2a\u7f51\u7edc\u8bbe\u5907\uff08Ubiquiti \u8bbe\u5907\uff09", "track_wired_clients": "\u5305\u62ec\u6709\u7ebf\u7f51\u7edc\u5ba2\u6237\u7aef" - } + }, + "description": "\u914d\u7f6e\u8bbe\u5907\u8ddf\u8e2a", + "title": "UniFi \u9009\u9879" + }, + "statistics_sensors": { + "description": "\u914d\u7f6e\u7edf\u8ba1\u4f20\u611f\u5668", + "title": "UniFi \u9009\u9879" } } } diff --git a/homeassistant/components/unifi/.translations/zh-Hant.json b/homeassistant/components/unifi/.translations/zh-Hant.json index 5e0b881af15200..cce150a6765d4f 100644 --- a/homeassistant/components/unifi/.translations/zh-Hant.json +++ b/homeassistant/components/unifi/.translations/zh-Hant.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "\u6700\u7d42\u51fa\u73fe\u5f8c\u8996\u70ba\u96e2\u958b\u7684\u6642\u9593\uff08\u4ee5\u79d2\u70ba\u55ae\u4f4d\uff09", + "ssid_filter": "\u9078\u64c7\u6240\u8981\u8ffd\u8e64\u7684\u7121\u7dda\u7db2\u8def", "track_clients": "\u8ffd\u8e64\u7db2\u8def\u5ba2\u6236\u7aef", "track_devices": "\u8ffd\u8e64\u7db2\u8def\u8a2d\u5099\uff08Ubiquiti \u8a2d\u5099\uff09", "track_wired_clients": "\u5305\u542b\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef" - } + }, + "description": "\u8a2d\u5b9a\u8a2d\u5099\u8ffd\u8e64", + "title": "UniFi \u9078\u9805" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\u65b0\u589e\u7db2\u8def\u5ba2\u6236\u7aef\u983b\u5bec\u7528\u91cf\u611f\u61c9\u5668" - } + "allow_bandwidth_sensors": "\u7db2\u8def\u5ba2\u6236\u7aef\u983b\u5bec\u7528\u91cf\u611f\u61c9\u5668" + }, + "description": "\u8a2d\u5b9a\u7d71\u8a08\u6578\u64da\u611f\u61c9\u5668", + "title": "UniFi \u9078\u9805" } } } diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 65015b357a7138..a21ae4ed5087c4 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,7 +1,7 @@ """Support for devices connected to UniFi POE.""" import voluptuous as vol -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -96,6 +96,8 @@ async def async_setup_entry(hass, config_entry): # sw_version=config.raw['swversion'], ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) + return True diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 52ecab08856fb5..36fa7489e81500 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,4 +1,6 @@ """Config flow for UniFi.""" +import socket + import voluptuous as vol from homeassistant import config_entries @@ -10,21 +12,18 @@ CONF_VERIFY_SSL, ) from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_CONTROLLER, CONF_DETECTION_TIME, CONF_SITE_ID, + CONF_SSID_FILTER, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, CONTROLLER_ID, - DEFAULT_ALLOW_BANDWIDTH_SENSORS, - DEFAULT_DETECTION_TIME, - DEFAULT_TRACK_CLIENTS, - DEFAULT_TRACK_DEVICES, - DEFAULT_TRACK_WIRED_CLIENTS, DOMAIN, LOGGER, ) @@ -104,11 +103,15 @@ async def async_step_user(self, user_input=None): ) return self.async_abort(reason="unknown") + host = "" + if await async_discover_unifi(self.hass): + host = "unifi" + return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_HOST): str, + vol.Required(CONF_HOST, default=host): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, @@ -179,33 +182,30 @@ async def async_step_device_tracker(self, user_input=None): self.options.update(user_input) return await self.async_step_statistics_sensors() + controller = get_controller_from_config_entry(self.hass, self.config_entry) + + ssid_filter = {wlan: wlan for wlan in controller.api.wlans} + return self.async_show_form( step_id="device_tracker", data_schema=vol.Schema( { vol.Optional( - CONF_TRACK_CLIENTS, - default=self.config_entry.options.get( - CONF_TRACK_CLIENTS, DEFAULT_TRACK_CLIENTS - ), + CONF_TRACK_CLIENTS, default=controller.option_track_clients, ): bool, vol.Optional( CONF_TRACK_WIRED_CLIENTS, - default=self.config_entry.options.get( - CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS - ), + default=controller.option_track_wired_clients, ): bool, vol.Optional( - CONF_TRACK_DEVICES, - default=self.config_entry.options.get( - CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES - ), + CONF_TRACK_DEVICES, default=controller.option_track_devices, ): bool, + vol.Optional( + CONF_SSID_FILTER, default=controller.option_ssid_filter + ): cv.multi_select(ssid_filter), vol.Optional( CONF_DETECTION_TIME, - default=self.config_entry.options.get( - CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME - ), + default=int(controller.option_detection_time.total_seconds()), ): int, } ), @@ -217,16 +217,15 @@ async def async_step_statistics_sensors(self, user_input=None): self.options.update(user_input) return await self._update_options() + controller = get_controller_from_config_entry(self.hass, self.config_entry) + return self.async_show_form( step_id="statistics_sensors", data_schema=vol.Schema( { vol.Optional( CONF_ALLOW_BANDWIDTH_SENSORS, - default=self.config_entry.options.get( - CONF_ALLOW_BANDWIDTH_SENSORS, - DEFAULT_ALLOW_BANDWIDTH_SENSORS, - ), + default=controller.option_allow_bandwidth_sensors, ): bool } ), @@ -235,3 +234,11 @@ async def async_step_statistics_sensors(self, user_input=None): async def _update_options(self): """Update config entry options.""" return self.async_create_entry(title="", data=self.options) + + +async def async_discover_unifi(hass): + """Discover UniFi address.""" + try: + return await hass.async_add_executor_job(socket.gethostbyname, "unifi") + except socket.gaierror: + return None diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 826491f6ba6df4..b7cd8e8b6a13b4 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,9 +5,13 @@ from aiohttp import CookieJar import aiounifi +from aiounifi.controller import SIGNAL_CONNECTION_STATE +from aiounifi.events import WIRELESS_CLIENT_CONNECTED, WIRELESS_GUEST_CONNECTED +from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING import async_timeout from homeassistant.const import CONF_HOST +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -40,6 +44,7 @@ ) from .errors import AuthenticationRequired, CannotConnect +RETRY_TIMER = 15 SUPPORTED_PLATFORMS = ["device_tracker", "sensor", "switch"] @@ -59,6 +64,11 @@ def __init__(self, hass, config_entry): self._site_name = None self._site_role = None + @property + def controller_id(self): + """Return the controller ID.""" + return CONTROLLER_ID.format(host=self.host, site=self.site) + @property def host(self): """Return the host of this controller.""" @@ -130,15 +140,47 @@ def mac(self): return client.mac return None + @callback + def async_unifi_signalling_callback(self, signal, data): + """Handle messages back from UniFi library.""" + if signal == SIGNAL_CONNECTION_STATE: + + if data == STATE_DISCONNECTED and self.available: + LOGGER.error("Lost connection to UniFi") + + if (data == STATE_RUNNING and not self.available) or ( + data == STATE_DISCONNECTED and self.available + ): + self.available = data == STATE_RUNNING + async_dispatcher_send(self.hass, self.signal_reachable) + + if not self.available: + self.hass.loop.call_later(RETRY_TIMER, self.reconnect) + + elif signal == "new_data" and data: + if "event" in data: + if data["event"].event in ( + WIRELESS_CLIENT_CONNECTED, + WIRELESS_GUEST_CONNECTED, + ): + self.update_wireless_clients() + elif data.get("clients") or data.get("devices"): + async_dispatcher_send(self.hass, self.signal_update) + + @property + def signal_reachable(self) -> str: + """Integration specific event to signal a change in connection status.""" + return f"unifi-reachable-{self.controller_id}" + @property def signal_update(self): """Event specific per UniFi entry to signal new data.""" - return f"unifi-update-{CONTROLLER_ID.format(host=self.host, site=self.site)}" + return f"unifi-update-{self.controller_id}" @property def signal_options_update(self): """Event specific per UniFi entry to signal new options.""" - return f"unifi-options-{CONTROLLER_ID.format(host=self.host, site=self.site)}" + return f"unifi-options-{self.controller_id}" def update_wireless_clients(self): """Update set of known to be wireless clients.""" @@ -156,59 +198,13 @@ def update_wireless_clients(self): unifi_wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry) - async def request_update(self): - """Request an update.""" - if self.progress is not None: - return await self.progress - - self.progress = self.hass.async_create_task(self.async_update()) - await self.progress - - self.progress = None - - async def async_update(self): - """Update UniFi controller information.""" - failed = False - - try: - with async_timeout.timeout(10): - await self.api.clients.update() - await self.api.devices.update() - if self.option_block_clients: - await self.api.clients_all.update() - - except aiounifi.LoginRequired: - try: - with async_timeout.timeout(5): - await self.api.login() - - except (asyncio.TimeoutError, aiounifi.AiounifiException): - failed = True - if self.available: - LOGGER.error("Unable to reach controller %s", self.host) - self.available = False - - except (asyncio.TimeoutError, aiounifi.AiounifiException): - failed = True - if self.available: - LOGGER.error("Unable to reach controller %s", self.host) - self.available = False - - if not failed and not self.available: - LOGGER.info("Reconnected to controller %s", self.host) - self.available = True - - self.update_wireless_clients() - - async_dispatcher_send(self.hass, self.signal_update) - async def async_setup(self): """Set up a UniFi controller.""" - hass = self.hass - try: self.api = await get_controller( - self.hass, **self.config_entry.data[CONF_CONTROLLER] + self.hass, + **self.config_entry.data[CONF_CONTROLLER], + async_callback=self.async_unifi_signalling_callback, ) await self.api.initialize() @@ -227,26 +223,28 @@ async def async_setup(self): LOGGER.error("Unknown error connecting with UniFi controller: %s", err) return False - wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] + wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] self.wireless_clients = wireless_clients.get_data(self.config_entry) self.update_wireless_clients() self.import_configuration() - self.config_entry.add_update_listener(self.async_options_updated) - for platform in SUPPORTED_PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup( + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( self.config_entry, platform ) ) + self.api.start_websocket() + + self.config_entry.add_update_listener(self.async_config_entry_updated) + return True @staticmethod - async def async_options_updated(hass, entry): - """Triggered by config entry options updates.""" + async def async_config_entry_updated(hass, entry) -> None: + """Handle signals of config entry being updated.""" controller_id = CONTROLLER_ID.format( host=entry.data[CONF_CONTROLLER][CONF_HOST], site=entry.data[CONF_CONTROLLER][CONF_SITE_ID], @@ -279,7 +277,6 @@ def import_configuration(self): (CONF_SSID_FILTER, CONF_SSID_FILTER), ): if config in import_config: - print(config) if config == option and import_config[ config ] != self.config_entry.options.get(option): @@ -296,12 +293,38 @@ def import_configuration(self): self.config_entry, options=options ) + @callback + def reconnect(self) -> None: + """Prepare to reconnect UniFi session.""" + LOGGER.debug("Reconnecting to UniFi in %i", RETRY_TIMER) + self.hass.loop.create_task(self.async_reconnect()) + + async def async_reconnect(self) -> None: + """Try to reconnect UniFi session.""" + try: + with async_timeout.timeout(5): + await self.api.login() + self.api.start_websocket() + + except (asyncio.TimeoutError, aiounifi.AiounifiException): + self.hass.loop.call_later(RETRY_TIMER, self.reconnect) + + @callback + def shutdown(self, event) -> None: + """Wrap the call to unifi.close. + + Used as an argument to EventBus.async_listen_once. + """ + self.api.stop_websocket() + async def async_reset(self): """Reset this controller to default state. Will cancel any scheduled setup retry and will unload the config entry. """ + self.api.stop_websocket() + for platform in SUPPORTED_PLATFORMS: await self.hass.config_entries.async_forward_entry_unload( self.config_entry, platform @@ -314,7 +337,9 @@ async def async_reset(self): return True -async def get_controller(hass, host, username, password, port, site, verify_ssl): +async def get_controller( + hass, host, username, password, port, site, verify_ssl, async_callback=None +): """Create a controller object and verify authentication.""" sslcontext = None @@ -335,6 +360,7 @@ async def get_controller(hass, host, username, password, port, site, verify_ssl) site=site, websession=session, sslcontext=sslcontext, + callback=async_callback, ) try: diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 8b45a0f227bc40..5dd5f0c83ae847 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,19 +1,17 @@ """Track devices using UniFi controllers.""" import logging -from pprint import pformat from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER from homeassistant.components.unifi.config_flow import get_controller_from_config_entry from homeassistant.core import callback -from homeassistant.helpers import entity_registry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_registry import DISABLED_CONFIG_ENTRY import homeassistant.util.dt as dt_util from .const import ATTR_MANUFACTURER +from .unifi_client import UniFiClient LOGGER = logging.getLogger(__name__) @@ -43,7 +41,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): controller = get_controller_from_config_entry(hass, config_entry) tracked = {} - registry = await entity_registry.async_get_registry(hass) + option_track_clients = controller.option_track_clients + option_track_devices = controller.option_track_devices + option_track_wired_clients = controller.option_track_wired_clients + + registry = await hass.helpers.entity_registry.async_get_registry() # Restore clients that is not a part of active clients list. for entity in registry.entities.values(): @@ -65,31 +67,72 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def update_controller(): """Update the values of the controller.""" - update_items(controller, async_add_entities, tracked) + nonlocal option_track_clients + nonlocal option_track_devices + + if not option_track_clients and not option_track_devices: + return + + add_entities(controller, async_add_entities, tracked) controller.listeners.append( async_dispatcher_connect(hass, controller.signal_update, update_controller) ) @callback - def update_disable_on_entities(): - """Update the values of the controller.""" - for entity in tracked.values(): - - if entity.entity_registry_enabled_default == entity.enabled: + def options_updated(): + """Manage entities affected by config entry options.""" + nonlocal option_track_clients + nonlocal option_track_devices + nonlocal option_track_wired_clients + + update = False + remove = set() + + for current_option, config_entry_option, tracker_class in ( + (option_track_clients, controller.option_track_clients, UniFiClientTracker), + (option_track_devices, controller.option_track_devices, UniFiDeviceTracker), + ): + if current_option == config_entry_option: continue - disabled_by = None - if not entity.entity_registry_enabled_default and entity.enabled: - disabled_by = DISABLED_CONFIG_ENTRY + if config_entry_option: + update = True + else: + for mac, entity in tracked.items(): + if isinstance(entity, tracker_class): + remove.add(mac) - registry.async_update_entity( - entity.registry_entry.entity_id, disabled_by=disabled_by - ) + if ( + controller.option_track_clients + and option_track_wired_clients != controller.option_track_wired_clients + ): + + if controller.option_track_wired_clients: + update = True + else: + for mac, entity in tracked.items(): + if isinstance(entity, UniFiClientTracker) and entity.is_wired: + remove.add(mac) + + option_track_clients = controller.option_track_clients + option_track_devices = controller.option_track_devices + option_track_wired_clients = controller.option_track_wired_clients + + for mac in remove: + entity = tracked.pop(mac) + + if registry.async_is_registered(entity.entity_id): + registry.async_remove(entity.entity_id) + + hass.async_create_task(entity.async_remove()) + + if update: + update_controller() controller.listeners.append( async_dispatcher_connect( - hass, controller.signal_options_update, update_disable_on_entities + hass, controller.signal_options_update, options_updated ) ) @@ -97,20 +140,25 @@ def update_disable_on_entities(): @callback -def update_items(controller, async_add_entities, tracked): - """Update tracked device state from the controller.""" +def add_entities(controller, async_add_entities, tracked): + """Add new tracker entities from the controller.""" new_tracked = [] - for items, tracker_class in ( - (controller.api.clients, UniFiClientTracker), - (controller.api.devices, UniFiDeviceTracker), + for items, tracker_class, track in ( + (controller.api.clients, UniFiClientTracker, controller.option_track_clients), + (controller.api.devices, UniFiDeviceTracker, controller.option_track_devices), ): + if not track: + continue for item_id in items: if item_id in tracked: - if tracked[item_id].enabled: - tracked[item_id].async_schedule_update_ha_state() + continue + + if tracker_class is UniFiClientTracker and ( + not controller.option_track_wired_clients and items[item_id].is_wired + ): continue tracked[item_id] = tracker_class(items[item_id], controller) @@ -120,25 +168,24 @@ def update_items(controller, async_add_entities, tracked): async_add_entities(new_tracked) -class UniFiClientTracker(ScannerEntity): +class UniFiClientTracker(UniFiClient, ScannerEntity): """Representation of a network client.""" def __init__(self, client, controller): """Set up tracked client.""" - self.client = client - self.controller = controller - self.is_wired = self.client.mac not in controller.wireless_clients - self.wired_bug = None + super().__init__(client, controller) + self.wired_bug = None if self.is_wired != self.client.is_wired: self.wired_bug = dt_util.utcnow() - self.controller.option_detection_time @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - if not self.controller.option_track_clients: - return False + def is_connected(self): + """Return true if the client is connected to the network. + If connected to unwanted ssid return False. + If is_wired and client.is_wired differ it means that the device is offline and UniFi bug shows device as wired. + """ if ( not self.is_wired and self.controller.option_ssid_filter @@ -146,37 +193,6 @@ def entity_registry_enabled_default(self): ): return False - if not self.controller.option_track_wired_clients and self.is_wired: - return False - - return True - - async def async_added_to_hass(self): - """Client entity created.""" - LOGGER.debug("New UniFi client tracker %s (%s)", self.name, self.client.mac) - - async def async_update(self): - """Synchronize state with controller. - - Make sure to update self.is_wired if client is wireless, there is an issue when clients go offline that they get marked as wired. - """ - await self.controller.request_update() - - if self.is_wired and self.client.mac in self.controller.wireless_clients: - self.is_wired = False - - LOGGER.debug( - "Updating UniFi tracked client %s\n%s", - self.entity_id, - pformat(self.client.raw), - ) - - @property - def is_connected(self): - """Return true if the client is connected to the network. - - If is_wired and client.is_wired differ it means that the device is offline and UniFi bug shows device as wired. - """ if self.is_wired != self.client.is_wired: if not self.wired_bug: self.wired_bug = dt_util.utcnow() @@ -198,26 +214,11 @@ def source_type(self): """Return the source type of the client.""" return SOURCE_TYPE_ROUTER - @property - def name(self) -> str: - """Return the name of the client.""" - return self.client.name or self.client.hostname - @property def unique_id(self) -> str: """Return a unique identifier for this client.""" return f"{self.client.mac}-{self.controller.site}" - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.controller.available - - @property - def device_info(self): - """Return a client description for device registry.""" - return {"connections": {(CONNECTION_NETWORK_MAC, self.client.mac)}} - @property def device_state_attributes(self): """Return the client state attributes.""" @@ -239,27 +240,30 @@ def __init__(self, device, controller): """Set up tracked device.""" self.device = device self.controller = controller - - @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - if self.controller.option_track_devices: - return True - return False + self.listeners = [] async def async_added_to_hass(self): """Subscribe to device events.""" LOGGER.debug("New UniFi device tracker %s (%s)", self.name, self.device.mac) + self.device.register_callback(self.async_update_callback) + self.listeners.append( + async_dispatcher_connect( + self.hass, self.controller.signal_reachable, self.async_update_callback + ) + ) - async def async_update(self): - """Synchronize state with controller.""" - await self.controller.request_update() + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.device.remove_callback(self.async_update_callback) + for unsub_dispatcher in self.listeners: + unsub_dispatcher() - LOGGER.debug( - "Updating UniFi tracked device %s\n%s", - self.entity_id, - pformat(self.device.raw), - ) + @callback + def async_update_callback(self): + """Update the sensor's state.""" + LOGGER.debug("Updating UniFi tracked device %s", self.entity_id) + + self.async_schedule_update_ha_state() @property def is_connected(self): @@ -325,3 +329,8 @@ def device_state_attributes(self): attributes["upgradable"] = self.device.upgradable return attributes + + @property + def should_poll(self): + """No polling needed.""" + return False diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index e2bcd5b68a54f9..a42b136e665cef 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,8 +3,12 @@ "name": "Ubiquiti UniFi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==11"], + "requirements": [ + "aiounifi==13" + ], "dependencies": [], - "codeowners": ["@kane610"], + "codeowners": [ + "@kane610" + ], "quality_scale": "platinum" -} +} \ No newline at end of file diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 9145fd8e00f48d..1b6667f2e8060c 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -2,17 +2,13 @@ import logging from homeassistant.components.unifi.config_flow import get_controller_from_config_entry +from homeassistant.const import DATA_BYTES from homeassistant.core import callback -from homeassistant.helpers import entity_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_registry import DISABLED_CONFIG_ENTRY -LOGGER = logging.getLogger(__name__) +from .unifi_client import UniFiClient -ATTR_RECEIVING = "receiving" -ATTR_TRANSMITTING = "transmitting" +LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -24,36 +20,48 @@ async def async_setup_entry(hass, config_entry, async_add_entities): controller = get_controller_from_config_entry(hass, config_entry) sensors = {} - registry = await entity_registry.async_get_registry(hass) + option_allow_bandwidth_sensors = controller.option_allow_bandwidth_sensors + + entity_registry = await hass.helpers.entity_registry.async_get_registry() @callback def update_controller(): """Update the values of the controller.""" - update_items(controller, async_add_entities, sensors) + nonlocal option_allow_bandwidth_sensors + + if not option_allow_bandwidth_sensors: + return + + add_entities(controller, async_add_entities, sensors) controller.listeners.append( async_dispatcher_connect(hass, controller.signal_update, update_controller) ) @callback - def update_disable_on_entities(): + def options_updated(): """Update the values of the controller.""" - for entity in sensors.values(): + nonlocal option_allow_bandwidth_sensors - if entity.entity_registry_enabled_default == entity.enabled: - continue + if option_allow_bandwidth_sensors != controller.option_allow_bandwidth_sensors: + option_allow_bandwidth_sensors = controller.option_allow_bandwidth_sensors - disabled_by = None - if not entity.entity_registry_enabled_default and entity.enabled: - disabled_by = DISABLED_CONFIG_ENTRY + if option_allow_bandwidth_sensors: + update_controller() - registry.async_update_entity( - entity.registry_entry.entity_id, disabled_by=disabled_by - ) + else: + for sensor in sensors.values(): + + if entity_registry.async_is_registered(sensor.entity_id): + entity_registry.async_remove(sensor.entity_id) + + hass.async_create_task(sensor.async_remove()) + + sensors.clear() controller.listeners.append( async_dispatcher_connect( - hass, controller.signal_options_update, update_disable_on_entities + hass, controller.signal_options_update, options_updated ) ) @@ -61,8 +69,8 @@ def update_disable_on_entities(): @callback -def update_items(controller, async_add_entities, sensors): - """Update sensors from the controller.""" +def add_entities(controller, async_add_entities, sensors): + """Add new sensor entities from the controller.""" new_sensors = [] for client_id in controller.api.clients: @@ -73,9 +81,6 @@ def update_items(controller, async_add_entities, sensors): item_id = f"{direction}-{client_id}" if item_id in sensors: - sensor = sensors[item_id] - if sensor.enabled: - sensor.async_schedule_update_ha_state() continue sensors[item_id] = sensor_class( @@ -87,51 +92,7 @@ def update_items(controller, async_add_entities, sensors): async_add_entities(new_sensors) -class UniFiBandwidthSensor(Entity): - """UniFi Bandwidth sensor base class.""" - - def __init__(self, client, controller): - """Set up client.""" - self.client = client - self.controller = controller - self.is_wired = self.client.mac not in controller.wireless_clients - - @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - if self.controller.option_allow_bandwidth_sensors: - return True - return False - - async def async_added_to_hass(self): - """Client entity created.""" - LOGGER.debug("New UniFi bandwidth sensor %s (%s)", self.name, self.client.mac) - - async def async_update(self): - """Synchronize state with controller. - - Make sure to update self.is_wired if client is wireless, there is an issue when clients go offline that they get marked as wired. - """ - LOGGER.debug( - "Updating UniFi bandwidth sensor %s (%s)", self.entity_id, self.client.mac - ) - await self.controller.request_update() - - if self.is_wired and self.client.mac in self.controller.wireless_clients: - self.is_wired = False - - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.controller.available - - @property - def device_info(self): - """Return a device description for device registry.""" - return {"connections": {(CONNECTION_NETWORK_MAC, self.client.mac)}} - - -class UniFiRxBandwidthSensor(UniFiBandwidthSensor): +class UniFiRxBandwidthSensor(UniFiClient): """Receiving bandwidth sensor.""" @property @@ -152,8 +113,13 @@ def unique_id(self): """Return a unique identifier for this bandwidth sensor.""" return f"rx-{self.client.mac}" + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return DATA_BYTES + -class UniFiTxBandwidthSensor(UniFiBandwidthSensor): +class UniFiTxBandwidthSensor(UniFiRxBandwidthSensor): """Transmitting bandwidth sensor.""" @property diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index ce2f2345917b13..e652b60ee32a6b 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -31,15 +31,20 @@ "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", + "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" - } + }, + "description": "Configure device tracking", + "title": "UniFi options" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Create bandwidth usage sensors for network clients" - } + "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients" + }, + "description": "Configure statistics sensors", + "title": "UniFi options" } } } diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index b1f62131eb4914..941f4f8ab84d8f 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -1,15 +1,15 @@ """Support for devices connected to UniFi POE.""" import logging -from pprint import pformat from homeassistant.components.switch import SwitchDevice from homeassistant.components.unifi.config_flow import get_controller_from_config_entry from homeassistant.core import callback from homeassistant.helpers import entity_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity +from .unifi_client import UniFiClient + LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up switches for UniFi component. - Switches are controlling network switch ports with Poe. + Switches are controlling network access and switch ports with POE. """ controller = get_controller_from_config_entry(hass, config_entry) @@ -55,7 +55,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def update_controller(): """Update the values of the controller.""" - update_items(controller, async_add_entities, switches, switches_off) + add_entities(controller, async_add_entities, switches, switches_off) controller.listeners.append( async_dispatcher_connect(hass, controller.signal_update, update_controller) @@ -66,8 +66,8 @@ def update_controller(): @callback -def update_items(controller, async_add_entities, switches, switches_off): - """Update POE port state from the controller.""" +def add_entities(controller, async_add_entities, switches, switches_off): + """Add new switch entities from the controller.""" new_switches = [] devices = controller.api.devices @@ -77,13 +77,6 @@ def update_items(controller, async_add_entities, switches, switches_off): block_client_id = f"block-{client_id}" if block_client_id in switches: - if switches[block_client_id].enabled: - LOGGER.debug( - "Updating UniFi block switch %s (%s)", - switches[block_client_id].entity_id, - switches[block_client_id].client.mac, - ) - switches[block_client_id].async_schedule_update_ha_state() continue if client_id not in controller.api.clients_all: @@ -99,13 +92,6 @@ def update_items(controller, async_add_entities, switches, switches_off): poe_client_id = f"poe-{client_id}" if poe_client_id in switches: - if switches[poe_client_id].enabled: - LOGGER.debug( - "Updating UniFi POE switch %s (%s)", - switches[poe_client_id].entity_id, - switches[poe_client_id].client.mac, - ) - switches[poe_client_id].async_schedule_update_ha_state() continue client = controller.api.clients[client_id] @@ -148,42 +134,21 @@ def update_items(controller, async_add_entities, switches, switches_off): async_add_entities(new_switches) -class UniFiClient: - """Base class for UniFi switches.""" - - def __init__(self, client, controller): - """Set up switch.""" - self.client = client - self.controller = controller - - async def async_update(self): - """Synchronize state with controller.""" - await self.controller.request_update() - - @property - def name(self): - """Return the name of the client.""" - return self.client.name or self.client.hostname - - @property - def device_info(self): - """Return a device description for device registry.""" - return {"connections": {(CONNECTION_NETWORK_MAC, self.client.mac)}} - - class UniFiPOEClientSwitch(UniFiClient, SwitchDevice, RestoreEntity): """Representation of a client that uses POE.""" def __init__(self, client, controller): """Set up POE switch.""" super().__init__(client, controller) + self.poe_mode = None if self.client.sw_port and self.port.poe_mode != "off": self.poe_mode = self.port.poe_mode async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" - LOGGER.debug("New UniFi POE switch %s (%s)", self.name, self.client.mac) + await super().async_added_to_hass() + state = await self.async_get_last_state() if state is None: @@ -198,16 +163,6 @@ async def async_added_to_hass(self): if not self.client.sw_port: self.client.raw["sw_port"] = state.attributes["port"] - async def async_update(self): - """Log client information after update.""" - await super().async_update() - - LOGGER.debug( - "Updating UniFi POE controlled client %s\n%s", - self.entity_id, - pformat(self.client.raw), - ) - @property def unique_id(self): """Return a unique identifier for this switch.""" @@ -261,16 +216,20 @@ def device(self): @property def port(self): """Shortcut to the switch port that client is connected to.""" - return self.device.ports[self.client.sw_port] + try: + return self.device.ports[self.client.sw_port] + except TypeError: + LOGGER.warning( + "Entity %s reports faulty device %s or port %s", + self.entity_id, + self.client.sw_mac, + self.client.sw_port, + ) class UniFiBlockClientSwitch(UniFiClient, SwitchDevice): """Representation of a blockable client.""" - async def async_added_to_hass(self): - """Call when entity about to be added to Home Assistant.""" - LOGGER.debug("New UniFi Block switch %s (%s)", self.name, self.client.mac) - @property def unique_id(self): """Return a unique identifier for this switch.""" @@ -281,11 +240,6 @@ def is_on(self): """Return true if client is allowed to connect.""" return not self.client.blocked - @property - def available(self): - """Return if controller is available.""" - return self.controller.available - async def async_turn_on(self, **kwargs): """Turn on connectivity for client.""" await self.controller.api.clients.async_unblock(self.client.mac) diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py new file mode 100644 index 00000000000000..2e18f55a57bad5 --- /dev/null +++ b/homeassistant/components/unifi/unifi_client.py @@ -0,0 +1,65 @@ +"""Base class for UniFi clients.""" + +import logging + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +LOGGER = logging.getLogger(__name__) + + +class UniFiClient(Entity): + """Base class for UniFi clients.""" + + def __init__(self, client, controller) -> None: + """Set up client.""" + self.client = client + self.controller = controller + self.listeners = [] + self.is_wired = self.client.mac not in controller.wireless_clients + + async def async_added_to_hass(self) -> None: + """Client entity created.""" + LOGGER.debug("New UniFi client %s (%s)", self.name, self.client.mac) + self.client.register_callback(self.async_update_callback) + self.listeners.append( + async_dispatcher_connect( + self.hass, self.controller.signal_reachable, self.async_update_callback + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect client object when removed.""" + self.client.remove_callback(self.async_update_callback) + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + + @callback + def async_update_callback(self) -> None: + """Update the clients state.""" + if self.is_wired and self.client.mac in self.controller.wireless_clients: + self.is_wired = False + LOGGER.debug("Updating client %s %s", self.entity_id, self.client.mac) + self.async_schedule_update_ha_state() + + @property + def name(self) -> str: + """Return the name of the client.""" + return self.client.name or self.client.hostname + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self.controller.available + + @property + def device_info(self) -> dict: + """Return a client description for device registry.""" + return {"connections": {(CONNECTION_NETWORK_MAC, self.client.mac)}} + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 37d4cf138f2f5a..803793d0683afd 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -137,10 +137,7 @@ def __init__(self, hass, name, children, commands, attributes, state_template=No self._state_template.hass = hass async def async_added_to_hass(self): - """Subscribe to children and template state changes. - - This method must be run in the event loop and returns a coroutine. - """ + """Subscribe to children and template state changes.""" @callback def async_on_dependency_update(*_): @@ -416,132 +413,79 @@ def media_position_updated_at(self): """When was the position of the current playing media valid.""" return self._child_attr(ATTR_MEDIA_POSITION_UPDATED_AT) - def async_turn_on(self): - """Turn the media player on. - - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_TURN_ON, allow_override=True) - - def async_turn_off(self): - """Turn the media player off. + async def async_turn_on(self): + """Turn the media player on.""" + await self._async_call_service(SERVICE_TURN_ON, allow_override=True) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_TURN_OFF, allow_override=True) - - def async_mute_volume(self, mute): - """Mute the volume. + async def async_turn_off(self): + """Turn the media player off.""" + await self._async_call_service(SERVICE_TURN_OFF, allow_override=True) - This method must be run in the event loop and returns a coroutine. - """ + async def async_mute_volume(self, mute): + """Mute the volume.""" data = {ATTR_MEDIA_VOLUME_MUTED: mute} - return self._async_call_service(SERVICE_VOLUME_MUTE, data, allow_override=True) + await self._async_call_service(SERVICE_VOLUME_MUTE, data, allow_override=True) - def async_set_volume_level(self, volume): - """Set volume level, range 0..1. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" data = {ATTR_MEDIA_VOLUME_LEVEL: volume} - return self._async_call_service(SERVICE_VOLUME_SET, data, allow_override=True) + await self._async_call_service(SERVICE_VOLUME_SET, data, allow_override=True) - def async_media_play(self): - """Send play command. + async def async_media_play(self): + """Send play command.""" + await self._async_call_service(SERVICE_MEDIA_PLAY) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_MEDIA_PLAY) - - def async_media_pause(self): - """Send pause command. + async def async_media_pause(self): + """Send pause command.""" + await self._async_call_service(SERVICE_MEDIA_PAUSE) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_MEDIA_PAUSE) + async def async_media_stop(self): + """Send stop command.""" + await self._async_call_service(SERVICE_MEDIA_STOP) - def async_media_stop(self): - """Send stop command. - - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_MEDIA_STOP) + async def async_media_previous_track(self): + """Send previous track command.""" + await self._async_call_service(SERVICE_MEDIA_PREVIOUS_TRACK) - def async_media_previous_track(self): - """Send previous track command. + async def async_media_next_track(self): + """Send next track command.""" + await self._async_call_service(SERVICE_MEDIA_NEXT_TRACK) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_MEDIA_PREVIOUS_TRACK) - - def async_media_next_track(self): - """Send next track command. - - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_MEDIA_NEXT_TRACK) - - def async_media_seek(self, position): - """Send seek command. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_seek(self, position): + """Send seek command.""" data = {ATTR_MEDIA_SEEK_POSITION: position} - return self._async_call_service(SERVICE_MEDIA_SEEK, data) + await self._async_call_service(SERVICE_MEDIA_SEEK, data) - def async_play_media(self, media_type, media_id, **kwargs): - """Play a piece of media. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} - return self._async_call_service(SERVICE_PLAY_MEDIA, data) - - def async_volume_up(self): - """Turn volume up for media player. - - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_VOLUME_UP, allow_override=True) - - def async_volume_down(self): - """Turn volume down for media player. + await self._async_call_service(SERVICE_PLAY_MEDIA, data) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_VOLUME_DOWN, allow_override=True) - - def async_media_play_pause(self): - """Play or pause the media player. + async def async_volume_up(self): + """Turn volume up for media player.""" + await self._async_call_service(SERVICE_VOLUME_UP, allow_override=True) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_MEDIA_PLAY_PAUSE) + async def async_volume_down(self): + """Turn volume down for media player.""" + await self._async_call_service(SERVICE_VOLUME_DOWN, allow_override=True) - def async_select_source(self, source): - """Set the input source. + async def async_media_play_pause(self): + """Play or pause the media player.""" + await self._async_call_service(SERVICE_MEDIA_PLAY_PAUSE) - This method must be run in the event loop and returns a coroutine. - """ + async def async_select_source(self, source): + """Set the input source.""" data = {ATTR_INPUT_SOURCE: source} - return self._async_call_service( - SERVICE_SELECT_SOURCE, data, allow_override=True - ) + await self._async_call_service(SERVICE_SELECT_SOURCE, data, allow_override=True) - def async_clear_playlist(self): - """Clear players playlist. + async def async_clear_playlist(self): + """Clear players playlist.""" + await self._async_call_service(SERVICE_CLEAR_PLAYLIST) - This method must be run in the event loop and returns a coroutine. - """ - return self._async_call_service(SERVICE_CLEAR_PLAYLIST) - - def async_set_shuffle(self, shuffle): - """Enable/disable shuffling. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_set_shuffle(self, shuffle): + """Enable/disable shuffling.""" data = {ATTR_MEDIA_SHUFFLE: shuffle} - return self._async_call_service(SERVICE_SHUFFLE_SET, data, allow_override=True) + await self._async_call_service(SERVICE_SHUFFLE_SET, data, allow_override=True) async def async_update(self): """Update state in HA.""" diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 42eb988ed56ce0..0a2c6697f69b02 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -12,11 +12,9 @@ import voluptuous as vol from homeassistant.const import __version__ as current_version -from homeassistant.helpers import discovery, event +from homeassistant.helpers import discovery, update_coordinator from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -28,8 +26,6 @@ DOMAIN = "updater" -DISPATCHER_REMOTE_UPDATE = "updater_remote_update" - UPDATER_URL = "https://updater.home-assistant.io/" UPDATER_UUID_FILE = ".uuid" @@ -84,30 +80,25 @@ async def async_setup(hass, config): # This component only makes sense in release versions _LOGGER.info("Running on 'dev', only analytics will be submitted") - hass.async_create_task( - discovery.async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) - ) - - config = config.get(DOMAIN, {}) - if config.get(CONF_REPORTING): + conf = config.get(DOMAIN, {}) + if conf.get(CONF_REPORTING): huuid = await hass.async_add_job(_load_uuid, hass) else: huuid = None - include_components = config.get(CONF_COMPONENT_REPORTING) + include_components = conf.get(CONF_COMPONENT_REPORTING) - async def check_new_version(now): + async def check_new_version(): """Check if a new version is available and report if one is.""" - result = await get_newest_version(hass, huuid, include_components) - - if result is None: - return + newest, release_notes = await get_newest_version( + hass, huuid, include_components + ) - newest, release_notes = result + _LOGGER.debug("Fetched version %s: %s", newest, release_notes) # Skip on dev - if newest is None or "dev" in current_version: - return + if "dev" in current_version: + return Updater(False, "", "") # Load data from supervisor on Hass.io if hass.components.hassio.is_hassio(): @@ -116,20 +107,33 @@ async def check_new_version(now): # Validate version update_available = False if StrictVersion(newest) > StrictVersion(current_version): - _LOGGER.info("The latest available version of Home Assistant is %s", newest) + _LOGGER.debug( + "The latest available version of Home Assistant is %s", newest + ) update_available = True elif StrictVersion(newest) == StrictVersion(current_version): - _LOGGER.info("You are on the latest version (%s) of Home Assistant", newest) + _LOGGER.debug( + "You are on the latest version (%s) of Home Assistant", newest + ) elif StrictVersion(newest) < StrictVersion(current_version): _LOGGER.debug("Local version is newer than the latest version (%s)", newest) - updater = Updater(update_available, newest, release_notes) - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, updater) + _LOGGER.debug("Update available: %s", update_available) + + return Updater(update_available, newest, release_notes) - # Update daily, start 1 hour after startup - _dt = dt_util.utcnow() + timedelta(hours=1) - event.async_track_utc_time_change( - hass, check_new_version, hour=_dt.hour, minute=_dt.minute, second=_dt.second + coordinator = hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( + hass, + _LOGGER, + name="Home Assistant update", + update_method=check_new_version, + update_interval=timedelta(days=1), + ) + + await coordinator.async_refresh() + + hass.async_create_task( + discovery.async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) ) return True @@ -164,17 +168,17 @@ async def get_newest_version(hass, huuid, include_components): ) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Could not contact Home Assistant Update to check for updates") - return None + raise update_coordinator.UpdateFailed try: res = await req.json() except ValueError: _LOGGER.error("Received invalid JSON from Home Assistant Update") - return None + raise update_coordinator.UpdateFailed try: res = RESPONSE_SCHEMA(res) return res["version"], res["release-notes"] except vol.Invalid: _LOGGER.error("Got unexpected response: %s", res) - return None + raise update_coordinator.UpdateFailed diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py index 3e026a87d4dff9..7abab616d5c513 100644 --- a/homeassistant/components/updater/binary_sensor.py +++ b/homeassistant/components/updater/binary_sensor.py @@ -1,26 +1,24 @@ """Support for Home Assistant Updater binary sensors.""" from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ATTR_NEWEST_VERSION, ATTR_RELEASE_NOTES, DISPATCHER_REMOTE_UPDATE, Updater +from . import ATTR_NEWEST_VERSION, ATTR_RELEASE_NOTES, DOMAIN as UPDATER_DOMAIN async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the updater binary sensors.""" - async_add_entities([UpdaterBinary()]) + if discovery_info is None: + return + + async_add_entities([UpdaterBinary(hass.data[UPDATER_DOMAIN])]) class UpdaterBinary(BinarySensorDevice): """Representation of an updater binary sensor.""" - def __init__(self): + def __init__(self, coordinator): """Initialize the binary sensor.""" - self._update_available = None - self._release_notes = None - self._newest_version = None - self._unsub_dispatcher = None + self.coordinator = coordinator @property def name(self) -> str: @@ -35,12 +33,12 @@ def unique_id(self) -> str: @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._update_available + return self.coordinator.data.update_available @property def available(self) -> bool: """Return True if entity is available.""" - return self._update_available is not None + return self.coordinator.last_update_success @property def should_poll(self) -> bool: @@ -50,32 +48,24 @@ def should_poll(self) -> bool: @property def device_state_attributes(self) -> dict: """Return the optional state attributes.""" - data = super().device_state_attributes - if data is None: - data = {} - if self._release_notes: - data[ATTR_RELEASE_NOTES] = self._release_notes - if self._newest_version: - data[ATTR_NEWEST_VERSION] = self._newest_version + data = {} + if self.coordinator.data.release_notes: + data[ATTR_RELEASE_NOTES] = self.coordinator.data.release_notes + if self.coordinator.data.newest_version: + data[ATTR_NEWEST_VERSION] = self.coordinator.data.newest_version return data async def async_added_to_hass(self): """Register update dispatcher.""" + self.coordinator.async_add_listener(self.async_write_ha_state) - @callback - def async_state_update(updater: Updater): - """Update callback.""" - self._newest_version = updater.newest_version - self._release_notes = updater.release_notes - self._update_available = updater.update_available - self.async_schedule_update_ha_state() + async def async_will_remove_from_hass(self): + """When removed from hass.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) - self._unsub_dispatcher = async_dispatcher_connect( - self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update - ) + async def async_update(self): + """Update the entity. - async def async_will_remove_from_hass(self): - """Register update dispatcher.""" - if self._unsub_dispatcher is not None: - self._unsub_dispatcher() - self._unsub_dispatcher = None + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/upnp/.translations/pl.json b/homeassistant/components/upnp/.translations/pl.json index d7ede44d22dd17..964e5a6818d828 100644 --- a/homeassistant/components/upnp/.translations/pl.json +++ b/homeassistant/components/upnp/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "UPnP/IGD jest ju\u017c skonfigurowane", + "already_configured": "UPnP/IGD jest ju\u017c skonfigurowane.", "incomplete_device": "Ignorowanie niekompletnego urz\u0105dzenia UPnP", "no_devices_discovered": "Nie wykryto urz\u0105dze\u0144 UPnP/IGD", "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 UPnP/IGD.", diff --git a/homeassistant/components/upnp/.translations/ru.json b/homeassistant/components/upnp/.translations/ru.json index 6dce1b3d76c905..b0a7b7e7b65bf0 100644 --- a/homeassistant/components/upnp/.translations/ru.json +++ b/homeassistant/components/upnp/.translations/ru.json @@ -5,7 +5,7 @@ "incomplete_device": "\u0418\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043d\u0435\u043f\u043e\u043b\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP.", "no_devices_discovered": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e UPnP / IGD.", "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP / IGD \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", - "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432.", + "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "error": { @@ -25,7 +25,7 @@ "user": { "data": { "enable_port_mapping": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432 \u0434\u043b\u044f Home Assistant", - "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430", + "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430", "igd": "UPnP / IGD" }, "title": "UPnP / IGD" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 81fd5c025b930f..c77a1b6279f291 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,6 +1,7 @@ """Support for UPnP/IGD Sensors.""" import logging +from homeassistant.const import DATA_BYTES, DATA_KIBIBYTES, TIME_SECONDS from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,15 +19,15 @@ PACKETS_SENT = "packets_sent" SENSOR_TYPES = { - BYTES_RECEIVED: {"name": "bytes received", "unit": "bytes"}, - BYTES_SENT: {"name": "bytes sent", "unit": "bytes"}, + BYTES_RECEIVED: {"name": "bytes received", "unit": DATA_BYTES}, + BYTES_SENT: {"name": "bytes sent", "unit": DATA_BYTES}, PACKETS_RECEIVED: {"name": "packets received", "unit": "packets"}, PACKETS_SENT: {"name": "packets sent", "unit": "packets"}, } IN = "received" OUT = "sent" -KBYTE = 1024 +KIBIBYTE = 1024 async def async_setup_platform( @@ -170,7 +171,7 @@ def unit(self) -> str: """Get unit we are measuring in.""" raise NotImplementedError() - def _async_fetch_value(self): + async def _async_fetch_value(self): """Fetch a value from the IGD.""" raise NotImplementedError() @@ -192,7 +193,7 @@ def icon(self) -> str: @property def unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" - return f"{self.unit}/s" + return f"{self.unit}/{TIME_SECONDS}" def _is_overflowed(self, new_value) -> bool: """Check if value has overflowed.""" @@ -225,7 +226,7 @@ class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor): @property def unit(self) -> str: """Get unit we are measuring in.""" - return "kB" + return DATA_KIBIBYTES async def _async_fetch_value(self) -> float: """Fetch value from device.""" @@ -240,7 +241,7 @@ def state(self) -> str: if self._state is None: return None - return format(float(self._state / KBYTE), ".1f") + return format(float(self._state / KIBIBYTE), ".1f") class PacketsPerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor): diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 3dab92b89f8028..8c47e716b805b9 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -140,7 +140,7 @@ def async_reading(self, entity, old_state, new_state): diff = Decimal(new_state.state) - Decimal(old_state.state) if (not self._sensor_net_consumption) and diff < 0: - # Source sensor just rolled over for unknow reasons, + # Source sensor just rolled over for unknown reasons, return self._state += diff diff --git a/homeassistant/components/vacuum/.translations/sv.json b/homeassistant/components/vacuum/.translations/sv.json new file mode 100644 index 00000000000000..38b7f72ab9bf5a --- /dev/null +++ b/homeassistant/components/vacuum/.translations/sv.json @@ -0,0 +1,16 @@ +{ + "device_automation": { + "action_type": { + "clean": "L\u00e5t {entity_name} st\u00e4da", + "dock": "L\u00e5t {entity_name} \u00e5terg\u00e5 till dockan" + }, + "condition_type": { + "is_cleaning": "{entity_name} st\u00e4dar", + "is_docked": "{entity_name} \u00e4r dockad" + }, + "trigger_type": { + "cleaning": "{entity_name} b\u00f6rjade st\u00e4da", + "docked": "{entity_name} dockad" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vacuum/.translations/zh-Hant.json b/homeassistant/components/vacuum/.translations/zh-Hant.json index b406e1baedefba..b108a2a6a4446c 100644 --- a/homeassistant/components/vacuum/.translations/zh-Hant.json +++ b/homeassistant/components/vacuum/.translations/zh-Hant.json @@ -1,16 +1,16 @@ { "device_automation": { "action_type": { - "clean": "\u555f\u52d5 {entity_name} \u6e05\u9664", - "dock": "\u555f\u52d5 {entity_name} \u56de\u5230\u5145\u96fb\u7ad9" + "clean": "\u555f\u52d5{entity_name}\u6e05\u9664", + "dock": "\u555f\u52d5{entity_name}\u56de\u5230\u5145\u96fb\u7ad9" }, "condition_type": { - "is_cleaning": "{entity_name} \u6b63\u5728\u6e05\u6383", - "is_docked": "{entity_name} \u65bc\u5145\u96fb\u7ad9" + "is_cleaning": "{entity_name}\u6b63\u5728\u6e05\u6383", + "is_docked": "{entity_name}\u65bc\u5145\u96fb\u7ad9" }, "trigger_type": { - "cleaning": "{entity_name} \u958b\u59cb\u6e05\u6383", - "docked": "{entity_name} \u5df2\u56de\u5145\u96fb\u7ad9" + "cleaning": "{entity_name}\u958b\u59cb\u6e05\u6383", + "docked": "{entity_name}\u5df2\u56de\u5145\u96fb\u7ad9" } } } \ No newline at end of file diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 225a6ed72bc0e8..3cd2de600e3157 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -248,7 +248,7 @@ def battery_icon(self): @property def capability_attributes(self): - """Return capabilitiy attributes.""" + """Return capability attributes.""" if self.fan_speed is not None: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} @@ -330,7 +330,7 @@ def battery_icon(self): @property def capability_attributes(self): - """Return capabilitiy attributes.""" + """Return capability attributes.""" if self.fan_speed is not None: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index 5a2eefd94f25ab..cb17505f6e1022 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -11,7 +11,7 @@ CONF_ENTITY_ID, CONF_TYPE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -62,6 +62,7 @@ async def async_get_conditions( return conditions +@callback def async_condition_from_config( config: ConfigType, config_validation: bool ) -> condition.ConditionCheckerType: diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 875cc6f8787c76..7a082200740a84 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -2,7 +2,7 @@ "domain": "vallox", "name": "Valloxs", "documentation": "https://www.home-assistant.io/integrations/vallox", - "requirements": ["vallox-websocket-api==2.2.0"], + "requirements": ["vallox-websocket-api==2.4.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/velbus/.translations/pl.json b/homeassistant/components/velbus/.translations/pl.json index 72e18b0e2c89fa..0856d142befe6d 100644 --- a/homeassistant/components/velbus/.translations/pl.json +++ b/homeassistant/components/velbus/.translations/pl.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "port_exists": "Ten port jest ju\u017c skonfigurowany" + "port_exists": "Ten port jest ju\u017c skonfigurowany." }, "error": { "connection_failed": "Po\u0142\u0105czenie Velbus nie powiod\u0142o si\u0119", - "port_exists": "Ten port jest ju\u017c skonfigurowany" + "port_exists": "Ten port jest ju\u017c skonfigurowany." }, "step": { "user": { diff --git a/homeassistant/components/velbus/.translations/sv.json b/homeassistant/components/velbus/.translations/sv.json new file mode 100644 index 00000000000000..5a86443942379b --- /dev/null +++ b/homeassistant/components/velbus/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Den h\u00e4r porten \u00e4r redan konfigurerad" + }, + "error": { + "connection_failed": "Velbus-anslutningen misslyckades", + "port_exists": "Den h\u00e4r porten \u00e4r redan konfigurerad" + }, + "step": { + "user": { + "data": { + "name": "Namnet p\u00e5 den h\u00e4r velbus-anslutningen", + "port": "Anslutningsstr\u00e4ng" + }, + "title": "Definiera velbus-anslutningstypen" + } + }, + "title": "Velbus-gr\u00e4nssnitt" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 8e00bc3fee5e99..b4fe49a88e7b3a 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -30,13 +30,11 @@ async def async_setup(hass, config): # Import from the configuration file if needed if DOMAIN not in config: return True - port = config[DOMAIN].get(CONF_PORT) data = {} if port: data = {CONF_PORT: port, CONF_NAME: "Velbus import"} - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=data @@ -55,7 +53,6 @@ def callback(): discovery_info = {"cntrl": controller} for category in COMPONENT_TYPES: discovery_info[category] = [] - for module in modules: for channel in range(1, module.number_of_channels() + 1): for category in COMPONENT_TYPES: @@ -63,7 +60,6 @@ def callback(): discovery_info[category].append( (module.get_module_address(), channel) ) - hass.data[DOMAIN][entry.entry_id] = discovery_info for category in COMPONENT_TYPES: @@ -144,11 +140,11 @@ def device_info(self): "identifiers": { (DOMAIN, self._module.get_module_address(), self._module.serial) }, - "name": "{} {}".format( - self._module.get_module_address(), self._module.get_module_name() + "name": "{} ({})".format( + self._module.get_module_name(), self._module.get_module_address() ), "manufacturer": "Velleman", - "model": self._module.get_module_name(), + "model": self._module.get_module_type_name(), "sw_version": "{}.{}-{}".format( self._module.memory_map_version, self._module.build_year, diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 9325acf0608003..1d081b711a8e69 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -49,7 +49,7 @@ def _prt_in_configuration_exists(self, prt: str) -> bool: return False async def async_step_user(self, user_input=None): - """Step when user intializes a integration.""" + """Step when user initializes a integration.""" self._errors = {} if user_input is not None: name = slugify(user_input[CONF_NAME]) diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 3e7df39b333fdb..4478bb81c3ce7f 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -4,8 +4,10 @@ from velbus.util import VelbusException from homeassistant.components.cover import ( + ATTR_POSITION, SUPPORT_CLOSE, SUPPORT_OPEN, + SUPPORT_SET_POSITION, SUPPORT_STOP, CoverDevice, ) @@ -33,24 +35,26 @@ class VelbusCover(VelbusEntity, CoverDevice): @property def supported_features(self): """Flag supported features.""" + if self._module.support_position(): + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP @property def is_closed(self): """Return if the cover is closed.""" - return self._module.is_closed(self._channel) + if self._module.get_position(self._channel) == 100: + return True + return False @property def current_cover_position(self): """Return current position of cover. None is unknown, 0 is closed, 100 is fully open + Velbus: 100 = closed, 0 = open """ - if self._module.is_closed(self._channel): - return 0 - if self._module.is_open(self._channel): - return 100 - return None + pos = self._module.get_position(self._channel) + return 100 - pos def open_cover(self, **kwargs): """Open the cover.""" @@ -72,3 +76,10 @@ def stop_cover(self, **kwargs): self._module.stop(self._channel) except VelbusException as err: _LOGGER.error("A Velbus error occurred: %s", err) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + try: + self._module.set(self._channel, (100 - kwargs[ATTR_POSITION])) + except VelbusException as err: + _LOGGER.error("A Velbus error occurred: %s", err) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 250b2c01e4e964..3063c4445bd8ae 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,8 +2,8 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["python-velbus==2.0.35"], + "requirements": ["python-velbus==2.0.41"], "config_flow": true, "dependencies": [], - "codeowners": ["@cereal2nd"] + "codeowners": ["@Cereal2nd", "@brefra"] } diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 7ebdda2d781c42..d8644b4569a0d7 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -1,6 +1,8 @@ """Support for Velbus sensors.""" import logging +from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR + from . import VelbusEntity from .const import DOMAIN @@ -15,23 +17,53 @@ async def async_setup_entry(hass, entry, async_add_entities): for address, channel in modules_data: module = cntrl.get_module(address) entities.append(VelbusSensor(module, channel)) + if module.get_class(channel) == "counter": + entities.append(VelbusSensor(module, channel, True)) async_add_entities(entities) class VelbusSensor(VelbusEntity): """Representation of a sensor.""" + def __init__(self, module, channel, counter=False): + """Initialize a sensor Velbus entity.""" + super().__init__(module, channel) + self._is_counter = counter + + @property + def unique_id(self): + """Return unique ID for counter sensors.""" + unique_id = super().unique_id + if self._is_counter: + unique_id = f"{unique_id}-counter" + return unique_id + @property def device_class(self): """Return the device class of the sensor.""" + if self._module.get_class(self._channel) == "counter" and not self._is_counter: + if self._module.get_counter_unit(self._channel) == ENERGY_KILO_WATT_HOUR: + return DEVICE_CLASS_POWER + return None return self._module.get_class(self._channel) @property def state(self): """Return the state of the sensor.""" + if self._is_counter: + return self._module.get_counter_state(self._channel) return self._module.get_state(self._channel) @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" + if self._is_counter: + return self._module.get_counter_unit(self._channel) return self._module.get_unit(self._channel) + + @property + def icon(self): + """Icon to use in the frontend.""" + if self._is_counter: + return "mdi:counter" + return None diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 01eb5faf8971b1..5b5d50347acb51 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -1,6 +1,6 @@ """Support for Verisure locks.""" import logging -from time import sleep, time +from time import monotonic, sleep from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_CODE, STATE_LOCKED, STATE_UNLOCKED @@ -71,7 +71,7 @@ def code_format(self): def update(self): """Update lock status.""" - if time() - self._change_timestamp < 10: + if monotonic() - self._change_timestamp < 10: return hub.update_overview() status = hub.get_first( @@ -131,4 +131,4 @@ def set_lock_state(self, code, state): transaction = hub.session.get_lock_state_transaction(transaction_id) if transaction["result"] == "OK": self._state = state - self._change_timestamp = time() + self._change_timestamp = monotonic() diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 32e1c1364a340f..2df250303c50c9 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -1,6 +1,6 @@ """Support for Verisure Smartplugs.""" import logging -from time import time +from time import monotonic from homeassistant.components.switch import SwitchDevice @@ -44,7 +44,7 @@ def name(self): @property def is_on(self): """Return true if on.""" - if time() - self._change_timestamp < 10: + if monotonic() - self._change_timestamp < 10: return self._state self._state = ( hub.get_first( @@ -67,13 +67,13 @@ def turn_on(self, **kwargs): """Set smartplug status on.""" hub.session.set_smartplug_state(self._device_label, True) self._state = True - self._change_timestamp = time() + self._change_timestamp = monotonic() def turn_off(self, **kwargs): """Set smartplug status off.""" hub.session.set_smartplug_state(self._device_label, False) self._state = False - self._change_timestamp = time() + self._change_timestamp = monotonic() # pylint: disable=no-self-use def update(self): diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json index aaa96fb0b968fc..37f88d16654c4e 100644 --- a/homeassistant/components/version/manifest.json +++ b/homeassistant/components/version/manifest.json @@ -2,7 +2,7 @@ "domain": "version", "name": "Version", "documentation": "https://www.home-assistant.io/integrations/version", - "requirements": ["pyhaversion==3.1.0"], + "requirements": ["pyhaversion==3.2.0"], "dependencies": [], "codeowners": ["@fabaff"], "quality_scale": "internal" diff --git a/homeassistant/components/vesync/.translations/sv.json b/homeassistant/components/vesync/.translations/sv.json new file mode 100644 index 00000000000000..a477ca6e5da0e2 --- /dev/null +++ b/homeassistant/components/vesync/.translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Endast en Vesync-instans \u00e4r till\u00e5ten" + }, + "error": { + "invalid_login": "Ogiltigt anv\u00e4ndarnamn eller l\u00f6senord" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "E-postadress" + }, + "title": "Ange anv\u00e4ndarnamn och l\u00f6senord" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index ef05bcf2adb3bc..783581a075528d 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -177,5 +177,5 @@ async def async_update(self): self._unit = "" else: self._state = res.get("ritardo") - self._unit = "min" + self._unit = TIME_MINUTES self._icon = ICON diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index cefc244e5b8c28..66fd15d3a90de3 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "dependencies": [], "codeowners": ["@oischinger"], - "requirements": ["PyViCare==0.1.2"] + "requirements": ["PyViCare==0.1.7"] } diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index f31e4f6517096b..eea3d81faf6a07 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -122,7 +122,8 @@ def set_temperature(self, **kwargs): """Set new target temperatures.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is not None: - self._api.setDomesticHotWaterTemperature(self._target_temperature) + self._api.setDomesticHotWaterTemperature(temp) + self._target_temperature = temp @property def min_temp(self): diff --git a/homeassistant/components/vilfo/.translations/ca.json b/homeassistant/components/vilfo/.translations/ca.json new file mode 100644 index 00000000000000..07d9ddafb5150f --- /dev/null +++ b/homeassistant/components/vilfo/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'encaminador Vilfo ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar. Verifica la informaci\u00f3 proporcionada i torna-ho a provar.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida. Comprova el testimoni d'acc\u00e9s i torna-ho a provar.", + "unknown": "S'ha produ\u00eft un error inesperat durant la configuraci\u00f3 de la integraci\u00f3." + }, + "step": { + "user": { + "data": { + "access_token": "Testimoni d'acc\u00e9s per l'API de l'encaminador Vilfo", + "host": "Nom d'amfitri\u00f3 o IP de l'encaminador" + }, + "description": "Configura la integraci\u00f3 de l'encaminador Vilfo. Necessites la seva IP o nom d'amfitri\u00f3 i el testimoni d'acc\u00e9s de l'API (token). Per a m\u00e9s informaci\u00f3, visita: https://www.home-assistant.io/integrations/vilfo", + "title": "Connexi\u00f3 amb l'encaminador Vilfo" + } + }, + "title": "Encaminador Vilfo" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/da.json b/homeassistant/components/vilfo/.translations/da.json new file mode 100644 index 00000000000000..f233b4cb7b9bdd --- /dev/null +++ b/homeassistant/components/vilfo/.translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Vilfo-router er allerede konfigureret." + }, + "error": { + "cannot_connect": "Forbindelsen kunne ikke oprettes. Tjek de oplysninger, du har angivet, og pr\u00f8v igen.", + "invalid_auth": "Ugyldig godkendelse. Kontroller adgangstoken og pr\u00f8v igen.", + "unknown": "Der opstod en uventet fejl under konfiguration af integrationen." + }, + "step": { + "user": { + "data": { + "access_token": "Adgangstoken til Vilfo-router-API", + "host": "Router-v\u00e6rtsnavn eller IP" + }, + "description": "Indstil Vilfo-routerintegration. Du har brug for dit Vilfo-routerv\u00e6rtsnavn/IP og et API-adgangstoken. For yderligere information om denne integration og hvordan du f\u00e5r disse detaljer, kan du bes\u00f8ge: https://www.home-assistant.io/integrations/vilfo", + "title": "Opret forbindelse til Vilfo-router" + } + }, + "title": "Vilfo-router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/de.json b/homeassistant/components/vilfo/.translations/de.json new file mode 100644 index 00000000000000..9c0f938b6798dc --- /dev/null +++ b/homeassistant/components/vilfo/.translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Dieser Vilfo Router ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung nicht m\u00f6glich. Bitte \u00fcberpr\u00fcfen Sie die von Ihnen angegebenen Informationen und versuchen Sie es erneut.", + "invalid_auth": "Ung\u00fcltige Authentifizierung. Bitte \u00fcberpr\u00fcfen Sie den Zugriffstoken und versuchen Sie es erneut.", + "unknown": "Beim Einrichten der Integration ist ein unerwarteter Fehler aufgetreten." + }, + "step": { + "user": { + "data": { + "access_token": "Zugriffstoken f\u00fcr die Vilfo Router-API", + "host": "Router-Hostname oder IP" + }, + "title": "Stellen Sie eine Verbindung zum Vilfo Router her" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/en.json b/homeassistant/components/vilfo/.translations/en.json new file mode 100644 index 00000000000000..e6b9817f5a8327 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "This Vilfo Router is already configured." + }, + "error": { + "cannot_connect": "Failed to connect. Please check the information you provided and try again.", + "invalid_auth": "Invalid authentication. Please check the access token and try again.", + "unknown": "An unexpected error occurred while setting up the integration." + }, + "step": { + "user": { + "data": { + "access_token": "Access token for the Vilfo Router API", + "host": "Router hostname or IP" + }, + "description": "Set up the Vilfo Router integration. You need your Vilfo Router hostname/IP and an API access token. For additional information on this integration and how to get those details, visit: https://www.home-assistant.io/integrations/vilfo", + "title": "Connect to the Vilfo Router" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/es.json b/homeassistant/components/vilfo/.translations/es.json new file mode 100644 index 00000000000000..170faa197da980 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Este router Vilfo ya est\u00e1 configurado." + }, + "error": { + "cannot_connect": "No se pudo conectar. Compruebe la informaci\u00f3n que proporcion\u00f3 e int\u00e9ntelo de nuevo.", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida. Compruebe el token de acceso e int\u00e9ntelo de nuevo.", + "unknown": "Se ha producido un error inesperado al configurar la integraci\u00f3n." + }, + "step": { + "user": { + "data": { + "access_token": "Token de acceso para la API del Router Vilfo", + "host": "Nombre de host o IP del router" + }, + "description": "Configure la integraci\u00f3n del Router Vilfo. Necesita su nombre de host/IP del Router Vilfo y un token de acceso a la API. Para obtener informaci\u00f3n adicional sobre esta integraci\u00f3n y c\u00f3mo obtener esos detalles, visite: https://www.home-assistant.io/integrations/vilfo", + "title": "Conectar con el Router Vilfo" + } + }, + "title": "Router Vilfo" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/fr.json b/homeassistant/components/vilfo/.translations/fr.json new file mode 100644 index 00000000000000..6abeb789f23405 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/fr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Routeur Vilfo" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/hu.json b/homeassistant/components/vilfo/.translations/hu.json new file mode 100644 index 00000000000000..5ae11707c19221 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Router hostname vagy IP" + }, + "title": "Csatlakoz\u00e1s a Vilfo routerhez" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/it.json b/homeassistant/components/vilfo/.translations/it.json new file mode 100644 index 00000000000000..5523dcc0c098ff --- /dev/null +++ b/homeassistant/components/vilfo/.translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Questo Vilfo Router \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi. Controllare le informazioni fornite e riprovare.", + "invalid_auth": "Autenticazione non valida. Controllare il token di accesso e riprovare.", + "unknown": "Si \u00e8 verificato un errore imprevisto durante l'impostazione dell'integrazione." + }, + "step": { + "user": { + "data": { + "access_token": "Token di accesso per il Vilfo Router API", + "host": "Nome host o IP del router" + }, + "description": "Configurare l'integrazione del Vilfo Router. \u00c8 necessario il vostro hostname/IP del Vilfo Router e un token di accesso API. Per ulteriori informazioni su questa integrazione e su come ottenere tali dettagli, visitare il sito: https://www.home-assistant.io/integrations/vilfo", + "title": "Collegamento al Vilfo Router" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/ko.json b/homeassistant/components/vilfo/.translations/ko.json new file mode 100644 index 00000000000000..85cb147ff6c8dc --- /dev/null +++ b/homeassistant/components/vilfo/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 Vilfo \ub77c\uc6b0\ud130\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \uc785\ub825\ud558\uc2e0 \ub0b4\uc6a9\uc744 \ud655\uc778\ud558\uc2e0 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc2a4 \ud1a0\ud070\uc744 \ud655\uc778\ud558\uc2e0 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud558\ub294 \uc911 \uc608\uae30\uce58 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "access_token": "Vilfo \ub77c\uc6b0\ud130 API \uc6a9 \uc561\uc138\uc2a4 \ud1a0\ud070", + "host": "\ub77c\uc6b0\ud130 \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c" + }, + "description": "Vilfo \ub77c\uc6b0\ud130 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. Vilfo \ub77c\uc6b0\ud130 \ud638\uc2a4\ud2b8 \uc774\ub984 / IP \uc640 API \uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc5d0 \ub300\ud55c \ucd94\uac00 \uc815\ubcf4\uc640 \uc138\ubd80 \uc0ac\ud56d\uc740 https://www.home-assistant.io/integrations/vilfo \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "title": "Vilfo \ub77c\uc6b0\ud130\uc5d0 \uc5f0\uacb0\ud558\uae30" + } + }, + "title": "Vilfo \ub77c\uc6b0\ud130" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/lb.json b/homeassistant/components/vilfo/.translations/lb.json new file mode 100644 index 00000000000000..7b88bd31d17368 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse Vilfo Router ass scho konfigur\u00e9iert." + }, + "error": { + "cannot_connect": "Feeler beim verbannen. Iwwerpr\u00e9ift \u00e4r Informatiounen an prob\u00e9iert nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun. Iwwerpr\u00e9ift den Acc\u00e8s jeton an prob\u00e9iert nach emol.", + "unknown": "Onerwaarte Feeler beim ariichten vun der Integratioun." + }, + "step": { + "user": { + "data": { + "access_token": "Acc\u00e8s Jeton fir Vilfo Router API", + "host": "Router Numm oder IP" + }, + "description": "Vilfo Router Integratioun ariichten. Dir braucht \u00e4re Vilfo Router Numm/IP an een API Acc\u00e8s Jeton. Fir weider Informatiounen zu d\u00ebser Integratioun a w\u00e9i een zu d\u00ebsen n\u00e9idegen Informatioune k\u00ebnnt, gitt op: https://www.home-assistant.io/integrations/vilfo", + "title": "Mam Vilfo Router verbannen" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/nl.json b/homeassistant/components/vilfo/.translations/nl.json new file mode 100644 index 00000000000000..db2691d3eebd2a --- /dev/null +++ b/homeassistant/components/vilfo/.translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Deze Vilfo Router is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kon niet verbinden. Controleer de door u verstrekte informatie en probeer het opnieuw.", + "unknown": "Er is een onverwachte fout opgetreden tijdens het instellen van de integratie." + }, + "step": { + "user": { + "data": { + "access_token": "Toegangstoken voor de Vilfo Router API", + "host": "Router hostnaam of IP-adres" + }, + "title": "Maak verbinding met de Vilfo Router" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/no.json b/homeassistant/components/vilfo/.translations/no.json new file mode 100644 index 00000000000000..af72a4bd7b0dba --- /dev/null +++ b/homeassistant/components/vilfo/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Vilfo Ruteren er allerede konfigurert." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes. Vennligst sjekk informasjonen du oppga, og pr\u00f8v igjen.", + "invalid_auth": "Ugyldig godkjenning. Vennligst sjekk access token, og pr\u00f8v p\u00e5 nytt.", + "unknown": "Det oppstod en uventet feil under installasjonen av integrasjonen." + }, + "step": { + "user": { + "data": { + "access_token": "Tilgangstoken for Vilfo Router API", + "host": "Ruter vertsnavn eller IP" + }, + "description": "Konfigurer Vilfo Router-integreringen. Du trenger ditt Vilfo Router vertsnavn/IP og et API-tilgangstoken. Hvis du vil ha mer informasjon om denne integreringen og hvordan du f\u00e5r disse detaljene, kan du g\u00e5 til: https://www.home-assistant.io/integrations/vilfo", + "title": "Koble til Vilfo Ruteren" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/pl.json b/homeassistant/components/vilfo/.translations/pl.json new file mode 100644 index 00000000000000..aef0c14703f61c --- /dev/null +++ b/homeassistant/components/vilfo/.translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ten router Vilfo jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia. Sprawd\u017a wprowadzone dane i spr\u00f3buj ponownie.", + "invalid_auth": "Nieudane uwierzytelnienie. Sprawd\u017a token dost\u0119pu i spr\u00f3buj ponownie.", + "unknown": "Wyst\u0105pi\u0142 nieoczekiwany b\u0142\u0105d podczas konfiguracji integracji." + }, + "step": { + "user": { + "data": { + "access_token": "Token dost\u0119pu do interfejsu API routera Vilfo", + "host": "Nazwa hosta lub adres IP routera" + }, + "description": "Skonfiguruj integracj\u0119 routera Vilfo. Potrzebujesz nazwy hosta/adresu IP routera Vilfo i tokena dost\u0119pu do interfejsu API. Aby uzyska\u0107 dodatkowe informacje na temat tej integracji i sposobu uzyskania niezb\u0119dnych danych do konfiguracji, odwied\u017a: https://www.home-assistant.io/integrations/vilfo", + "title": "Po\u0142\u0105cz si\u0119 z routerem Vilfo" + } + }, + "title": "Router Vilfo" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/ru.json b/homeassistant/components/vilfo/.translations/ru.json new file mode 100644 index 00000000000000..ce8f325e0ead0f --- /dev/null +++ b/homeassistant/components/vilfo/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a API \u0440\u043e\u0443\u0442\u0435\u0440\u0430", + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0430 Vilfo. \u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0440\u043e\u0443\u0442\u0435\u0440\u0430 \u0438 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 API. \u0414\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438, \u043f\u043e\u0441\u0435\u0442\u0438\u0442\u0435 \u0432\u0435\u0431-\u0441\u0430\u0439\u0442: https://www.home-assistant.io/integrations/vilfo.", + "title": "Vilfo Router" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/sl.json b/homeassistant/components/vilfo/.translations/sl.json new file mode 100644 index 00000000000000..a7d683e793cf1f --- /dev/null +++ b/homeassistant/components/vilfo/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ta usmerjevalnik Vilfo je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Povezava ni uspela. Prosimo, preverite informacije, ki ste jih vnesli in poskusite znova.", + "invalid_auth": "Neveljavna avtentikacija. Preverite dostopni \u017eeton in poskusite znova.", + "unknown": "Med nastavitvijo integracije je pri\u0161lo do nepri\u010dakovane napake." + }, + "step": { + "user": { + "data": { + "access_token": "Dostopni \u017eeton za API Vilfo Router", + "host": "Ime gostitelja usmerjevalnika ali IP" + }, + "description": "Nastavite integracijo Vilfo Router. Potrebujete ime gostitelja ali IP Vilfo usmerjevalnika in dostopni \u017eeton API. Za dodatne informacije o tej integraciji in kako do teh podrobnosti obi\u0161\u010dite: https://www.home-assistant.io/integrations/vilfo", + "title": "Pove\u017eite se z usmerjevalnikom Vilfo" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/sv.json b/homeassistant/components/vilfo/.translations/sv.json new file mode 100644 index 00000000000000..69edce6b9d8bb4 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r Vilfo-routern \u00e4r redan konfigurerad." + }, + "error": { + "cannot_connect": "Kunde inte ansluta. V\u00e4nligen kontrollera informationen du angav och f\u00f6rs\u00f6k igen.", + "invalid_auth": "Ogiltig autentisering. V\u00e4nligen kontrollera \u00e5tkomstnyckeln och f\u00f6rs\u00f6k igen.", + "unknown": "Ett ov\u00e4ntat fel intr\u00e4ffade n\u00e4r integrationen skulle konfigureras." + }, + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel f\u00f6r Vilfo-routerns API", + "host": "Routerns v\u00e4rdnamn eller IP-adress" + }, + "description": "St\u00e4ll in Vilfo Router-integrationen. Du beh\u00f6ver din Vilfo-routers v\u00e4rdnamn eller IP-adress och en \u00e5tkomstnyckel till dess API. F\u00f6r ytterligare information om den h\u00e4r integrationen och hur du f\u00e5r fram den n\u00f6dv\u00e4ndiga informationen, bes\u00f6k: https://www.home-assistant.io/integrations/vilfo", + "title": "Anslut till Vilfo-routern" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/zh-Hans.json b/homeassistant/components/vilfo/.translations/zh-Hans.json new file mode 100644 index 00000000000000..788f85b9382a53 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25\u3002\u8bf7\u68c0\u67e5\u8f93\u5165\u4fe1\u606f\u540e\uff0c\u518d\u8bd5\u4e00\u6b21\u3002", + "unknown": "\u8bbe\u7f6e\u6574\u5408\u65f6\u53d1\u751f\u610f\u5916\u9519\u8bef\u3002" + }, + "step": { + "user": { + "data": { + "access_token": "Vilfo \u8def\u7531\u5668 API \u5b58\u53d6\u5bc6\u94a5", + "host": "\u8def\u7531\u5668\u4e3b\u673a\u540d\u6216 IP \u5730\u5740" + }, + "description": "\u8bbe\u7f6e Vilfo \u8def\u7531\u5668\u6574\u5408\u3002\u60a8\u9700\u8981\u8f93\u5165 Vilfo \u8def\u7531\u5668\u4e3b\u673a\u540d/IP \u5730\u5740\u3001API\u5b58\u53d6\u5bc6\u94a5\u3002\u5176\u4ed6\u6574\u5408\u7684\u76f8\u5173\u4fe1\u606f\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/vilfo", + "title": "\u8fde\u63a5\u5230 Vilfo \u8def\u7531\u5668" + } + }, + "title": "Vilfo \u8def\u7531\u5668" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/zh-Hant.json b/homeassistant/components/vilfo/.translations/zh-Hant.json new file mode 100644 index 00000000000000..7553cc683cd38d --- /dev/null +++ b/homeassistant/components/vilfo/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Vilfo \u8def\u7531\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\u3002\u8acb\u6aa2\u67e5\u8f38\u5165\u8cc7\u6599\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_auth": "\u9a57\u8b49\u7121\u6548\uff0c\u8acb\u6aa2\u67e5\u5b58\u53d6\u5bc6\u9470\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "unknown": "\u8a2d\u5b9a\u6574\u5408\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "step": { + "user": { + "data": { + "access_token": "Vilfo \u8def\u7531\u5668 API \u5b58\u53d6\u5bc6\u9470", + "host": "\u8def\u7531\u5668\u4e3b\u6a5f\u7aef\u6216 IP \u4f4d\u5740" + }, + "description": "\u8a2d\u5b9a Vilfo \u8def\u7531\u5668\u6574\u5408\u3002\u9700\u8981\u8f38\u5165 Vilfo \u8def\u7531\u5668\u4e3b\u6a5f\u540d\u7a31/IP \u4f4d\u5740\u3001API \u5b58\u53d6\u5bc6\u9470\u3002\u5176\u4ed6\u6574\u5408\u76f8\u95dc\u8cc7\u8a0a\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/vilfo", + "title": "\u9023\u7dda\u81f3 Vilfo \u8def\u7531\u5668" + } + }, + "title": "Vilfo \u8def\u7531\u5668" + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/__init__.py b/homeassistant/components/vilfo/__init__.py new file mode 100644 index 00000000000000..ffa628d6db2441 --- /dev/null +++ b/homeassistant/components/vilfo/__init__.py @@ -0,0 +1,125 @@ +"""The Vilfo Router integration.""" +import asyncio +from datetime import timedelta +import logging + +from vilfo import Client as VilfoClient +from vilfo.exceptions import VilfoException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import Throttle + +from .const import ATTR_BOOT_TIME, ATTR_LOAD, DOMAIN, ROUTER_DEFAULT_HOST + +PLATFORMS = ["sensor"] + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the Vilfo Router component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Vilfo Router from a config entry.""" + host = entry.data[CONF_HOST] + access_token = entry.data[CONF_ACCESS_TOKEN] + + vilfo_router = VilfoRouterData(hass, host, access_token) + + await vilfo_router.async_update() + + if not vilfo_router.available: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = vilfo_router + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class VilfoRouterData: + """Define an object to hold sensor data.""" + + def __init__(self, hass, host, access_token): + """Initialize.""" + self._vilfo = VilfoClient(host, access_token) + self.hass = hass + self.host = host + self.available = False + self.firmware_version = None + self.mac_address = self._vilfo.mac + self.data = {} + self._unavailable_logged = False + + @property + def unique_id(self): + """Get the unique_id for the Vilfo Router.""" + if self.mac_address: + return self.mac_address + + if self.host == ROUTER_DEFAULT_HOST: + return self.host + + return self.host + + def _fetch_data(self): + board_information = self._vilfo.get_board_information() + load = self._vilfo.get_load() + + return { + "board_information": board_information, + "load": load, + } + + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update data using calls to VilfoClient library.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_data) + + self.firmware_version = data["board_information"]["version"] + self.data[ATTR_BOOT_TIME] = data["board_information"]["bootTime"] + self.data[ATTR_LOAD] = data["load"] + + self.available = True + except VilfoException as error: + if not self._unavailable_logged: + _LOGGER.error( + "Could not fetch data from %s, error: %s", self.host, error + ) + self._unavailable_logged = True + self.available = False + return + + if self.available and self._unavailable_logged: + _LOGGER.info("Vilfo Router %s is available again", self.host) + self._unavailable_logged = False diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py new file mode 100644 index 00000000000000..2b9df3d919565e --- /dev/null +++ b/homeassistant/components/vilfo/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for Vilfo Router integration.""" +import ipaddress +import logging +import re + +from vilfo import Client as VilfoClient +from vilfo.exceptions import ( + AuthenticationException as VilfoAuthenticationException, + VilfoException, +) +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_ID, CONF_MAC + +from .const import DOMAIN # pylint:disable=unused-import +from .const import ROUTER_DEFAULT_HOST + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=ROUTER_DEFAULT_HOST): str, + vol.Required(CONF_ACCESS_TOKEN, default=""): str, + } +) + +RESULT_SUCCESS = "success" +RESULT_CANNOT_CONNECT = "cannot_connect" +RESULT_INVALID_AUTH = "invalid_auth" + + +def host_valid(host): + """Return True if hostname or IP address is valid.""" + try: + if ipaddress.ip_address(host).version == (4 or 6): + return True + except ValueError: + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) + + +def _try_connect_and_fetch_basic_info(host, token): + """Attempt to connect and call the ping endpoint and, if successful, fetch basic information.""" + + # Perform the ping. This doesn't validate authentication. + controller = VilfoClient(host=host, token=token) + result = {"type": None, "data": {}} + + try: + controller.ping() + except VilfoException: + result["type"] = RESULT_CANNOT_CONNECT + result["data"] = CannotConnect + return result + + # Perform a call that requires authentication. + try: + controller.get_board_information() + except VilfoAuthenticationException: + result["type"] = RESULT_INVALID_AUTH + result["data"] = InvalidAuth + return result + + if controller.mac: + result["data"][CONF_ID] = controller.mac + result["data"][CONF_MAC] = controller.mac + else: + result["data"][CONF_ID] = host + result["data"][CONF_MAC] = None + + result["type"] = RESULT_SUCCESS + + return result + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + # Validate the host before doing anything else. + if not host_valid(data[CONF_HOST]): + raise InvalidHost + + config = {} + + result = await hass.async_add_executor_job( + _try_connect_and_fetch_basic_info, data[CONF_HOST], data[CONF_ACCESS_TOKEN] + ) + + if result["type"] != RESULT_SUCCESS: + raise result["data"] + + # Return some info we want to store in the config entry. + result_data = result["data"] + config["title"] = f"{data[CONF_HOST]}" + config[CONF_MAC] = result_data[CONF_MAC] + config[CONF_HOST] = data[CONF_HOST] + config[CONF_ID] = result_data[CONF_ID] + + return config + + +class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Vilfo Router.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidHost: + errors[CONF_HOST] = "wrong_host" + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidHost(exceptions.HomeAssistantError): + """Error to indicate that hostname/IP address is invalid.""" diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py new file mode 100644 index 00000000000000..1a40b8430d7f89 --- /dev/null +++ b/homeassistant/components/vilfo/const.py @@ -0,0 +1,36 @@ +"""Constants for the Vilfo Router integration.""" +from homeassistant.const import DEVICE_CLASS_TIMESTAMP + +DOMAIN = "vilfo" + +ATTR_API_DATA_FIELD = "api_data_field" +ATTR_API_DATA_FIELD_LOAD = "load" +ATTR_API_DATA_FIELD_BOOT_TIME = "boot_time" +ATTR_DEVICE_CLASS = "device_class" +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_LOAD = "load" +ATTR_UNIT = "unit" +ATTR_BOOT_TIME = "boot_time" + +ROUTER_DEFAULT_HOST = "admin.vilfo.com" +ROUTER_DEFAULT_MODEL = "Vilfo Router" +ROUTER_DEFAULT_NAME = "Vilfo Router" +ROUTER_MANUFACTURER = "Vilfo AB" + +UNIT_PERCENT = "%" + +SENSOR_TYPES = { + ATTR_LOAD: { + ATTR_LABEL: "Load", + ATTR_UNIT: UNIT_PERCENT, + ATTR_ICON: "mdi:memory", + ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD, + }, + ATTR_BOOT_TIME: { + ATTR_LABEL: "Boot time", + ATTR_ICON: "mdi:timer", + ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_BOOT_TIME, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + }, +} diff --git a/homeassistant/components/vilfo/manifest.json b/homeassistant/components/vilfo/manifest.json new file mode 100644 index 00000000000000..cedb485fab3c37 --- /dev/null +++ b/homeassistant/components/vilfo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "vilfo", + "name": "Vilfo Router", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vilfo", + "requirements": ["vilfo-api-client==0.3.2"], + "dependencies": [], + "codeowners": ["@ManneW"] +} diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py new file mode 100644 index 00000000000000..e2909647c2d6ee --- /dev/null +++ b/homeassistant/components/vilfo/sensor.py @@ -0,0 +1,94 @@ +"""Support for Vilfo Router sensors.""" +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_API_DATA_FIELD, + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_LABEL, + ATTR_UNIT, + DOMAIN, + ROUTER_DEFAULT_MODEL, + ROUTER_DEFAULT_NAME, + ROUTER_MANUFACTURER, + SENSOR_TYPES, +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add Vilfo Router entities from a config_entry.""" + vilfo = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [] + + for sensor_type in SENSOR_TYPES: + sensors.append(VilfoRouterSensor(sensor_type, vilfo)) + + async_add_entities(sensors, True) + + +class VilfoRouterSensor(Entity): + """Define a Vilfo Router Sensor.""" + + def __init__(self, sensor_type, api): + """Initialize.""" + self.api = api + self.sensor_type = sensor_type + self._device_info = { + "identifiers": {(DOMAIN, api.host, api.mac_address)}, + "name": ROUTER_DEFAULT_NAME, + "manufacturer": ROUTER_MANUFACTURER, + "model": ROUTER_DEFAULT_MODEL, + "sw_version": api.firmware_version, + } + self._unique_id = f"{self.api.unique_id}_{self.sensor_type}" + self._state = None + + @property + def available(self): + """Return whether the sensor is available or not.""" + return self.api.available + + @property + def device_info(self): + """Return the device info.""" + return self._device_info + + @property + def device_class(self): + """Return the device class.""" + return SENSOR_TYPES[self.sensor_type].get(ATTR_DEVICE_CLASS) + + @property + def icon(self): + """Return the icon for the sensor.""" + return SENSOR_TYPES[self.sensor_type][ATTR_ICON] + + @property + def name(self): + """Return the name of the sensor.""" + parent_device_name = self._device_info["name"] + sensor_name = SENSOR_TYPES[self.sensor_type][ATTR_LABEL] + return f"{parent_device_name} {sensor_name}" + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return SENSOR_TYPES[self.sensor_type].get(ATTR_UNIT) + + async def async_update(self): + """Update the router data.""" + await self.api.async_update() + self._state = self.api.data.get( + SENSOR_TYPES[self.sensor_type][ATTR_API_DATA_FIELD] + ) diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json new file mode 100644 index 00000000000000..e7a55c55f1f76a --- /dev/null +++ b/homeassistant/components/vilfo/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "Vilfo Router", + "step": { + "user": { + "title": "Connect to the Vilfo Router", + "description": "Set up the Vilfo Router integration. You need your Vilfo Router hostname/IP and an API access token. For additional information on this integration and how to get those details, visit: https://www.home-assistant.io/integrations/vilfo", + "data": { + "host": "Router hostname or IP", + "access_token": "Access token for the Vilfo Router API" + } + } + }, + "error": { + "cannot_connect": "Failed to connect. Please check the information you provided and try again.", + "invalid_auth": "Invalid authentication. Please check the access token and try again.", + "unknown": "An unexpected error occurred while setting up the integration." + }, + "abort": { + "already_configured": "This Vilfo Router is already configured." + } + } +} diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index f4a195f5b0c03b..6bf9fdace5a77b 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -25,8 +25,8 @@ CONF_SECURITY_LEVEL = "security_level" CONF_STREAM_PATH = "stream_path" -DEFAULT_CAMERA_BRAND = "Vivotek" -DEFAULT_NAME = "Vivotek Camera" +DEFAULT_CAMERA_BRAND = "VIVOTEK" +DEFAULT_NAME = "VIVOTEK Camera" DEFAULT_EVENT_0_KEY = "event_i0_enable" DEFAULT_SECURITY_LEVEL = "admin" DEFAULT_STREAM_SOURCE = "live.sdp" diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json index 9246bc4c89ba1a..3b4a4211f34dbd 100644 --- a/homeassistant/components/vivotek/manifest.json +++ b/homeassistant/components/vivotek/manifest.json @@ -1,6 +1,6 @@ { "domain": "vivotek", - "name": "Vivotek", + "name": "VIVOTEK", "documentation": "https://www.home-assistant.io/integrations/vivotek", "requirements": ["libpyvivotek==0.4.0"], "dependencies": [], diff --git a/homeassistant/components/vizio/.translations/ca.json b/homeassistant/components/vizio/.translations/ca.json index abbf1092bf3b92..834138e92214d7 100644 --- a/homeassistant/components/vizio/.translations/ca.json +++ b/homeassistant/components/vizio/.translations/ca.json @@ -1,8 +1,20 @@ { "config": { + "abort": { + "already_in_progress": "El flux de dades de configuraci\u00f3 pel component Vizio ja est\u00e0 en curs.", + "already_setup": "Aquesta entrada ja ha estat configurada.", + "already_setup_with_diff_host_and_name": "Sembla que aquesta entrada ja s'ha configurat amb un amfitri\u00f3 i nom diferents a partir del n\u00famero de s\u00e8rie. Elimina les entrades antigues de configuraction.yaml i del men\u00fa d'integracions abans de provar d'afegir el dispositiu novament.", + "host_exists": "Ja existeix un component Vizio configurat amb el host.", + "name_exists": "Ja existeix un component Vizio configurat amb el nom.", + "updated_entry": "Aquesta entrada ja s'ha configurat per\u00f2 el nom i les opcions definides a la configuraci\u00f3 no coincideixen amb els valors importats anteriorment, en conseq\u00fc\u00e8ncia, s'han actualitzat.", + "updated_options": "Aquesta entrada ja s'ha configurat per\u00f2 les opcions definides a la configuraci\u00f3 no coincideixen amb els valors importats anteriorment, en conseq\u00fc\u00e8ncia, s'han actualitzat.", + "updated_volume_step": "Aquesta entrada ja s'ha configurat per\u00f2 la mida de l'increment de volum definit a la configuraci\u00f3 no coincideix, en conseq\u00fc\u00e8ncia, s'ha actualitzat." + }, "error": { - "host_exists": "L'amfitri\u00f3 ja est\u00e0 configurat.", - "name_exists": "El nom ja est\u00e0 configurat." + "cant_connect": "No s'ha pogut connectar amb el dispositiu. [Comprova la documentaci\u00f3](https://www.home-assistant.io/integrations/vizio/) i torna a verificar que: \n - El dispositiu est\u00e0 engegat \n - El dispositiu est\u00e0 connectat a la xarxa \n - Els valors que has intridu\u00eft s\u00f3n correctes\n abans d\u2019intentar tornar a presentar.", + "host_exists": "Dispositiu Vizio amb aquest nom d'amfitri\u00f3 ja configurat.", + "name_exists": "Dispositiu Vizio amb aquest nom ja configurat.", + "tv_needs_token": "Si el tipus de dispositiu \u00e9s 'tv', cal un testimoni d'acc\u00e9s v\u00e0lid (token)." }, "step": { "user": { @@ -21,9 +33,12 @@ "step": { "init": { "data": { + "timeout": "Temps d'espera de les sol\u00b7licituds API (en segons)", "volume_step": "Mida del pas de volum" - } + }, + "title": "Actualitzaci\u00f3 de les opcions de Vizo SmartCast" } - } + }, + "title": "Actualitzaci\u00f3 de les opcions de Vizo SmartCast" } } \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/da.json b/homeassistant/components/vizio/.translations/da.json index dc5de132cc2334..9bfd5864025c4c 100644 --- a/homeassistant/components/vizio/.translations/da.json +++ b/homeassistant/components/vizio/.translations/da.json @@ -6,13 +6,14 @@ "already_setup_with_diff_host_and_name": "Denne post ser ud til allerede at v\u00e6re konfigureret med en anden v\u00e6rt og navn baseret p\u00e5 dens serienummer. Fjern eventuelle gamle poster fra din configuration.yaml og i menuen Integrationer, f\u00f8r du fors\u00f8ger at tilf\u00f8je denne enhed igen.", "host_exists": "Vizio-komponent med v\u00e6rt er allerede konfigureret.", "name_exists": "Vizio-komponent med navn er allerede konfigureret.", + "updated_entry": "Denne post er allerede konfigureret, men navnet og/eller indstillingerne, der er defineret i konfigurationen, stemmer ikke overens med den tidligere importerede konfiguration, s\u00e5 konfigurationsposten er blevet opdateret i overensstemmelse hermed.", "updated_options": "Denne post er allerede konfigureret, men indstillingerne, der er defineret i konfigurationen, stemmer ikke overens med de tidligere importerede indstillingsv\u00e6rdier, s\u00e5 konfigurationsposten er blevet opdateret i overensstemmelse hermed.", "updated_volume_step": "Denne post er allerede konfigureret, men lydstyrketrinst\u00f8rrelsen i konfigurationen stemmer ikke overens med konfigurationsposten, s\u00e5 konfigurationsposten er blevet opdateret i overensstemmelse hermed." }, "error": { "cant_connect": "Kunne ikke oprette forbindelse til enheden. [Gennemg\u00e5 dokumentationen] (https://www.home-assistant.io/integrations/vizio/), og bekr\u00e6ft, at: \n - Enheden er t\u00e6ndt \n - Enheden er tilsluttet netv\u00e6rket \n - De angivne v\u00e6rdier er korrekte \n f\u00f8r du fors\u00f8ger at indsende igen.", - "host_exists": "V\u00e6rt er allerede konfigureret.", - "name_exists": "Navn er allerede konfigureret.", + "host_exists": "Vizio-enhed med den specificerede v\u00e6rt er allerede konfigureret.", + "name_exists": "Vizio-enhed med det specificerede navn er allerede konfigureret.", "tv_needs_token": "N\u00e5r enhedstypen er 'tv', skal der bruges en gyldig adgangstoken." }, "step": { @@ -23,7 +24,7 @@ "host": ":", "name": "Navn" }, - "title": "Ops\u00e6tning af Vizio SmartCast-klient" + "title": "Ops\u00e6t Vizio SmartCast-enhed" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/.translations/en.json b/homeassistant/components/vizio/.translations/en.json index 60fd9049bb3746..cee436c9647947 100644 --- a/homeassistant/components/vizio/.translations/en.json +++ b/homeassistant/components/vizio/.translations/en.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "This entry appears to have already been setup with a different host and name based on its serial number. Please remove any old entries from your configuration.yaml and from the Integrations menu before reattempting to add this device.", "host_exists": "Vizio component with host already configured.", "name_exists": "Vizio component with name already configured.", + "updated_entry": "This entry has already been setup but the name and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly.", "updated_options": "This entry has already been setup but the options defined in the config do not match the previously imported options values so the config entry has been updated accordingly.", "updated_volume_step": "This entry has already been setup but the volume step size in the config does not match the config entry so the config entry has been updated accordingly." }, @@ -23,7 +24,7 @@ "host": ":", "name": "Name" }, - "title": "Setup Vizio SmartCast Client" + "title": "Setup Vizio SmartCast Device" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/.translations/es.json b/homeassistant/components/vizio/.translations/es.json index 997dde7088a182..408d94825f1569 100644 --- a/homeassistant/components/vizio/.translations/es.json +++ b/homeassistant/components/vizio/.translations/es.json @@ -3,8 +3,10 @@ "abort": { "already_in_progress": "Configurar el flujo para el componente vizio que ya est\u00e1 en marcha.", "already_setup": "Esta entrada ya ha sido configurada.", + "already_setup_with_diff_host_and_name": "Esta entrada parece haber sido ya configurada con un host y un nombre diferentes basados en su n\u00famero de serie. Elimine las entradas antiguas de su archivo configuration.yaml y del men\u00fa Integraciones antes de volver a intentar agregar este dispositivo.", "host_exists": "Host ya configurado del componente de Vizio", "name_exists": "Nombre ya configurado del componente de Vizio", + "updated_entry": "Esta entrada ya ha sido configurada pero el nombre y/o las opciones definidas en la configuraci\u00f3n no coinciden con la configuraci\u00f3n previamente importada, por lo que la entrada de la configuraci\u00f3n ha sido actualizada en consecuencia.", "updated_options": "Esta entrada ya ha sido configurada pero las opciones definidas en la configuraci\u00f3n no coinciden con los valores de las opciones importadas previamente, por lo que la entrada de la configuraci\u00f3n ha sido actualizada en consecuencia.", "updated_volume_step": "Esta entrada ya ha sido configurada pero el tama\u00f1o del paso de volumen en la configuraci\u00f3n no coincide con la entrada de la configuraci\u00f3n, por lo que la entrada de la configuraci\u00f3n ha sido actualizada en consecuencia." }, diff --git a/homeassistant/components/vizio/.translations/fr.json b/homeassistant/components/vizio/.translations/fr.json index 78d2347bfac848..cf0cdea787f1be 100644 --- a/homeassistant/components/vizio/.translations/fr.json +++ b/homeassistant/components/vizio/.translations/fr.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "Cette entr\u00e9e semble avoir d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e avec un h\u00f4te et un nom diff\u00e9rents en fonction de son num\u00e9ro de s\u00e9rie. Veuillez supprimer toutes les anciennes entr\u00e9es de votre configuration.yaml et du menu Int\u00e9grations avant de r\u00e9essayer d'ajouter ce p\u00e9riph\u00e9rique.", "host_exists": "Composant Vizio avec h\u00f4te d\u00e9j\u00e0 configur\u00e9.", "name_exists": "Composant Vizio dont le nom est d\u00e9j\u00e0 configur\u00e9.", + "updated_entry": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e mais le nom et/ou les options d\u00e9finis dans la configuration ne correspondent pas \u00e0 la configuration pr\u00e9c\u00e9demment import\u00e9e, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence.", "updated_options": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e mais les options d\u00e9finies dans la configuration ne correspondent pas aux valeurs des options pr\u00e9c\u00e9demment import\u00e9es, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence.", "updated_volume_step": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e, mais la taille du pas du volume dans la configuration ne correspond pas \u00e0 l'entr\u00e9e de configuration, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence." }, @@ -32,7 +33,8 @@ "step": { "init": { "data": { - "timeout": "D\u00e9lai d'expiration de la demande d'API (secondes)" + "timeout": "D\u00e9lai d'expiration de la demande d'API (secondes)", + "volume_step": "Taille du pas de volume" }, "title": "Mettre \u00e0 jour les options de Vizo SmartCast" } diff --git a/homeassistant/components/vizio/.translations/hu.json b/homeassistant/components/vizio/.translations/hu.json new file mode 100644 index 00000000000000..650d5133dbda1b --- /dev/null +++ b/homeassistant/components/vizio/.translations/hu.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_in_progress": "A vizio komponens konfigur\u00e1ci\u00f3s folyamata m\u00e1r folyamatban van.", + "already_setup": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva.", + "already_setup_with_diff_host_and_name": "\u00dagy t\u0171nik, hogy ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva egy m\u00e1sik \u00e1llom\u00e1ssal \u00e9s n\u00e9vvel a sorozatsz\u00e1ma alapj\u00e1n. T\u00e1vol\u00edtsa el a r\u00e9gi bejegyz\u00e9seket a configuration.yaml \u00e9s az Integr\u00e1ci\u00f3k men\u00fcb\u0151l, miel\u0151tt \u00fajra megpr\u00f3b\u00e1ln\u00e1 hozz\u00e1adni ezt az eszk\u00f6zt.", + "host_exists": "Vizio-\u00f6sszetev\u0151, amelynek az kiszolg\u00e1l\u00f3neve m\u00e1r konfigur\u00e1lva van.", + "name_exists": "Vizio-\u00f6sszetev\u0151, amelynek neve m\u00e1r konfigur\u00e1lva van.", + "updated_entry": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva, de a konfigur\u00e1ci\u00f3ban defini\u00e1lt n\u00e9v \u00e9s/vagy be\u00e1ll\u00edt\u00e1sok nem egyeznek meg a kor\u00e1bban import\u00e1lt konfigur\u00e1ci\u00f3val, \u00edgy a konfigur\u00e1ci\u00f3s bejegyz\u00e9s ennek megfelel\u0151en friss\u00fclt.", + "updated_options": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva, de a konfigur\u00e1ci\u00f3ban megadott be\u00e1ll\u00edt\u00e1sok nem egyeznek meg a kor\u00e1bban import\u00e1lt be\u00e1ll\u00edt\u00e1si \u00e9rt\u00e9kekkel, \u00edgy a konfigur\u00e1ci\u00f3s bejegyz\u00e9s ennek megfelel\u0151en friss\u00fclt.", + "updated_volume_step": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva, de a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151 henger\u0151l\u00e9p\u00e9s m\u00e9rete nem egyezik meg a konfigur\u00e1ci\u00f3s bejegyz\u00e9ssel, \u00edgy a konfigur\u00e1ci\u00f3s bejegyz\u00e9s ennek megfelel\u0151en friss\u00fclt." + }, + "error": { + "cant_connect": "Nem lehetett csatlakozni az eszk\u00f6zh\u00f6z. [Tekintsd \u00e1t a dokumentumokat] (https://www.home-assistant.io/integrations/vizio/) \u00e9s \u00fajra ellen\u0151rizd, hogy:\n- A k\u00e9sz\u00fcl\u00e9k be van kapcsolva\n- A k\u00e9sz\u00fcl\u00e9k csatlakozik a h\u00e1l\u00f3zathoz\n- A kit\u00f6lt\u00f6tt \u00e9rt\u00e9kek pontosak\nmiel\u0151tt \u00fajra elk\u00fclden\u00e9d.", + "host_exists": "A megadott kiszolg\u00e1l\u00f3n\u00e9vvel rendelkez\u0151 Vizio-eszk\u00f6z m\u00e1r konfigur\u00e1lva van.", + "name_exists": "A megadott n\u00e9vvel rendelkez\u0151 Vizio-eszk\u00f6z m\u00e1r konfigur\u00e1lva van.", + "tv_needs_token": "Ha az eszk\u00f6z t\u00edpusa \"tv\", akkor \u00e9rv\u00e9nyes hozz\u00e1f\u00e9r\u00e9si tokenre van sz\u00fcks\u00e9g." + }, + "step": { + "user": { + "data": { + "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", + "device_class": "Eszk\u00f6zt\u00edpus", + "name": "N\u00e9v" + }, + "title": "A Vizio SmartCast Client be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Vizio SmartCast" + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "API-k\u00e9r\u00e9s id\u0151t\u00fall\u00e9p\u00e9se (m\u00e1sodpercben)", + "volume_step": "Hanger\u0151 l\u00e9p\u00e9s nagys\u00e1ga" + }, + "title": "Friss\u00edtse a Vizo SmartCast be\u00e1ll\u00edt\u00e1sokat" + } + }, + "title": "Friss\u00edtse a Vizo SmartCast be\u00e1ll\u00edt\u00e1sokat" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/it.json b/homeassistant/components/vizio/.translations/it.json index 83c72912618b86..dd27133453e4ae 100644 --- a/homeassistant/components/vizio/.translations/it.json +++ b/homeassistant/components/vizio/.translations/it.json @@ -6,13 +6,14 @@ "already_setup_with_diff_host_and_name": "Sembra che questa voce sia gi\u00e0 stata configurata con un host e un nome diversi in base al suo numero seriale. Rimuovere eventuali voci precedenti da configuration.yaml e dal menu Integrazioni prima di tentare nuovamente di aggiungere questo dispositivo.", "host_exists": "Componente Vizio con host gi\u00e0 configurato.", "name_exists": "Componente Vizio con nome gi\u00e0 configurato.", + "updated_entry": "Questa voce \u00e8 gi\u00e0 stata configurata, ma il nome e/o le opzioni definite nella configurazione non corrispondono alla configurazione importata in precedenza, pertanto la voce di configurazione \u00e8 stata aggiornata di conseguenza.", "updated_options": "Questa voce \u00e8 gi\u00e0 stata impostata, ma le opzioni definite nella configurazione non corrispondono ai valori delle opzioni importate in precedenza, quindi la voce di configurazione \u00e8 stata aggiornata di conseguenza.", "updated_volume_step": "Questa voce \u00e8 gi\u00e0 stata impostata, ma la dimensione del passo del volume nella configurazione non corrisponde alla voce di configurazione, quindi \u00e8 stata aggiornata di conseguenza." }, "error": { "cant_connect": "Impossibile connettersi al dispositivo. [Esamina i documenti] (https://www.home-assistant.io/integrations/vizio/) e verifica nuovamente che: \n - Il dispositivo sia acceso \n - Il dispositivo sia collegato alla rete \n - I valori inseriti siano corretti \n prima di ritentare.", - "host_exists": "Host gi\u00e0 configurato.", - "name_exists": "Nome gi\u00e0 configurato.", + "host_exists": "Dispositivo Vizio con host specificato gi\u00e0 configurato.", + "name_exists": "Dispositivo Vizio con il nome specificato gi\u00e0 configurato.", "tv_needs_token": "Quando Device Type \u00e8 `tv`, \u00e8 necessario un token di accesso valido." }, "step": { diff --git a/homeassistant/components/vizio/.translations/ko.json b/homeassistant/components/vizio/.translations/ko.json index 3e54d343f7a6a8..64c0887b3f8cbb 100644 --- a/homeassistant/components/vizio/.translations/ko.json +++ b/homeassistant/components/vizio/.translations/ko.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "\uc774 \ud56d\ubaa9\uc740 \uc2dc\ub9ac\uc5bc \ubc88\ud638\ub85c \ub2e4\ub978 \ud638\uc2a4\ud2b8 \ubc0f \uc774\ub984\uc73c\ub85c \uc774\ubbf8 \uc124\uc815\ub418\uc5b4\uc788\ub294 \uac83\uc73c\ub85c \ubcf4\uc785\ub2c8\ub2e4. \uc774 \uae30\uae30\ub97c \ucd94\uac00\ud558\uae30 \uc804\uc5d0 configuration.yaml \ubc0f \ud1b5\ud569 \uad6c\uc131\uc694\uc18c \uba54\ub274\uc5d0\uc11c \uc774\uc804 \ud56d\ubaa9\uc744 \uc81c\uac70\ud574\uc8fc\uc138\uc694.", "host_exists": "\ud574\ub2f9 \ud638\uc2a4\ud2b8\uc758 Vizio \uad6c\uc131 \uc694\uc18c\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "name_exists": "\ud574\ub2f9 \uc774\ub984\uc758 Vizio \uad6c\uc131 \uc694\uc18c\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "updated_entry": "\uc774 \ud56d\ubaa9\uc740 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc9c0\ub9cc \uad6c\uc131\uc5d0 \uc815\uc758\ub41c \uc774\ub984\uc774\ub098 \uc635\uc158\uc774 \uc774\uc804\uc5d0 \uac00\uc838\uc628 \uad6c\uc131 \ub0b4\uc6a9\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uad6c\uc131 \ud56d\ubaa9\uc774 \uadf8\uc5d0 \ub530\ub77c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "updated_options": "\uc774 \ud56d\ubaa9\uc740 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc9c0\ub9cc \uad6c\uc131\uc5d0 \uc815\uc758\ub41c \uc635\uc158\uc774 \uc774\uc804\uc5d0 \uac00\uc838\uc628 \uc635\uc158 \uac12\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uad6c\uc131 \ud56d\ubaa9\uc774 \uadf8\uc5d0 \ub530\ub77c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "updated_volume_step": "\uc774 \ud56d\ubaa9\uc740 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc9c0\ub9cc \uad6c\uc131\uc758 \ubcfc\ub968 \ub2e8\uacc4 \ud06c\uae30\uac00 \uad6c\uc131 \ud56d\ubaa9\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uad6c\uc131 \ud56d\ubaa9\uc774 \uadf8\uc5d0 \ub530\ub77c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, @@ -23,7 +24,7 @@ "host": "<\ud638\uc2a4\ud2b8/ip>:", "name": "\uc774\ub984" }, - "title": "Vizio SmartCast \ud074\ub77c\uc774\uc5b8\ud2b8 \uc124\uc815" + "title": "Vizio SmartCast \uae30\uae30 \uc124\uc815" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/.translations/lb.json b/homeassistant/components/vizio/.translations/lb.json index 965dd7af841586..809ae6d4eb57f1 100644 --- a/homeassistant/components/vizio/.translations/lb.json +++ b/homeassistant/components/vizio/.translations/lb.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "D\u00ebs Entr\u00e9e ass scho konfigur\u00e9iert mat engem aneren Host an Numm bas\u00e9ierend unhand vu\u00a0senger Seriennummer. L\u00e4scht w.e.g. al Entr\u00e9e vun \u00e4rer configuration.yaml a\u00a0vum Integratioun's Men\u00fc ier dir prob\u00e9iert d\u00ebsen Apparate r\u00ebm b\u00e4i ze setzen.", "host_exists": "Vizio Komponent mam Host ass schon konfigur\u00e9iert.", "name_exists": "Vizio Komponent mam Numm ass scho konfigur\u00e9iert.", + "updated_entry": "D\u00ebs Entr\u00e9e ass scho konfigur\u00e9iert mee d\u00e9i defin\u00e9ierten Numm an/oder Optiounen an der Konfiguratioun st\u00ebmmen net mat deene virdrun import\u00e9ierten Optiounen iwwereneen, esou gouf d'Entr\u00e9e deementspriechend aktualis\u00e9iert.", "updated_options": "D\u00ebs Entr\u00e9e ass scho konfigur\u00e9iert mee d\u00e9i defin\u00e9iert Optiounen an der Konfiguratioun st\u00ebmmen net mat deene virdrun import\u00e9ierten Optiounen iwwereneen, esou gouf d'Entr\u00e9e deementspriechend aktualis\u00e9iert.", "updated_volume_step": "D\u00ebs Entr\u00e9e ass scho konfigur\u00e9iert mee d\u00e9i defin\u00e9iert Lautst\u00e4erkt Schr\u00ebtt Gr\u00e9isst an der Konfiguratioun st\u00ebmmt net mat der Konfiguratioun iwwereneen, esou gouf d'Entr\u00e9e deementspriechend aktualis\u00e9iert." }, diff --git a/homeassistant/components/vizio/.translations/nl.json b/homeassistant/components/vizio/.translations/nl.json new file mode 100644 index 00000000000000..bbc95d73bbc9ca --- /dev/null +++ b/homeassistant/components/vizio/.translations/nl.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_in_progress": "Configuratie stroom voor vizio component al in uitvoering.", + "already_setup": "Dit item is al ingesteld.", + "already_setup_with_diff_host_and_name": "Dit item lijkt al te zijn ingesteld met een andere host en naam op basis van het serienummer. Verwijder alle oude vermeldingen uit uw configuratie.yaml en uit het menu Integraties voordat u opnieuw probeert dit apparaat toe te voegen.", + "host_exists": "Vizio apparaat met opgegeven host al geconfigureerd.", + "name_exists": "Vizio apparaat met opgegeven naam al geconfigureerd.", + "updated_entry": "Dit item is al ingesteld, maar de naam en/of opties die zijn gedefinieerd in de configuratie komen niet overeen met de eerder ge\u00efmporteerde configuratie, dus het configuratie-item is dienovereenkomstig bijgewerkt.", + "updated_options": "Dit item is al ingesteld, maar de opties die in de configuratie zijn gedefinieerd komen niet overeen met de eerder ge\u00efmporteerde optiewaarden, dus de configuratie-invoer is dienovereenkomstig bijgewerkt.", + "updated_volume_step": "Dit item is al ingesteld, maar de volumestapgrootte in de configuratie komt niet overeen met het configuratie-item, dus het configuratie-item is dienovereenkomstig bijgewerkt." + }, + "error": { + "cant_connect": "Kan geen verbinding maken met het apparaat. [Bekijk de documenten] (https://www.home-assistant.io/integrations/vizio/) en controleer of:\n- Het apparaat is ingeschakeld\n- Het apparaat is aangesloten op het netwerk\n- De waarden die u ingevuld correct zijn\nvoordat u weer probeert om opnieuw in te dienen.", + "host_exists": "Vizio apparaat met opgegeven host al geconfigureerd.", + "name_exists": "Vizio apparaat met opgegeven naam al geconfigureerd.", + "tv_needs_token": "Wanneer het apparaattype `tv` is, dan is er een geldig toegangstoken nodig." + }, + "step": { + "user": { + "data": { + "access_token": "Toegangstoken", + "device_class": "Apparaattype", + "host": ":", + "name": "Naam" + }, + "title": "Vizio SmartCast Client instellen" + } + }, + "title": "Vizio SmartCast" + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "Time-out van API-aanvragen (seconden)", + "volume_step": "Volume Stapgrootte" + }, + "title": "Update Vizo SmartCast Opties" + } + }, + "title": "Update Vizo SmartCast Opties" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/no.json b/homeassistant/components/vizio/.translations/no.json index cdf16bfe28d318..0b92497a5e7c33 100644 --- a/homeassistant/components/vizio/.translations/no.json +++ b/homeassistant/components/vizio/.translations/no.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "Denne oppf\u00f8ringen ser ut til \u00e5 allerede v\u00e6re konfigurert med en annen vert og navn basert p\u00e5 serienummeret. Fjern den gamle oppf\u00f8ringer fra konfigurasjonen.yaml og fra integrasjonsmenyen f\u00f8r du pr\u00f8ver ut \u00e5 legge til denne enheten p\u00e5 nytt.", "host_exists": "Vizio komponent med vert allerede konfigurert.", "name_exists": "Vizio-komponent med navn som allerede er konfigurert.", + "updated_entry": "Denne oppf\u00f8ringen er allerede konfigurert, men navnet og / eller alternativene som er definert i konfigurasjonen samsvarer ikke med den tidligere importerte konfigurasjonen, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter.", "updated_options": "Denne oppf\u00f8ringen er allerede konfigurert, men alternativene som er definert i konfigurasjonen samsvarer ikke med de tidligere importerte alternativverdiene, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter.", "updated_volume_step": "Denne oppf\u00f8ringen er allerede konfigurert, men volumstrinnst\u00f8rrelsen i konfigurasjonen samsvarer ikke med konfigurasjonsoppf\u00f8ringen, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter." }, @@ -23,7 +24,7 @@ "host": ":", "name": "Navn" }, - "title": "Oppsett Vizio SmartCast Client" + "title": "Sett opp Vizio SmartCast-enhet" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/.translations/pl.json b/homeassistant/components/vizio/.translations/pl.json index ad79dc827c4b5c..cba9f4319f5278 100644 --- a/homeassistant/components/vizio/.translations/pl.json +++ b/homeassistant/components/vizio/.translations/pl.json @@ -1,19 +1,20 @@ { "config": { "abort": { - "already_in_progress": "Trwa konfiguracja przep\u0142ywu dla komponentu Vizio.", - "already_setup": "Ten wpis zosta\u0142 ju\u017c skonfigurowany.", - "already_setup_with_diff_host_and_name": "Wygl\u0105da na to, \u017ce ten wpis zosta\u0142 ju\u017c skonfigurowany z innym hostem i nazw\u0105 na podstawie jego numeru seryjnego. Usu\u0144 wszystkie stare wpisy z pliku configuration.yaml iz menu Integracje przed ponown\u0105 pr\u00f3b\u0105 dodania tego urz\u0105dzenia.", - "host_exists": "Komponent Vizio z ju\u017c skonfigurowanym hostem.", - "name_exists": "Komponent Vizio z ju\u017c skonfigurowan\u0105 nazw\u0105.", - "updated_options": "Ten wpis zosta\u0142 ju\u017c skonfigurowany, ale opcje zdefiniowane w konfiguracji nie pasuj\u0105 do wcze\u015bniej zaimportowanych warto\u015bci opcji, wi\u0119c wpis konfiguracji zosta\u0142 odpowiednio zaktualizowany.", - "updated_volume_step": "Ten wpis zosta\u0142 ju\u017c skonfigurowany, ale rozmiar kroku g\u0142o\u015bno\u015bci w konfiguracji nie pasuje do wpisu konfiguracji, wi\u0119c wpis konfiguracji zosta\u0142 odpowiednio zaktualizowany." + "already_in_progress": "Konfiguracja komponentu Vizio jest ju\u017c w trakcie.", + "already_setup": "Ten komponent jest ju\u017c skonfigurowany.", + "already_setup_with_diff_host_and_name": "Wygl\u0105da na to, \u017ce ten wpis zosta\u0142 ju\u017c skonfigurowany z innym hostem i nazw\u0105 na podstawie jego numeru seryjnego. Usu\u0144 wszystkie stare wpisy z pliku configuration.yaml i z menu Integracje przed ponown\u0105 pr\u00f3b\u0105 dodania tego urz\u0105dzenia.", + "host_exists": "Komponent Vizio dla tego hosta jest ju\u017c skonfigurowany.", + "name_exists": "Komponent Vizio dla tej nazwy jest ju\u017c skonfigurowany.", + "updated_entry": "Ten wpis zosta\u0142 ju\u017c skonfigurowany, ale nazwa i/lub opcje zdefiniowane w konfiguracji nie pasuj\u0105 do wcze\u015bniej zaimportowanych warto\u015bci, wi\u0119c wpis konfiguracji zosta\u0142 odpowiednio zaktualizowany.", + "updated_options": "Ten wpis zosta\u0142 ju\u017c skonfigurowany, ale opcje zdefiniowane w konfiguracji nie pasuj\u0105 do wcze\u015bniej zaimportowanych warto\u015bci, wi\u0119c wpis konfiguracji zosta\u0142 odpowiednio zaktualizowany.", + "updated_volume_step": "Ten wpis zosta\u0142 ju\u017c skonfigurowany, ale rozmiar skoku g\u0142o\u015bno\u015bci w konfiguracji nie pasuje do wpisu konfiguracji, wi\u0119c wpis konfiguracji zosta\u0142 odpowiednio zaktualizowany." }, "error": { - "cant_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z urz\u0105dzeniem. [Przejrzyj dokumentacj\u0119] (https://www.home-assistant.io/integrations/vizio/) i ponownie sprawd\u017a, czy: \n - Urz\u0105dzenie jest w\u0142\u0105czone \n - Urz\u0105dzenie jest pod\u0142\u0105czone do sieci \n - Podane warto\u015bci s\u0105 dok\u0142adne \n przed pr\u00f3b\u0105 ponownego przes\u0142ania.", + "cant_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z urz\u0105dzeniem. [Przejrzyj dokumentacj\u0119] (https://www.home-assistant.io/integrations/vizio/) i ponownie sprawd\u017a, czy: \n - urz\u0105dzenie jest w\u0142\u0105czone,\n - urz\u0105dzenie jest pod\u0142\u0105czone do sieci,\n - wprowadzone warto\u015bci s\u0105 prawid\u0142owe,\n przed pr\u00f3b\u0105 ponownego przes\u0142ania.", "host_exists": "Urz\u0105dzenie Vizio z okre\u015blonym hostem jest ju\u017c skonfigurowane.", "name_exists": "Urz\u0105dzenie Vizio o okre\u015blonej nazwie jest ju\u017c skonfigurowane.", - "tv_needs_token": "Gdy typem urz\u0105dzenia jest `tv` to potrzebny jest wa\u017cny token dost\u0119pu." + "tv_needs_token": "Gdy typem urz\u0105dzenia jest `tv` potrzebny jest prawid\u0142owy token dost\u0119pu." }, "step": { "user": { @@ -23,7 +24,7 @@ "host": ":", "name": "Nazwa" }, - "title": "Skonfiguruj klienta Vizio SmartCast" + "title": "Konfiguracja klienta Vizio SmartCast" } }, "title": "Vizio SmartCast" @@ -33,11 +34,11 @@ "init": { "data": { "timeout": "Limit czasu \u017c\u0105dania API (sekundy)", - "volume_step": "Wielko\u015b\u0107 kroku g\u0142o\u015bno\u015bci" + "volume_step": "Skok g\u0142o\u015bno\u015bci" }, - "title": "Zaktualizuj opcje Vizo SmartCast" + "title": "Aktualizacja opcji Vizo SmartCast" } }, - "title": "Zaktualizuj opcje Vizo SmartCast" + "title": "Aktualizuj opcje Vizo SmartCast" } } \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/ru.json b/homeassistant/components/vizio/.translations/ru.json index 2206336a5b44ad..e8f14e796ba687 100644 --- a/homeassistant/components/vizio/.translations/ru.json +++ b/homeassistant/components/vizio/.translations/ru.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "\u041f\u043e\u0445\u043e\u0436\u0435, \u0447\u0442\u043e \u044d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0431\u044b\u043b\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0445\u043e\u0441\u0442\u043e\u043c \u0438 \u0438\u043c\u0435\u043d\u0435\u043c \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0435 \u0435\u0433\u043e \u0441\u0435\u0440\u0438\u0439\u043d\u043e\u0433\u043e \u043d\u043e\u043c\u0435\u0440\u0430. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0434\u0430\u043b\u0438\u0442\u0435 \u0432\u0441\u0435 \u0441\u0442\u0430\u0440\u044b\u0435 \u0437\u0430\u043f\u0438\u0441\u0438 \u0438\u0437 \u0412\u0430\u0448\u0435\u0433\u043e configuration.yaml \u0438 \u0438\u0437 \u0440\u0430\u0437\u0434\u0435\u043b\u0430 \"\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438\" \u0438 \u0437\u0430\u0442\u0435\u043c \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443.", "host_exists": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0445\u043e\u0441\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "name_exists": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "updated_entry": "\u042d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430, \u043d\u043e \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0442 \u0440\u0430\u043d\u0435\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0431\u044b\u043b\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", "updated_options": "\u042d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430, \u043d\u043e \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0442 \u0440\u0430\u043d\u0435\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0431\u044b\u043b\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", "updated_volume_step": "\u042d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430, \u043d\u043e \u0448\u0430\u0433 \u0433\u0440\u043e\u043c\u043a\u043e\u0441\u0442\u0438, \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0439 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0440\u0430\u043d\u0435\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0431\u044b\u043b\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430." }, diff --git a/homeassistant/components/vizio/.translations/sl.json b/homeassistant/components/vizio/.translations/sl.json new file mode 100644 index 00000000000000..55faaaf26a8cce --- /dev/null +++ b/homeassistant/components/vizio/.translations/sl.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_in_progress": "Konfiguracijski tok za komponento vizio je \u017ee v teku.", + "already_setup": "Ta vnos je \u017ee nastavljen.", + "already_setup_with_diff_host_and_name": "Zdi se, da je bil ta vnos \u017ee nastavljen z drugim gostiteljem in imenom glede na njegovo serijsko \u0161tevilko. Pred ponovnim poskusom dodajanja te naprave, odstranite vse stare vnose iz config.yaml in iz menija Integrations.", + "host_exists": "VIZIO komponenta z gostiteljem \u017ee nastavljen.", + "name_exists": "Vizio komponenta z imenom je \u017ee konfigurirana.", + "updated_entry": "Ta vnos je \u017ee nastavljen, vendar se ime in / ali mo\u017enosti, opredeljene v config, ne ujemajo s predhodno uvo\u017eenim configom, zato je bil vnos konfiguracije ustrezno posodobljen.", + "updated_options": "Ta vnos je \u017ee nastavljen, vendar se mo\u017enosti, definirane v config-u, ne ujemajo s predhodno uvo\u017eenimi vrednostmi, zato je bil vnos konfiguracije ustrezno posodobljen.", + "updated_volume_step": "Ta vnos je \u017ee nastavljen, vendar velikost koraka glasnosti v config-u ne ustreza vnosu konfiguracije, zato je bil vnos konfiguracije ustrezno posodobljen." + }, + "error": { + "cant_connect": "Ni bilo mogo\u010de povezati z napravo. [Preglejte dokumente] (https://www.home-assistant.io/integrations/vizio/) in ponovno preverite, ali: \n \u2013 Naprava je vklopljena \n \u2013 Naprava je povezana z omre\u017ejem \n \u2013 Vrednosti, ki ste jih izpolnili, so to\u010dne \nnato poskusite ponovno.", + "host_exists": "Naprava Vizio z dolo\u010denim gostiteljem je \u017ee konfigurirana.", + "name_exists": "Naprava Vizio z navedenim imenom je \u017ee konfigurirana.", + "tv_needs_token": "Ko je vrsta naprave\u00bb TV \u00ab, je potreben veljaven \u017eeton za dostop." + }, + "step": { + "user": { + "data": { + "access_token": "\u017deton za dostop", + "device_class": "Vrsta naprave", + "host": ":", + "name": "Ime" + }, + "title": "Nastavite odjemalec Vizio SmartCast" + } + }, + "title": "Vizio SmartCast" + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "\u010casovna omejitev zahteve za API (sekunde)", + "volume_step": "Velikost koraka glasnosti" + }, + "title": "Posodobite mo\u017enosti Vizo SmartCast" + } + }, + "title": "Posodobite mo\u017enosti Vizo SmartCast" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/sv.json b/homeassistant/components/vizio/.translations/sv.json new file mode 100644 index 00000000000000..072b441a0715e7 --- /dev/null +++ b/homeassistant/components/vizio/.translations/sv.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_in_progress": "Konfigurationsfl\u00f6de f\u00f6r vizio-komponenten p\u00e5g\u00e5r\nredan.", + "already_setup": "Den h\u00e4r posten har redan st\u00e4llts in.", + "already_setup_with_diff_host_and_name": "Den h\u00e4r posten verkar redan ha st\u00e4llts in med en annan v\u00e4rd och ett annat namn baserat p\u00e5 dess serienummer. Ta bort alla gamla poster fr\u00e5n configuration.yaml och fr\u00e5n menyn Integrationer innan du f\u00f6rs\u00f6ker l\u00e4gga till den h\u00e4r enheten igen.", + "host_exists": "Vizio-komponenten med v\u00e4rdnamnet \u00e4r redan konfigurerad.", + "name_exists": "Vizio-komponent med namn redan konfigurerad.", + "updated_entry": "Den h\u00e4r posten har redan konfigurerats, men namnet och/eller alternativen som definierats i konfigurationen matchar inte den tidigare importerade konfigurationen och d\u00e4rf\u00f6r har konfigureringsposten uppdaterats i enlighet med detta.", + "updated_options": "Den h\u00e4r posten har redan st\u00e4llts in men de alternativ som definierats i konfigurationen matchar inte de tidigare importerade alternativv\u00e4rdena s\u00e5 konfigurationsposten har uppdaterats i enlighet med detta.", + "updated_volume_step": "Den h\u00e4r posten har redan st\u00e4llts in men volymstegstorleken i konfigurationen matchar inte konfigurationsposten s\u00e5 konfigurationsposten har uppdaterats i enlighet med detta." + }, + "error": { + "cant_connect": "Det gick inte att ansluta till enheten. [Granska dokumentationen] (https://www.home-assistant.io/integrations/vizio/) och p\u00e5 nytt kontrollera att\n- Enheten \u00e4r p\u00e5slagen\n- Enheten \u00e4r ansluten till n\u00e4tverket\n- De v\u00e4rden du fyllt i \u00e4r korrekta\ninnan du f\u00f6rs\u00f6ker skicka in igen.", + "host_exists": "Vizio-enheten med angivet v\u00e4rdnamn \u00e4r redan konfigurerad.", + "name_exists": "Vizio-enheten med angivet namn \u00e4r redan konfigurerad.", + "tv_needs_token": "N\u00e4r Enhetstyp \u00e4r 'tv' beh\u00f6vs en giltig \u00e5tkomsttoken." + }, + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel", + "device_class": "Enhetstyp", + "host": ":", + "name": "Namn" + }, + "title": "St\u00e4ll in Vizio SmartCast-klient" + } + }, + "title": "Vizio SmartCast" + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "Timeout f\u00f6r API-anrop (sekunder)", + "volume_step": "Storlek p\u00e5 volymsteg" + }, + "title": "Uppdatera Vizo SmartCast-alternativ" + } + }, + "title": "Uppdatera Vizo SmartCast-alternativ" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/zh-Hant.json b/homeassistant/components/vizio/.translations/zh-Hant.json index 6707a3219113cb..24128bb1b9e2ad 100644 --- a/homeassistant/components/vizio/.translations/zh-Hant.json +++ b/homeassistant/components/vizio/.translations/zh-Hant.json @@ -6,6 +6,7 @@ "already_setup_with_diff_host_and_name": "\u6839\u64da\u6240\u63d0\u4f9b\u7684\u5e8f\u865f\uff0c\u6b64\u7269\u4ef6\u4f3c\u4e4e\u5df2\u7d93\u4f7f\u7528\u4e0d\u540c\u7684\u4e3b\u6a5f\u7aef\u8207\u540d\u7a31\u9032\u884c\u8a2d\u5b9a\u3002\u8acb\u5f9e\u6574\u5408\u9078\u55ae Config.yaml \u4e2d\u79fb\u9664\u820a\u7269\u4ef6\uff0c\u7136\u5f8c\u518d\u65b0\u589e\u6b64\u8a2d\u5099\u3002", "host_exists": "\u4f9d\u4e3b\u6a5f\u7aef\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", "name_exists": "\u4f9d\u540d\u7a31\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "updated_entry": "\u6b64\u7269\u4ef6\u5df2\u7d93\u8a2d\u5b9a\uff0c\u4f46\u8a2d\u5b9a\u4e4b\u9078\u9805\u540d\u7a31\u53ca/\u6216\u9078\u9805\u8207\u5148\u524d\u532f\u5165\u7684\u7269\u4ef6\u9078\u9805\u503c\u4e0d\u5408\uff0c\u56e0\u6b64\u8a2d\u5b9a\u5c07\u6703\u8ddf\u8457\u66f4\u65b0\u3002", "updated_options": "\u6b64\u7269\u4ef6\u5df2\u7d93\u8a2d\u5b9a\uff0c\u4f46\u8a2d\u5b9a\u4e4b\u9078\u9805\u5b9a\u7fa9\u8207\u7269\u4ef6\u9078\u9805\u503c\u4e0d\u5408\uff0c\u56e0\u6b64\u8a2d\u5b9a\u5c07\u6703\u8ddf\u8457\u66f4\u65b0\u3002", "updated_volume_step": "\u6b64\u7269\u4ef6\u5df2\u7d93\u8a2d\u5b9a\uff0c\u4f46\u8a2d\u5b9a\u4e4b\u97f3\u91cf\u5927\u5c0f\u8207\u7269\u4ef6\u8a2d\u5b9a\u4e0d\u5408\uff0c\u56e0\u6b64\u8a2d\u5b9a\u5c07\u6703\u8ddf\u8457\u66f4\u65b0\u3002" }, @@ -23,7 +24,7 @@ "host": "<\u4e3b\u6a5f\u7aef/IP>:", "name": "\u540d\u7a31" }, - "title": "\u8a2d\u5b9a Vizio SmartCast \u5ba2\u6236\u7aef" + "title": "\u8a2d\u5b9a Vizio SmartCast \u8a2d\u5099" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 5500ec3db94d88..969d387a26b3d2 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -17,6 +17,7 @@ CONF_TYPE, ) from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import validate_auth from .const import ( @@ -30,8 +31,8 @@ _LOGGER = logging.getLogger(__name__) -def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: - """Return schema defaults based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema.""" +def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: + """Return schema defaults for config data based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema.""" if input_dict is None: input_dict = {} @@ -53,6 +54,11 @@ def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: ) +def _host_is_same(host1: str, host2: str) -> bool: + """Check if host1 and host2 are the same.""" + return host1.split(":")[0] == host2.split(":")[0] + + class VizioOptionsConfigFlow(config_entries.OptionsFlow): """Handle Transmission client options.""" @@ -104,11 +110,11 @@ async def async_step_user( if user_input is not None: # Store current values in case setup fails and user needs to edit - self._user_schema = _get_config_flow_schema(user_input) + self._user_schema = _get_config_schema(user_input) # Check if new config entry matches any existing config entries for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == user_input[CONF_HOST]: + if _host_is_same(entry.data[CONF_HOST], user_input[CONF_HOST]): errors[CONF_HOST] = "host_exists" break @@ -126,6 +132,7 @@ async def async_step_user( user_input[CONF_HOST], user_input.get(CONF_ACCESS_TOKEN), user_input[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), ): errors["base"] = "cant_connect" except vol.Invalid: @@ -143,6 +150,7 @@ async def async_step_user( user_input[CONF_HOST], user_input.get(CONF_ACCESS_TOKEN), user_input[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), ) if await self.async_set_unique_id( @@ -157,7 +165,7 @@ async def async_step_user( ) # Use user_input params as default values for schema if user_input is non-empty, otherwise use default schema - schema = self._user_schema or _get_config_flow_schema() + schema = self._user_schema or _get_config_schema() return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @@ -165,24 +173,31 @@ async def async_step_import(self, import_config: Dict[str, Any]) -> Dict[str, An """Import a config entry from configuration.yaml.""" # Check if new config entry matches any existing config entries for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == import_config[CONF_HOST] and entry.data[ - CONF_NAME - ] == import_config.get(CONF_NAME): + if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]): updated_options = {} + updated_name = {} - if entry.data[CONF_VOLUME_STEP] != import_config[CONF_VOLUME_STEP]: + if entry.data[CONF_NAME] != import_config[CONF_NAME]: + updated_name[CONF_NAME] = import_config[CONF_NAME] + + if entry.data.get(CONF_VOLUME_STEP) != import_config[CONF_VOLUME_STEP]: updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP] - if updated_options: + if updated_options or updated_name: new_data = entry.data.copy() - new_data.update(updated_options) new_options = entry.options.copy() - new_options.update(updated_options) + + if updated_name: + new_data.update(updated_name) + + if updated_options: + new_data.update(updated_options) + new_options.update(updated_options) self.hass.config_entries.async_update_entry( entry=entry, data=new_data, options=new_options, ) - return self.async_abort(reason="updated_options") + return self.async_abort(reason="updated_entry") return self.async_abort(reason="already_setup") @@ -193,13 +208,18 @@ async def async_step_zeroconf( ) -> Dict[str, Any]: """Handle zeroconf discovery.""" + # Set unique ID early to prevent device from getting rediscovered multiple times + await self.async_set_unique_id( + unique_id=discovery_info[CONF_HOST].split(":")[0], raise_on_progress=True + ) + discovery_info[ CONF_HOST ] = f"{discovery_info[CONF_HOST]}:{discovery_info[CONF_PORT]}" # Check if new config entry matches any existing config entries and abort if so for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == discovery_info[CONF_HOST]: + if _host_is_same(entry.data[CONF_HOST], discovery_info[CONF_HOST]): return self.async_abort(reason="already_setup") # Set default name to discovered device name by stripping zeroconf service diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 92fb37c153e1fc..e3ac66e05c3228 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -1,6 +1,4 @@ """Constants used by vizio component.""" -from datetime import timedelta - from pyvizio.const import ( DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, @@ -72,6 +70,3 @@ vol.Coerce(int), vol.Range(min=1, max=10) ), } - -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index ea1162540cfc65..08d442b803eb25 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -1,10 +1,11 @@ { "domain": "vizio", - "name": "Vizio SmartCast TV", + "name": "Vizio SmartCast", "documentation": "https://www.home-assistant.io/integrations/vizio", - "requirements": ["pyvizio==0.1.4"], + "requirements": ["pyvizio==0.1.26"], "dependencies": [], "codeowners": ["@raman325"], "config_flow": true, - "zeroconf": ["_viziocast._tcp.local."] + "zeroconf": ["_viziocast._tcp.local."], + "quality_scale": "platinum" } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index b2f529bce10d30..6b62d6bafd0a6a 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -1,10 +1,10 @@ """Vizio SmartCast Device support.""" +from datetime import timedelta import logging from typing import Callable, List from pyvizio import VizioAsync -from homeassistant import util from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -31,15 +31,13 @@ DEVICE_ID, DOMAIN, ICON, - MIN_TIME_BETWEEN_FORCED_SCANS, - MIN_TIME_BETWEEN_SCANS, SUPPORTED_COMMANDS, VIZIO_DEVICE_CLASSES, ) _LOGGER = logging.getLogger(__name__) - +SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -55,13 +53,21 @@ async def async_setup_entry( device_class = config_entry.data[CONF_DEVICE_CLASS] # If config entry options not set up, set them up, otherwise assign values managed in options + volume_step = config_entry.options.get( + CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP), + ) + + params = {} if not config_entry.options: - volume_step = config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP) - hass.config_entries.async_update_entry( - config_entry, options={CONF_VOLUME_STEP: volume_step} - ) - else: - volume_step = config_entry.options[CONF_VOLUME_STEP] + params["options"] = {CONF_VOLUME_STEP: volume_step} + + if not config_entry.data.get(CONF_VOLUME_STEP): + new_data = config_entry.data.copy() + new_data.update({CONF_VOLUME_STEP: volume_step}) + params["data"] = new_data + + if params: + hass.config_entries.async_update_entry(config_entry, **params) device = VizioAsync( DEVICE_ID, @@ -73,19 +79,8 @@ async def async_setup_entry( timeout=DEFAULT_TIMEOUT, ) - if not await device.can_connect(): - fail_auth_msg = "" - if token: - fail_auth_msg = f"and auth token '{token}' are correct." - else: - fail_auth_msg = "is correct." - _LOGGER.warning( - "Failed to connect to Vizio device, please check if host '%s' " - "is valid and available. Also check if device class '%s' %s", - host, - device_class, - fail_auth_msg, - ) + if not await device.can_connect_with_auth_check(): + _LOGGER.warning("Failed to connect to %s", host) raise PlatformNotReady entity = VizioDevice(config_entry, device, name, volume_step, device_class) @@ -120,17 +115,32 @@ def __init__( self._max_volume = float(self._device.get_max_volume()) self._icon = ICON[device_class] self._available = True + self._model = None + self._sw_version = None - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) async def async_update(self) -> None: """Retrieve latest state of the device.""" + if not self._model: + self._model = await self._device.get_model_name() + + if not self._sw_version: + self._sw_version = await self._device.get_version() + is_on = await self._device.get_power_state(log_api_exception=False) if is_on is None: - self._available = False + if self._available: + _LOGGER.warning( + "Lost connection to %s", self._config_entry.data[CONF_HOST] + ) + self._available = False return - self._available = True + if not self._available: + _LOGGER.info( + "Restored connection to %s", self._config_entry.data[CONF_HOST] + ) + self._available = True if not is_on: self._state = STATE_OFF @@ -147,9 +157,9 @@ async def async_update(self) -> None: input_ = await self._device.get_current_input(log_api_exception=False) if input_ is not None: - self._current_input = input_.meta_name + self._current_input = input_ - inputs = await self._device.get_inputs(log_api_exception=False) + inputs = await self._device.get_inputs_list(log_api_exception=False) if inputs is not None: self._available_inputs = [input_.name for input_ in inputs] @@ -157,7 +167,7 @@ async def async_update(self) -> None: async def _async_send_update_options_signal( hass: HomeAssistantType, config_entry: ConfigEntry ) -> None: - """Send update event when when Vizio config entry is updated.""" + """Send update event when Vizio config entry is updated.""" # Move this method to component level if another entity ever gets added for a single config entry. # See here: https://github.com/home-assistant/home-assistant/pull/30653#discussion_r366426121 async_dispatcher_send(hass, config_entry.entry_id, config_entry) @@ -241,6 +251,8 @@ def device_info(self): "identifiers": {(DOMAIN, self._config_entry.unique_id)}, "name": self.name, "manufacturer": "VIZIO", + "model": self._model, + "sw_version": self._sw_version, } @property @@ -273,10 +285,10 @@ async def async_media_next_track(self) -> None: async def async_select_source(self, source: str) -> None: """Select input source.""" - await self._device.input_switch(source) + await self._device.set_input(source) async def async_volume_up(self) -> None: - """Increasing volume of the device.""" + """Increase volume of the device.""" await self._device.vol_up(num=self._volume_step) if self._volume_level is not None: @@ -285,7 +297,7 @@ async def async_volume_up(self) -> None: ) async def async_volume_down(self) -> None: - """Decreasing volume of the device.""" + """Decrease volume of the device.""" await self._device.vol_down(num=self._volume_step) if self._volume_level is not None: diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 305e49d56f8997..d1890ee49edef9 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -3,7 +3,7 @@ "title": "Vizio SmartCast", "step": { "user": { - "title": "Setup Vizio SmartCast Client", + "title": "Setup Vizio SmartCast Device", "data": { "name": "Name", "host": ":", @@ -21,7 +21,7 @@ "abort": { "already_setup": "This entry has already been setup.", "already_setup_with_diff_host_and_name": "This entry appears to have already been setup with a different host and name based on its serial number. Please remove any old entries from your configuration.yaml and from the Integrations menu before reattempting to add this device.", - "updated_options": "This entry has already been setup but the options defined in the config do not match the previously imported options values so the config entry has been updated accordingly." + "updated_entry": "This entry has already been setup but the name and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly." } }, "options": { @@ -35,4 +35,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index f62a74345b1dc9..90e62c0d951ed2 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -1,9 +1,6 @@ """ Volumio Platform. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.volumio/ - Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ import asyncio @@ -251,73 +248,75 @@ def supported_features(self): """Flag of media commands that are supported.""" return SUPPORT_VOLUMIO - def async_media_next_track(self): + async def async_media_next_track(self): """Send media_next command to media player.""" - return self.send_volumio_msg("commands", params={"cmd": "next"}) + await self.send_volumio_msg("commands", params={"cmd": "next"}) - def async_media_previous_track(self): + async def async_media_previous_track(self): """Send media_previous command to media player.""" - return self.send_volumio_msg("commands", params={"cmd": "prev"}) + await self.send_volumio_msg("commands", params={"cmd": "prev"}) - def async_media_play(self): + async def async_media_play(self): """Send media_play command to media player.""" - return self.send_volumio_msg("commands", params={"cmd": "play"}) + await self.send_volumio_msg("commands", params={"cmd": "play"}) - def async_media_pause(self): + async def async_media_pause(self): """Send media_pause command to media player.""" if self._state["trackType"] == "webradio": - return self.send_volumio_msg("commands", params={"cmd": "stop"}) - return self.send_volumio_msg("commands", params={"cmd": "pause"}) + await self.send_volumio_msg("commands", params={"cmd": "stop"}) + else: + await self.send_volumio_msg("commands", params={"cmd": "pause"}) - def async_set_volume_level(self, volume): + async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "volume", "volume": int(volume * 100)} ) - def async_volume_up(self): + async def async_volume_up(self): """Service to send the Volumio the command for volume up.""" - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "volume", "volume": "plus"} ) - def async_volume_down(self): + async def async_volume_down(self): """Service to send the Volumio the command for volume down.""" - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "volume", "volume": "minus"} ) - def async_mute_volume(self, mute): + async def async_mute_volume(self, mute): """Send mute command to media player.""" mutecmd = "mute" if mute else "unmute" if mute: # mute is implemented as 0 volume, do save last volume level self._lastvol = self._state["volume"] - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "volume", "volume": mutecmd} ) + return - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "volume", "volume": self._lastvol} ) - def async_set_shuffle(self, shuffle): + async def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "random", "value": str(shuffle).lower()} ) - def async_select_source(self, source): + async def async_select_source(self, source): """Choose a different available playlist and play it.""" self._currentplaylist = source - return self.send_volumio_msg( + await self.send_volumio_msg( "commands", params={"cmd": "playplaylist", "name": source} ) - def async_clear_playlist(self): + async def async_clear_playlist(self): """Clear players playlist.""" self._currentplaylist = None - return self.send_volumio_msg("commands", params={"cmd": "clearQueue"}) + await self.send_volumio_msg("commands", params={"cmd": "clearQueue"}) @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index fec912f00d8150..0bcdcf9d4c153a 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, DATA_GIGABYTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -21,7 +21,7 @@ MONITORED_CONDITIONS = { ATTR_CURRENT_BANDWIDTH_USED: [ "Current Bandwidth Used", - "GB", + DATA_GIGABYTES, "mdi:chart-histogram", ], ATTR_PENDING_CHARGES: ["Pending Charges", "US$", "mdi:currency-usd"], diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index ecff3105ae0f47..4de0a58a881eaa 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -145,7 +145,7 @@ def precision(self): @property def capability_attributes(self): - """Return capabilitiy attributes.""" + """Return capability attributes.""" supported_features = self.supported_features or 0 data = { diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index b9ca64c0970315..0357825cb12711 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -16,6 +16,7 @@ CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, EVENT_HOMEASSISTANT_START, + TIME_MINUTES, ) from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv @@ -167,7 +168,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/weblink/__init__.py b/homeassistant/components/weblink/__init__.py deleted file mode 100644 index 8a770f916bd3e1..00000000000000 --- a/homeassistant/components/weblink/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Support for links to external web pages.""" -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_URL -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import slugify - -_LOGGER = logging.getLogger(__name__) - -CONF_ENTITIES = "entities" -CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." -CONF_RELATIVE_URL_REGEX = r"\A/" - -DOMAIN = "weblink" - -ENTITIES_SCHEMA = vol.Schema( - { - # pylint: disable=no-value-for-parameter - vol.Required(CONF_URL): vol.Any( - vol.Match(CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), - vol.Url(), - ), - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON): cv.icon, - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Required(CONF_ENTITIES): [ENTITIES_SCHEMA]})}, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Set up the weblink component.""" - _LOGGER.warning( - "The weblink integration has been deprecated and is pending for removal " - "in Home Assistant 0.107.0. Please use this instead: " - "https://www.home-assistant.io/lovelace/entities/#weblink" - ) - - links = config.get(DOMAIN) - - for link in links.get(CONF_ENTITIES): - Link(hass, link.get(CONF_NAME), link.get(CONF_URL), link.get(CONF_ICON)) - - return True - - -class Link(Entity): - """Representation of a link.""" - - def __init__(self, hass, name, url, icon): - """Initialize the link.""" - self.hass = hass - self._name = name - self._url = url - self._icon = icon - self.entity_id = DOMAIN + ".%s" % slugify(name) - self.schedule_update_ha_state() - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - @property - def name(self): - """Return the name of the URL.""" - return self._name - - @property - def state(self): - """Return the URL.""" - return self._url diff --git a/homeassistant/components/weblink/manifest.json b/homeassistant/components/weblink/manifest.json deleted file mode 100644 index 28ffa581bb8336..00000000000000 --- a/homeassistant/components/weblink/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "weblink", - "name": "Weblink", - "documentation": "https://www.home-assistant.io/integrations/weblink", - "requirements": [], - "dependencies": [], - "codeowners": ["@home-assistant/core"], - "quality_scale": "internal" -} diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 13f3d9e8f8d80d..9dec8fe0c71eb1 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -17,6 +17,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from .const import ATTR_SOUND_OUTPUT + DOMAIN = "webostv" CONF_SOURCES = "sources" @@ -30,6 +32,8 @@ SERVICE_COMMAND = "command" ATTR_COMMAND = "command" +SERVICE_SELECT_SOUND_OUTPUT = "select_sound_output" + CUSTOMIZE_SCHEMA = vol.Schema( {vol.Optional(CONF_SOURCES, default=[]): vol.All(cv.ensure_list, [cv.string])} ) @@ -60,9 +64,15 @@ COMMAND_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_COMMAND): cv.string}) +SOUND_OUTPUT_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_SOUND_OUTPUT): cv.string}) + SERVICE_TO_METHOD = { SERVICE_BUTTON: {"method": "async_button", "schema": BUTTON_SCHEMA}, SERVICE_COMMAND: {"method": "async_command", "schema": COMMAND_SCHEMA}, + SERVICE_SELECT_SOUND_OUTPUT: { + "method": "async_select_sound_output", + "schema": SOUND_OUTPUT_SCHEMA, + }, } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py new file mode 100644 index 00000000000000..a81696f6c0baed --- /dev/null +++ b/homeassistant/components/webostv/const.py @@ -0,0 +1,4 @@ +"""Constants used for WebOS TV.""" +LIVE_TV_APP_ID = "com.webos.app.livetv" + +ATTR_SOUND_OUTPUT = "sound_output" diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 4328ff96b56cae..acdee1d9ca9ee7 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -2,7 +2,7 @@ "domain": "webostv", "name": "LG webOS Smart TV", "documentation": "https://www.home-assistant.io/integrations/webostv", - "requirements": ["aiopylgtv==0.3.0"], + "requirements": ["aiopylgtv==0.3.3"], "dependencies": ["configurator"], "codeowners": ["@bendavid"] } diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c34fb376d316ec..f4d9f97fe42301 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -29,6 +29,7 @@ CONF_HOST, CONF_NAME, ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, STATE_OFF, STATE_ON, ) @@ -36,13 +37,11 @@ from homeassistant.helpers.script import Script from . import CONF_ON_ACTION, CONF_SOURCES, DOMAIN +from .const import ATTR_SOUND_OUTPUT, LIVE_TV_APP_ID _LOGGER = logging.getLogger(__name__) -LIVETV_APP_ID = "com.webos.app.livetv" - - SUPPORT_WEBOSTV = ( SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK @@ -59,8 +58,6 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) -LIVE_TV_APP_ID = "com.webos.app.livetv" - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the LG WebOS TV platform.""" @@ -141,6 +138,9 @@ async def async_will_remove_from_hass(self): async def async_signal_handler(self, data): """Handle domain-specific signal by calling appropriate method.""" entity_ids = data[ATTR_ENTITY_ID] + if entity_ids == ENTITY_MATCH_NONE: + return + if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: params = { key: value @@ -227,11 +227,10 @@ def name(self): @property def state(self): """Return the state of the device.""" - client_state = self._client.power_state.get("state") - if client_state in [None, "Power Off", "Suspend", "Active Standby"]: - return STATE_OFF + if self._client.is_on: + return STATE_ON - return STATE_ON + return STATE_OFF @property def is_volume_muted(self): @@ -290,6 +289,14 @@ def supported_features(self): return SUPPORT_WEBOSTV | SUPPORT_TURN_ON return SUPPORT_WEBOSTV + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if self._client.sound_output is not None and self.state != STATE_OFF: + attributes[ATTR_SOUND_OUTPUT] = self._client.sound_output + return attributes + @cmd async def async_turn_off(self): """Turn off media player.""" @@ -313,7 +320,7 @@ async def async_volume_down(self): @cmd async def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" - tv_volume = volume * 100 + tv_volume = int(round(volume * 100)) await self._client.set_volume(tv_volume) @cmd @@ -321,6 +328,11 @@ async def async_mute_volume(self, mute): """Send mute command.""" await self._client.set_mute(mute) + @cmd + async def async_select_sound_output(self, sound_output): + """Select the sound output.""" + await self._client.change_sound_output(sound_output) + @cmd async def async_media_play_pause(self): """Simulate play pause media player.""" @@ -397,7 +409,7 @@ async def async_media_stop(self): async def async_media_next_track(self): """Send next track command.""" current_input = self._client.get_input() - if current_input == LIVETV_APP_ID: + if current_input == LIVE_TV_APP_ID: await self._client.channel_up() else: await self._client.fast_forward() @@ -406,7 +418,7 @@ async def async_media_next_track(self): async def async_media_previous_track(self): """Send the previous track command.""" current_input = self._client.get_input() - if current_input == LIVETV_APP_ID: + if current_input == LIVE_TV_APP_ID: await self._client.channel_down() else: await self._client.rewind() diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml index 137a6026eda648..1dfb3a6f1d3f22 100644 --- a/homeassistant/components/webostv/services.yaml +++ b/homeassistant/components/webostv/services.yaml @@ -24,3 +24,12 @@ command: https://github.com/TheRealLink/pylgtv/blob/master/pylgtv/endpoints.py example: 'media.controls/rewind' +select_sound_output: + description: 'Send the TV the command to change sound output.' + fields: + entity_id: + description: Name(s) of the webostv entities to change sound output on. + example: 'media_player.living_room_tv' + sound_output: + description: Name of the sound output to switch to. + example: 'external_speaker' diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index b1fa1263a992ce..61f12fd5f57503 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -12,10 +12,7 @@ from .connection import ActiveConnection # noqa -WebSocketCommandHandler = Callable[ - [HomeAssistant, "ActiveConnection", dict], None -] # pylint: disable=invalid-name - +WebSocketCommandHandler = Callable[[HomeAssistant, "ActiveConnection", dict], None] DOMAIN = "websocket_api" URL = "/api/websocket" diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index c270c0f0cccfc5..8b00981fb04cf7 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -3,10 +3,11 @@ Separate file to avoid circular imports. """ from homeassistant.components.frontend import EVENT_PANELS_UPDATED -from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED +from homeassistant.components.lovelace.const import EVENT_LOVELACE_UPDATED from homeassistant.components.persistent_notification import ( EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, ) +from homeassistant.components.shopping_list import EVENT as EVENT_SHOPPING_LIST_UPDATED from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_CORE_CONFIG_UPDATE, @@ -22,16 +23,17 @@ # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. SUBSCRIBE_WHITELIST = { + EVENT_AREA_REGISTRY_UPDATED, EVENT_COMPONENT_LOADED, EVENT_CORE_CONFIG_UPDATE, + EVENT_DEVICE_REGISTRY_UPDATED, + EVENT_ENTITY_REGISTRY_UPDATED, + EVENT_LOVELACE_UPDATED, EVENT_PANELS_UPDATED, EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, + EVENT_SHOPPING_LIST_UPDATED, EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, - EVENT_AREA_REGISTRY_UPDATED, - EVENT_DEVICE_REGISTRY_UPDATED, - EVENT_ENTITY_REGISTRY_UPDATED, - EVENT_LOVELACE_UPDATED, } diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 3e4081ae300a00..9cac85dee0910e 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -149,7 +149,7 @@ def stop_wemo(event): ) elif component in hass.data[DOMAIN]["pending"]: - hass.data[DOMAIN]["pending"].append(device) + hass.data[DOMAIN]["pending"][component].append(device) else: async_dispatcher_send( diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 8e43f47ef00ea9..5988019e66f88e 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -113,8 +113,8 @@ def device_info(self): """Return the device info.""" return { "name": self.wemo.name, - "identifiers": {(WEMO_DOMAIN, self.wemo.serialnumber)}, - "model": self.wemo.model_name, + "identifiers": {(WEMO_DOMAIN, self.wemo.uniqueID)}, + "model": type(self.wemo).__name__, "manufacturer": "Belkin", } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index dc9da1100f0120..7ec5c3dac5e436 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -6,7 +6,7 @@ import whois from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, TIME_DAYS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -75,7 +75,7 @@ def icon(self): @property def unit_of_measurement(self): """Return the unit of measurement to present the value in.""" - return "days" + return TIME_DAYS @property def state(self): diff --git a/homeassistant/components/wink/services.yaml b/homeassistant/components/wink/services.yaml index 93d53159702ae4..500f1fd3f2a76d 100644 --- a/homeassistant/components/wink/services.yaml +++ b/homeassistant/components/wink/services.yaml @@ -131,7 +131,7 @@ set_nimbus_dial_configuration: description: The minimum value allowed to be set example: 0 max_value: - description: The maximum value allowd to be set + description: The maximum value allowed to be set example: 500 min_position: description: The minimum position the dial hand can rotate to generally [0-360] @@ -141,10 +141,10 @@ set_nimbus_dial_configuration: example: 360 set_nimbus_dial_state: - description: Set the value and lables of an individual nimbus dial + description: Set the value and labels of an individual nimbus dial fields: entity_id: - description: Name fo the entity to set. + description: Name of the entity to set. example: 'wink.nimbus_dial_3' value: description: The value that should be set (Should be between min_value and max_value) diff --git a/homeassistant/components/withings/.translations/ca.json b/homeassistant/components/withings/.translations/ca.json index 5794dbbc1a5fef..edb95a946aaf21 100644 --- a/homeassistant/components/withings/.translations/ca.json +++ b/homeassistant/components/withings/.translations/ca.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", + "missing_configuration": "La integraci\u00f3 Withings no est\u00e0 configurada. Mira'n la documentaci\u00f3.", "no_flows": "Necessites configurar Withings abans de poder autenticar't-hi. Llegeix la documentaci\u00f3." }, "create_entry": { diff --git a/homeassistant/components/withings/.translations/cs.json b/homeassistant/components/withings/.translations/cs.json index a8aea1fa08f656..379ad7fde3032e 100644 --- a/homeassistant/components/withings/.translations/cs.json +++ b/homeassistant/components/withings/.translations/cs.json @@ -1,6 +1,13 @@ { "config": { + "abort": { + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", + "missing_configuration": "Integrace Withings nen\u00ed nakonfigurov\u00e1na. Postupujte podle n\u00e1vodu." + }, "step": { + "pick_implementation": { + "title": "Vyberte metodu ov\u011b\u0159en\u00ed" + }, "profile": { "data": { "profile": "Profil" diff --git a/homeassistant/components/withings/.translations/da.json b/homeassistant/components/withings/.translations/da.json index 7b51cec402d46f..72d851ad873a0d 100644 --- a/homeassistant/components/withings/.translations/da.json +++ b/homeassistant/components/withings/.translations/da.json @@ -6,7 +6,7 @@ "no_flows": "Du skal konfigurere Withings, f\u00f8r du kan godkende med den. L\u00e6s venligst dokumentationen." }, "create_entry": { - "default": "Godkendt med Withings for den valgte profil." + "default": "Godkendt med Withings." }, "step": { "pick_implementation": { diff --git a/homeassistant/components/withings/.translations/de.json b/homeassistant/components/withings/.translations/de.json index a75160fcef8952..ae8ab67959352f 100644 --- a/homeassistant/components/withings/.translations/de.json +++ b/homeassistant/components/withings/.translations/de.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Autorisierungs-URL.", + "missing_configuration": "Die Withings-Integration ist nicht konfiguriert. Bitte folgen Sie der Dokumentation.", "no_flows": "Withings muss konfiguriert werden, bevor die Integration authentifiziert werden kann. Bitte lies die Dokumentation." }, "create_entry": { - "default": "Erfolgreiche Authentifizierung mit Withings f\u00fcr das ausgew\u00e4hlte Profil." + "default": "Erfolgreiche Authentifizierung mit Withings." }, "step": { + "pick_implementation": { + "title": "Authentifizierungsmethode ausw\u00e4hlen" + }, "profile": { "data": { "profile": "Profil" diff --git a/homeassistant/components/withings/.translations/es.json b/homeassistant/components/withings/.translations/es.json index c1e969c7f51c64..c239d7d8db9447 100644 --- a/homeassistant/components/withings/.translations/es.json +++ b/homeassistant/components/withings/.translations/es.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "authorize_url_timeout": "Tiempo de espera agotado para la autorizaci\u00f3n de la url.", + "missing_configuration": "La integraci\u00f3n de Withings no est\u00e1 configurada. Por favor, siga la documentaci\u00f3n.", "no_flows": "Debe configurar Withings antes de poder autenticarse con \u00e9l. Por favor, lea la documentaci\u00f3n." }, "create_entry": { "default": "Autenticado correctamente con Withings para el perfil seleccionado." }, "step": { + "pick_implementation": { + "title": "Elija el m\u00e9todo de autenticaci\u00f3n" + }, "profile": { "data": { "profile": "Perfil" diff --git a/homeassistant/components/withings/.translations/fr.json b/homeassistant/components/withings/.translations/fr.json index bd0ec740421cc8..0ad55e7eaa7c5a 100644 --- a/homeassistant/components/withings/.translations/fr.json +++ b/homeassistant/components/withings/.translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "missing_configuration": "L'int\u00e9gration Withings n'est pas configur\u00e9e. Veuillez suivre la documentation.", "no_flows": "Vous devez configurer Withings avant de pouvoir vous authentifier avec celui-ci. Veuillez lire la documentation." }, "create_entry": { diff --git a/homeassistant/components/withings/.translations/hu.json b/homeassistant/components/withings/.translations/hu.json new file mode 100644 index 00000000000000..503013e402f8c9 --- /dev/null +++ b/homeassistant/components/withings/.translations/hu.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", + "missing_configuration": "A Withings integr\u00e1ci\u00f3 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", + "no_flows": "Konfigur\u00e1lnia kell a Withings-et, miel\u0151tt hiteles\u00edtheti mag\u00e1t vele. K\u00e9rj\u00fck, olvassa el a dokument\u00e1ci\u00f3t." + }, + "create_entry": { + "default": "A Withings sikeresen hiteles\u00edtett." + }, + "step": { + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9si m\u00f3dszert" + }, + "profile": { + "data": { + "profile": "Profil" + }, + "description": "Melyik profilt v\u00e1lasztottad ki a Withings weboldalon? Fontos, hogy a profilok egyeznek, k\u00fcl\u00f6nben az adatok helytelen c\u00edmk\u00e9vel lesznek ell\u00e1tva.", + "title": "Felhaszn\u00e1l\u00f3i profil." + }, + "user": { + "data": { + "profile": "Profil" + }, + "description": "V\u00e1lasszon egy felhaszn\u00e1l\u00f3i profilt, amelyet szeretn\u00e9, hogy a Home Assistant hozz\u00e1rendeljen a Withings profilhoz. \u00dcgyeljen arra, hogy ugyanazt a felhaszn\u00e1l\u00f3t v\u00e1lassza a Withings oldalon, k\u00fcl\u00f6nben az adatok nem lesznek megfelel\u0151en felcimk\u00e9zve.", + "title": "Felhaszn\u00e1l\u00f3i profil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/it.json b/homeassistant/components/withings/.translations/it.json index cc7a941813d8fd..4a6f5e67965d5f 100644 --- a/homeassistant/components/withings/.translations/it.json +++ b/homeassistant/components/withings/.translations/it.json @@ -6,7 +6,7 @@ "no_flows": "\u00c8 necessario configurare Withings prima di potersi autenticare con esso. Si prega di leggere la documentazione." }, "create_entry": { - "default": "Autenticazione completata con Withings per il profilo selezionato." + "default": "Autenticazione riuscita con Withings." }, "step": { "pick_implementation": { diff --git a/homeassistant/components/withings/.translations/nl.json b/homeassistant/components/withings/.translations/nl.json index c831561a439238..0b01fc8c16a831 100644 --- a/homeassistant/components/withings/.translations/nl.json +++ b/homeassistant/components/withings/.translations/nl.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.", + "missing_configuration": "De Withings integratie is niet geconfigureerd. Gelieve de documentatie te volgen.", "no_flows": "U moet Withings configureren voordat u zich ermee kunt verifi\u00ebren. [Gelieve de documentatie te lezen]" }, "create_entry": { "default": "Succesvol geverifieerd met Withings voor het geselecteerde profiel." }, "step": { + "pick_implementation": { + "title": "Kies Authenticatiemethode" + }, "profile": { "data": { "profile": "Profiel" diff --git a/homeassistant/components/withings/.translations/pl.json b/homeassistant/components/withings/.translations/pl.json index 97aa393fde4165..afe35bd06cfa3e 100644 --- a/homeassistant/components/withings/.translations/pl.json +++ b/homeassistant/components/withings/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", "missing_configuration": "Integracja z Withings nie jest skonfigurowana. Post\u0119puj zgodnie z dokumentacj\u0105.", "no_flows": "Musisz skonfigurowa\u0107 Withings, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Zapoznaj si\u0119 z dokumentacj\u0105." }, diff --git a/homeassistant/components/withings/.translations/sl.json b/homeassistant/components/withings/.translations/sl.json index 2ee52b29b2d19a..600b2dbf450900 100644 --- a/homeassistant/components/withings/.translations/sl.json +++ b/homeassistant/components/withings/.translations/sl.json @@ -1,12 +1,17 @@ { "config": { "abort": { + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "missing_configuration": "Integracija Withings ni konfigurirana. Prosimo, upo\u0161tevajte dokumentacijo.", "no_flows": "Withings morate prvo konfigurirati, preden ga boste lahko uporabili za overitev. Prosimo, preberite dokumentacijo." }, "create_entry": { "default": "Uspe\u0161no overjen z Withings za izbrani profil." }, "step": { + "pick_implementation": { + "title": "Izberite na\u010din preverjanja pristnosti" + }, "profile": { "data": { "profile": "Profil" diff --git a/homeassistant/components/withings/.translations/sv.json b/homeassistant/components/withings/.translations/sv.json new file mode 100644 index 00000000000000..dc8954af2c73de --- /dev/null +++ b/homeassistant/components/withings/.translations/sv.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Skapandet av en auktoriseringsadress \u00f6verskred tidsgr\u00e4nsen.", + "missing_configuration": "Withings-integrationen \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen.", + "no_flows": "Du m\u00e5ste konfigurera Withings innan du kan autentisera med den. L\u00e4s dokumentationen." + }, + "create_entry": { + "default": "Lyckad autentisering med Withings." + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + }, + "profile": { + "data": { + "profile": "Profil" + }, + "description": "Vilken profil valde du p\u00e5 Withings webbplats? Det \u00e4r viktigt att profilerna matchar, annars kommer data att vara felm\u00e4rkta.", + "title": "Anv\u00e4ndarprofil." + }, + "user": { + "data": { + "profile": "Profil" + }, + "description": "V\u00e4lj en anv\u00e4ndarprofil som du vill att Home Assistant ska kartl\u00e4gga med en Withings-profil. Var noga med att v\u00e4lja samma anv\u00e4ndare p\u00e5 visningssidan eller s\u00e5 kommer inte data att betecknas korrekt.", + "title": "Anv\u00e4ndarprofil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 856f50ce9adc0c..ea3814d3b3a81c 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -59,12 +59,9 @@ MEAS_WEIGHT_KG = "weight_kg" UOM_BEATS_PER_MINUTE = "bpm" -UOM_BREATHS_PER_MINUTE = "br/m" +UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}" UOM_FREQUENCY = "times" -UOM_METERS_PER_SECOND = "m/s" UOM_MMHG = "mmhg" UOM_PERCENT = "%" UOM_LENGTH_M = const.LENGTH_METERS -UOM_MASS_KG = const.MASS_KILOGRAMS -UOM_SECONDS = "seconds" UOM_TEMP_C = const.TEMP_CELSIUS diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index ea570569fa6fb7..0fee2271067a81 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -13,6 +13,7 @@ ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MASS_KILOGRAMS, SPEED_METERS_PER_SECOND, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.entity import Entity @@ -86,35 +87,35 @@ class WithingsSleepSummaryAttribute(WithingsAttribute): const.MEAS_WEIGHT_KG, MeasureType.WEIGHT, "Weight", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_FAT_MASS_KG, MeasureType.FAT_MASS_WEIGHT, "Fat Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_FAT_FREE_MASS_KG, MeasureType.FAT_FREE_MASS, "Fat Free Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_MUSCLE_MASS_KG, MeasureType.MUSCLE_MASS, "Muscle Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_BONE_MASS_KG, MeasureType.BONE_MASS, "Bone Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( @@ -187,7 +188,7 @@ class WithingsSleepSummaryAttribute(WithingsAttribute): const.MEAS_PWV, MeasureType.PULSE_WAVE_VELOCITY, "Pulse Wave Velocity", - const.UOM_METERS_PER_SECOND, + SPEED_METERS_PER_SECOND, None, ), WithingsSleepStateAttribute( @@ -197,28 +198,28 @@ class WithingsSleepSummaryAttribute(WithingsAttribute): const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS, GetSleepSummaryField.WAKEUP_DURATION.value, "Wakeup time", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep-off", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_LIGHT_DURATION_SECONDS, GetSleepSummaryField.LIGHT_SLEEP_DURATION.value, "Light sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_DEEP_DURATION_SECONDS, GetSleepSummaryField.DEEP_SLEEP_DURATION.value, "Deep sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_REM_DURATION_SECONDS, GetSleepSummaryField.REM_SLEEP_DURATION.value, "REM sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( @@ -232,14 +233,14 @@ class WithingsSleepSummaryAttribute(WithingsAttribute): const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS, GetSleepSummaryField.DURATION_TO_SLEEP.value, "Time to sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS, GetSleepSummaryField.DURATION_TO_WAKEUP.value, "Time to wakeup", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep-off", ), WithingsSleepSummaryAttribute( diff --git a/homeassistant/components/wled/.translations/ca.json b/homeassistant/components/wled/.translations/ca.json index 347dc576d91a63..cf4d1d98f6ee95 100644 --- a/homeassistant/components/wled/.translations/ca.json +++ b/homeassistant/components/wled/.translations/ca.json @@ -14,7 +14,7 @@ "host": "Amfitri\u00f3 o adre\u00e7a IP" }, "description": "Configura el teu WLED per integrar-lo amb Home Assistant.", - "title": "Enlla\u00e7a el teu WLED" + "title": "Enlla\u00e7 amb WLED" }, "zeroconf_confirm": { "description": "Vols afegir el WLED `{name}` a Home Assistant?", diff --git a/homeassistant/components/wled/.translations/hu.json b/homeassistant/components/wled/.translations/hu.json new file mode 100644 index 00000000000000..644b61ceb7390d --- /dev/null +++ b/homeassistant/components/wled/.translations/hu.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Ez a WLED eszk\u00f6z m\u00e1r konfigur\u00e1lva van.", + "connection_error": "Nem siker\u00fclt csatlakozni a WLED eszk\u00f6zh\u00f6z." + }, + "error": { + "connection_error": "Nem siker\u00fclt csatlakozni a WLED eszk\u00f6zh\u00f6z." + }, + "flow_title": "WLED: {name}", + "step": { + "user": { + "data": { + "host": "Hosztn\u00e9v vagy IP c\u00edm" + }, + "description": "\u00c1ll\u00edtsa be a WLED-et, hogy integr\u00e1l\u00f3djon a Home Assistant alkalmaz\u00e1sba.", + "title": "Csatlakoztassa a WLED-t" + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 akarja adni a {name} `nev\u0171 WLED-et a Home Assistant-hez?", + "title": "Felfedezett WLED eszk\u00f6z" + } + }, + "title": "WLED" + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/.translations/pl.json b/homeassistant/components/wled/.translations/pl.json index c10c8ab34d6afb..6080336c44f937 100644 --- a/homeassistant/components/wled/.translations/pl.json +++ b/homeassistant/components/wled/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "To urz\u0105dzenie WLED jest ju\u017c skonfigurowane", + "already_configured": "To urz\u0105dzenie WLED jest ju\u017c skonfigurowane.", "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem WLED." }, "error": { diff --git a/homeassistant/components/wled/.translations/sv.json b/homeassistant/components/wled/.translations/sv.json new file mode 100644 index 00000000000000..980c023118e4ed --- /dev/null +++ b/homeassistant/components/wled/.translations/sv.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r WLED-enheten \u00e4r redan konfigurerad.", + "connection_error": "Det gick inte att ansluta till WLED-enheten." + }, + "error": { + "connection_error": "Det gick inte att ansluta till WLED-enheten." + }, + "flow_title": "WLED: {name}", + "step": { + "user": { + "data": { + "host": "V\u00e4rd eller IP-adress" + }, + "description": "St\u00e4ll in din WLED f\u00f6r att integrera med Home Assistant.", + "title": "L\u00e4nka din WLED" + }, + "zeroconf_confirm": { + "description": "Vill du l\u00e4gga till WLED med namnet `{name}` till Home Assistant?", + "title": "Uppt\u00e4ckt WLED-enhet" + } + }, + "title": "WLED" + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 155cd022fd7948..dbcd55a7b17e6c 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -11,8 +11,8 @@ ConfigFlow, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -from homeassistant.helpers import ConfigType from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN # pylint: disable=unused-import diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index dcfdad963a7be8..94ee513f134c12 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -30,4 +30,3 @@ # Units of measurement CURRENT_MA = "mA" -DATA_BYTES = "bytes" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index c3fc2d4e6c2434..41e03d8c728469 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -4,20 +4,13 @@ from typing import Callable, List, Optional, Union from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import DATA_BYTES, DEVICE_CLASS_TIMESTAMP from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.dt import utcnow from . import WLED, WLEDDeviceEntity -from .const import ( - ATTR_LED_COUNT, - ATTR_MAX_POWER, - CURRENT_MA, - DATA_BYTES, - DATA_WLED_CLIENT, - DOMAIN, -) +from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, CURRENT_MA, DATA_WLED_CLIENT, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index e888b0c56144cb..21b84d87cbb7ac 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.9.12"], + "requirements": ["holidays==0.10.1"], "dependencies": [], "codeowners": ["@fabaff"], "quality_scale": "internal" diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 5afa3a3efcf38f..6ee55aa387fae0 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -13,6 +13,7 @@ CONF_API_KEY, CONF_ID, CONF_NAME, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -137,7 +138,7 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES def _parse_wsdot_timestamp(timestamp): diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py index 44eb10c0e7de0d..6a1112c028bf99 100644 --- a/homeassistant/components/wunderground/sensor.py +++ b/homeassistant/components/wunderground/sensor.py @@ -17,10 +17,13 @@ CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_FEET, LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_MILES, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -421,7 +424,10 @@ def _get_attributes(rest): "Station ID", "station_id", "mdi:home" ), "solarradiation": WUCurrentConditionsSensorConfig( - "Solar Radiation", "solarradiation", "mdi:weather-sunny", "w/m2" + "Solar Radiation", + "solarradiation", + "mdi:weather-sunny", + IRRADIATION_WATTS_PER_SQUARE_METER, ), "temperature_string": WUCurrentConditionsSensorConfig( "Temperature Summary", "temperature_string", "mdi:thermometer" @@ -455,16 +461,16 @@ def _get_attributes(rest): "Wind Direction", "wind_dir", "mdi:weather-windy" ), "wind_gust_kph": WUCurrentConditionsSensorConfig( - "Wind Gust", "wind_gust_kph", "mdi:weather-windy", "kph" + "Wind Gust", "wind_gust_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR ), "wind_gust_mph": WUCurrentConditionsSensorConfig( - "Wind Gust", "wind_gust_mph", "mdi:weather-windy", "mph" + "Wind Gust", "wind_gust_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR ), "wind_kph": WUCurrentConditionsSensorConfig( - "Wind Speed", "wind_kph", "mdi:weather-windy", "kph" + "Wind Speed", "wind_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR ), "wind_mph": WUCurrentConditionsSensorConfig( - "Wind Speed", "wind_mph", "mdi:weather-windy", "mph" + "Wind Speed", "wind_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR ), "wind_string": WUCurrentConditionsSensorConfig( "Wind Summary", "wind_string", "mdi:weather-windy" @@ -738,52 +744,132 @@ def _get_attributes(rest): device_class="temperature", ), "wind_gust_1d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind Today", 0, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind Today", + 0, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_2d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind Tomorrow", 1, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind Tomorrow", + 1, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_3d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 3 Days", 2, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind in 3 Days", + 2, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_4d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 4 Days", 3, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind in 4 Days", + 3, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_1d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind Today", 0, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind Today", + 0, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_2d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind Tomorrow", 1, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind Tomorrow", + 1, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_3d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 3 Days", 2, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind in 3 Days", + 2, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_4d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 4 Days", 3, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind in 4 Days", + 3, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_1d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Today", 0, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind Today", + 0, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_2d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Tomorrow", 1, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind Tomorrow", + 1, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_3d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 3 Days", 2, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind in 3 Days", + 2, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_4d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 4 Days", 3, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind in 4 Days", + 3, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_1d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Today", 0, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind Today", + 0, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_2d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Tomorrow", 1, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind Tomorrow", + 1, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_3d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 3 Days", 2, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind in 3 Days", + 2, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_4d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 4 Days", 3, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind in 4 Days", + 3, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "precip_1d_mm": WUDailySimpleForecastSensorConfig( "Precipitation Intensity Today", 0, "qpf_allday", "mm", "mm", "mdi:umbrella" diff --git a/homeassistant/components/wwlln/.translations/pl.json b/homeassistant/components/wwlln/.translations/pl.json index 652d580644fce6..658dbebbe45a19 100644 --- a/homeassistant/components/wwlln/.translations/pl.json +++ b/homeassistant/components/wwlln/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Lokalizacja ju\u017c zarejestrowana" + "identifier_exists": "Lokalizacja jest ju\u017c zarejestrowana." }, "step": { "user": { diff --git a/homeassistant/components/wwlln/geo_location.py b/homeassistant/components/wwlln/geo_location.py index e8dd7ec08c774c..e1ca47664d5051 100644 --- a/homeassistant/components/wwlln/geo_location.py +++ b/homeassistant/components/wwlln/geo_location.py @@ -35,7 +35,7 @@ DEFAULT_ICON = "mdi:flash" DEFAULT_UPDATE_INTERVAL = timedelta(minutes=10) -SIGNAL_DELETE_ENTITY = "delete_entity_{0}" +SIGNAL_DELETE_ENTITY = "wwlln_delete_entity_{0}" async def async_setup_entry(hass, entry, async_add_entities): diff --git a/homeassistant/components/xfinity/device_tracker.py b/homeassistant/components/xfinity/device_tracker.py index 20e13682979c2f..832c8bb1d5dc86 100644 --- a/homeassistant/components/xfinity/device_tracker.py +++ b/homeassistant/components/xfinity/device_tracker.py @@ -24,6 +24,11 @@ def get_scanner(hass, config): """Validate the configuration and return an Xfinity Gateway scanner.""" + _LOGGER.warning( + "The Xfinity Gateway has been deprecated and will be removed from " + "Home Assistant in version 0.109. Please remove it from your " + "configuration. " + ) gateway = XfinityGateway(config[DOMAIN][CONF_HOST]) scanner = None diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index 4568f67dbf5e65..fade5e1a51b131 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", "requirements": ["PyXiaomiGateway==0.12.4"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": ["@danielhiversen", "@syssi"] } diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 50de263fb15424..93aeb0d28b7006 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -5,7 +5,12 @@ import voluptuous as vol from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONF_HOST, + CONF_NAME, + CONF_TOKEN, +) from homeassistant.exceptions import NoEntitySpecifiedError, PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -21,6 +26,8 @@ ATTR_CO2E = "carbon_dioxide_equivalent" ATTR_TVOC = "total_volatile_organic_compounds" +ATTR_TEMP = "temperature" +ATTR_HUM = "humidity" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -33,6 +40,8 @@ PROP_TO_ATTR = { "carbon_dioxide_equivalent": ATTR_CO2E, "total_volatile_organic_compounds": ATTR_TVOC, + "temperature": ATTR_TEMP, + "humidity": ATTR_HUM, } @@ -84,13 +93,15 @@ def __init__(self, name, device, unique_id): self._device = device self._unique_id = unique_id self._icon = "mdi:cloud" - self._unit_of_measurement = "μg/m3" + self._unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER self._available = None self._air_quality_index = None self._carbon_dioxide = None self._carbon_dioxide_equivalent = None self._particulate_matter_2_5 = None self._total_volatile_organic_compounds = None + self._temperature = None + self._humidity = None async def async_update(self): """Fetch state from the miio device.""" @@ -100,6 +111,8 @@ async def async_update(self): self._carbon_dioxide_equivalent = state.co2e self._particulate_matter_2_5 = round(state.pm25, 1) self._total_volatile_organic_compounds = round(state.tvoc, 3) + self._temperature = round(state.temperature, 2) + self._humidity = round(state.humidity, 2) self._available = True except DeviceException as ex: self._available = False @@ -150,6 +163,16 @@ def total_volatile_organic_compounds(self): """Return the total volatile organic compounds.""" return self._total_volatile_organic_compounds + @property + def temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def humidity(self): + """Return the current humidity.""" + return self._humidity + @property def device_state_attributes(self): """Return the state attributes.""" @@ -179,6 +202,8 @@ async def async_update(self): self._carbon_dioxide = state.co2 self._particulate_matter_2_5 = state.pm25 self._total_volatile_organic_compounds = state.tvoc + self._temperature = state.temperature + self._humidity = state.humidity self._available = True except DeviceException as ex: self._available = False diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index bcc83bae454994..61462bcdbc0666 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -19,7 +19,6 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, - ATTR_ENTITY_ID, ATTR_HS_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, @@ -27,7 +26,7 @@ SUPPORT_COLOR_TEMP, Light, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util import color, dt diff --git a/homeassistant/components/yamaha/services.yaml b/homeassistant/components/yamaha/services.yaml index 592a1d1342e340..c92522008be0e9 100644 --- a/homeassistant/components/yamaha/services.yaml +++ b/homeassistant/components/yamaha/services.yaml @@ -2,7 +2,7 @@ enable_output: description: Enable or disable an output port fields: entity_id: - description: Name(s) of entites to enable/disable port on. + description: Name(s) of entities to enable/disable port on. example: 'media_player.yamaha' port: description: Name of port to enable/disable. diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 35c2a8ddfac3e6..1a181536d0b675 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/yeelight", "requirements": ["yeelight==0.5.0"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": ["@rytilahti", "@zewelor"] } diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py index c9392561fc8c12..5e7f4ed1db5977 100644 --- a/homeassistant/components/yr/sensor.py +++ b/homeassistant/components/yr/sensor.py @@ -21,6 +21,7 @@ DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PRESSURE_HPA, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -41,8 +42,8 @@ "symbol": ["Symbol", None, None], "precipitation": ["Precipitation", "mm", None], "temperature": ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "windSpeed": ["Wind speed", "m/s", None], - "windGust": ["Wind gust", "m/s", None], + "windSpeed": ["Wind speed", SPEED_METERS_PER_SECOND, None], + "windGust": ["Wind gust", SPEED_METERS_PER_SECOND, None], "pressure": ["Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE], "windDirection": ["Wind direction", "°", None], "humidity": ["Humidity", "%", DEVICE_CLASS_HUMIDITY], diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 44c216eb1be951..74335a2ccddd10 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -17,6 +17,7 @@ CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_KILOMETERS_PER_HOUR, __version__, ) import homeassistant.helpers.config_validation as cv @@ -39,9 +40,19 @@ "pressure": ("Pressure", "hPa", "LDstat hPa", float), "pressure_sealevel": ("Pressure at Sea Level", "hPa", "LDred hPa", float), "humidity": ("Humidity", "%", "RF %", int), - "wind_speed": ("Wind Speed", "km/h", "WG km/h", float), + "wind_speed": ( + "Wind Speed", + SPEED_KILOMETERS_PER_HOUR, + f"WG {SPEED_KILOMETERS_PER_HOUR}", + float, + ), "wind_bearing": ("Wind Bearing", "°", "WR °", int), - "wind_max_speed": ("Top Wind Speed", "km/h", "WSG km/h", float), + "wind_max_speed": ( + "Top Wind Speed", + SPEED_KILOMETERS_PER_HOUR, + f"WSG {SPEED_KILOMETERS_PER_HOUR}", + float, + ), "wind_max_bearing": ("Top Wind Bearing", "°", "WSR °", int), "sun_last_hour": ("Sun Last Hour", "%", "SO %", int), "temperature": ("Temperature", "°C", "T °C", float), diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b4dbbda51f1e14..206f529344f0f5 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -108,7 +108,6 @@ def service_update(zeroconf, service_type, name, state_change): def stop_zeroconf(_): """Stop Zeroconf.""" - zeroconf.unregister_service(info) zeroconf.close() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf) diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json index 2b8230ad689109..e5181fb5106a71 100644 --- a/homeassistant/components/zha/.translations/ca.json +++ b/homeassistant/components/zha/.translations/ca.json @@ -54,14 +54,14 @@ "device_shaken": "Dispositiu sacsejat", "device_slid": "Dispositiu lliscat a \"{subtype}\"", "device_tilted": "Dispositiu inclinat", - "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades consecutives", - "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut continuament", + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut cont\u00ednuament", "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", - "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades consecutives", - "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades consecutives", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades", "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", - "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades consecutives" + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/cs.json b/homeassistant/components/zha/.translations/cs.json new file mode 100644 index 00000000000000..0951ca3377e71d --- /dev/null +++ b/homeassistant/components/zha/.translations/cs.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "trigger_subtype": { + "button_2": "Druh\u00e9 tla\u010d\u00edtko", + "button_3": "T\u0159et\u00ed tla\u010d\u00edtko", + "button_4": "\u010ctvrt\u00e9 tla\u010d\u00edtko", + "button_5": "P\u00e1t\u00e9 tla\u010d\u00edtko", + "button_6": "\u0160est\u00e9 tla\u010d\u00edtko", + "close": "Zav\u0159\u00edt", + "dim_down": "ztmavit", + "dim_up": "ro\u017ehnout" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pl.json b/homeassistant/components/zha/.translations/pl.json index 4189ea6d9befe9..4698a0a37efdc4 100644 --- a/homeassistant/components/zha/.translations/pl.json +++ b/homeassistant/components/zha/.translations/pl.json @@ -54,14 +54,14 @@ "device_shaken": "nast\u0105pi potrz\u0105\u015bni\u0119cie urz\u0105dzeniem", "device_slid": "nast\u0105pi przesuni\u0119cie urz\u0105dzenia \"{subtype}\"", "device_tilted": "nast\u0105pi przechylenie urz\u0105dzenia", - "remote_button_double_press": "przycisk \"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", - "remote_button_long_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", - "remote_button_long_release": "przycisk \"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", - "remote_button_quadruple_press": "przycisk \"{subtype}\" czterokrotnie naci\u015bni\u0119ty", - "remote_button_quintuple_press": "przycisk \"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", - "remote_button_short_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty", - "remote_button_short_release": "przycisk \"{subtype}\" zostanie zwolniony", - "remote_button_triple_press": "przycisk \"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" + "remote_button_double_press": "\"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", + "remote_button_long_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "remote_button_long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_quadruple_press": "\"{subtype}\" czterokrotnie naci\u015bni\u0119ty", + "remote_button_quintuple_press": "\"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", + "remote_button_short_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty", + "remote_button_short_release": "\"{subtype}\" zostanie zwolniony", + "remote_button_triple_press": "\"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json index 8850fdfc07a504..38b0aa8359cb78 100644 --- a/homeassistant/components/zha/.translations/ru.json +++ b/homeassistant/components/zha/.translations/ru.json @@ -12,10 +12,10 @@ "radio_type": "\u0422\u0438\u043f \u0420\u0430\u0434\u0438\u043e", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, - "title": "Zigbee Home Automation (ZHA)" + "title": "Zigbee Home Automation" } }, - "title": "Zigbee Home Automation" + "title": "Zigbee Home Automation (ZHA)" }, "device_automation": { "action_type": { diff --git a/homeassistant/components/zha/.translations/sv.json b/homeassistant/components/zha/.translations/sv.json index 2762adc0fba19f..473cf1cd2a979c 100644 --- a/homeassistant/components/zha/.translations/sv.json +++ b/homeassistant/components/zha/.translations/sv.json @@ -18,15 +18,50 @@ "title": "ZHA" }, "device_automation": { + "action_type": { + "squawk": "Kraxa", + "warn": "Varna" + }, "trigger_subtype": { + "both_buttons": "B\u00e5da knapparna", + "button_1": "F\u00f6rsta knappen", + "button_2": "Andra knappen", + "button_3": "Tredje knappen", + "button_4": "Fj\u00e4rde knappen", + "button_5": "Femte knappen", + "button_6": "Sj\u00e4tte knappen", "close": "St\u00e4ng", "dim_down": "Dimma ned", "dim_up": "Dimma upp", + "face_1": "med bildsida 1 aktiverat", + "face_2": "med bildsida 2 aktiverat", + "face_3": "med bildsida 3 aktiverat", + "face_4": "med bildsida 4 aktiverat", + "face_5": "med bildsida 5 aktiverat", + "face_6": "med bildsida 6 aktiverat", + "face_any": "Med valfri/specificerad bildsida(or) aktiverat", "left": "V\u00e4nster", "open": "\u00d6ppen", "right": "H\u00f6ger", "turn_off": "St\u00e4ng av", "turn_on": "Starta" + }, + "trigger_type": { + "device_dropped": "Enheten tappades", + "device_flipped": "Enheten v\u00e4nd \"{subtype}\"", + "device_knocked": "Enheten knackad \"{subtype}\"", + "device_rotated": "Enheten roterade \"{subtype}\"", + "device_shaken": "Enheten skakad", + "device_slid": "Enheten gled \"{subtype}\"", + "device_tilted": "Enheten lutad", + "remote_button_double_press": "\"{subtype}\"-knappen dubbelklickades", + "remote_button_long_press": "\"{subtype}\"-knappen kontinuerligt nedtryckt", + "remote_button_long_release": "\"{subtype}\"-knappen sl\u00e4pptes efter ett l\u00e5ngttryck", + "remote_button_quadruple_press": "\"{subtype}\"-knappen klickades \nfyrfaldigt", + "remote_button_quintuple_press": "\"{subtype}\"-knappen klickades \nfemfaldigt", + "remote_button_short_press": "\"{subtype}\"-knappen trycktes in", + "remote_button_short_release": "\"{subtype}\"-knappen sl\u00e4ppt", + "remote_button_triple_press": "\"{subtype}\"-knappen trippelklickades" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 377c77bf601f83..0d4ceed829b2ba 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" +import asyncio import logging import voluptuous as vol @@ -22,6 +23,7 @@ DATA_ZHA_CONFIG, DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY, + DATA_ZHA_PLATFORM_LOADED, DEFAULT_BAUDRATE, DEFAULT_RADIO_TYPE, DOMAIN, @@ -87,11 +89,23 @@ async def async_setup_entry(hass, config_entry): Will automatically load components to support devices found on the network. """ - for component in COMPONENTS: - hass.data[DATA_ZHA][component] = hass.data[DATA_ZHA].get(component, {}) - hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] + hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED] = asyncio.Event() + platforms = [] + for component in COMPONENTS: + platforms.append( + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + ) + + async def _platforms_loaded(): + await asyncio.gather(*platforms) + hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED].set() + + hass.async_create_task(_platforms_loaded()) + config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) if config.get(CONF_ENABLE_QUIRKS, True): @@ -112,11 +126,6 @@ async def async_setup_entry(hass, config_entry): model=zha_gateway.radio_description, ) - for component in COMPONENTS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, component) - ) - api.async_load_api(hass) async def async_zha_shutdown(event): @@ -125,6 +134,7 @@ async def async_zha_shutdown(event): await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_update_device_storage() hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown) + hass.async_create_task(zha_gateway.async_load_devices()) return True diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 3871a26c9d79a7..ea5586ef96f579 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -12,7 +12,6 @@ from homeassistant.components import websocket_api from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( @@ -53,11 +52,7 @@ WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) -from .core.helpers import ( - async_get_device_info, - async_is_bindable_target, - get_matched_clusters, -) +from .core.helpers import async_is_bindable_target, get_matched_clusters _LOGGER = logging.getLogger(__name__) @@ -212,13 +207,9 @@ def async_cleanup() -> None: async def websocket_get_devices(hass, connection, msg): """Get ZHA devices.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) - devices = [] - for device in zha_gateway.devices.values(): - devices.append( - async_get_device_info(hass, device, ha_device_registry=ha_device_registry) - ) + devices = [device.async_get_info() for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) @@ -228,16 +219,13 @@ async def websocket_get_devices(hass, connection, msg): async def websocket_get_groupable_devices(hass, connection, msg): """Get ZHA devices that can be grouped.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) - - devices = [] - for device in zha_gateway.devices.values(): - if device.is_groupable: - devices.append( - async_get_device_info( - hass, device, ha_device_registry=ha_device_registry - ) - ) + + devices = [ + device.async_get_info() + for device in zha_gateway.devices.values() + if device.is_groupable or device.is_coordinator + ] + connection.send_result(msg[ID], devices) @@ -246,7 +234,8 @@ async def websocket_get_groupable_devices(hass, connection, msg): @websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) async def websocket_get_groups(hass, connection, msg): """Get ZHA groups.""" - groups = await get_groups(hass) + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + groups = [group.async_get_info() for group in zha_gateway.groups.values()] connection.send_result(msg[ID], groups) @@ -258,13 +247,10 @@ async def websocket_get_groups(hass, connection, msg): async def websocket_get_device(hass, connection, msg): """Get ZHA devices.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) ieee = msg[ATTR_IEEE] device = None if ieee in zha_gateway.devices: - device = async_get_device_info( - hass, zha_gateway.devices[ieee], ha_device_registry=ha_device_registry - ) + device = zha_gateway.devices[ieee].async_get_info() if not device: connection.send_message( websocket_api.error_message( @@ -283,17 +269,11 @@ async def websocket_get_device(hass, connection, msg): async def websocket_get_group(hass, connection, msg): """Get ZHA group.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) group_id = msg[GROUP_ID] group = None - if group_id in zha_gateway.application_controller.groups: - group = async_get_group_info( - hass, - zha_gateway, - zha_gateway.application_controller.groups[group_id], - ha_device_registry, - ) + if group_id in zha_gateway.groups: + group = zha_gateway.groups.get(group_id).async_get_info() if not group: connection.send_message( websocket_api.error_message( @@ -316,28 +296,10 @@ async def websocket_get_group(hass, connection, msg): async def websocket_add_group(hass, connection, msg): """Add a new ZHA group.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) group_name = msg[GROUP_NAME] - zigpy_group = async_get_group_by_name(zha_gateway, group_name) - ret_group = None members = msg.get(ATTR_MEMBERS) - # we start with one to fill any gaps from a user removing existing groups - group_id = 1 - while group_id in zha_gateway.application_controller.groups: - group_id += 1 - - # guard against group already existing - if zigpy_group is None: - zigpy_group = zha_gateway.application_controller.groups.add_group( - group_id, group_name - ) - if members is not None: - tasks = [] - for ieee in members: - tasks.append(zha_gateway.devices[ieee].async_add_to_group(group_id)) - await asyncio.gather(*tasks) - ret_group = async_get_group_info(hass, zha_gateway, zigpy_group, ha_device_registry) - connection.send_result(msg[ID], ret_group) + group = await zha_gateway.async_create_zigpy_group(group_name, members) + connection.send_result(msg[ID], group.async_get_info()) @websocket_api.require_admin @@ -351,17 +313,16 @@ async def websocket_add_group(hass, connection, msg): async def websocket_remove_groups(hass, connection, msg): """Remove the specified ZHA groups.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - groups = zha_gateway.application_controller.groups group_ids = msg[GROUP_IDS] if len(group_ids) > 1: tasks = [] for group_id in group_ids: - tasks.append(remove_group(groups[group_id], zha_gateway)) + tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) await asyncio.gather(*tasks) else: - await remove_group(groups[group_ids[0]], zha_gateway) - ret_groups = await get_groups(hass) + await zha_gateway.async_remove_zigpy_group(group_ids[0]) + ret_groups = [group.async_get_info() for group in zha_gateway.groups.values()] connection.send_result(msg[ID], ret_groups) @@ -377,25 +338,21 @@ async def websocket_remove_groups(hass, connection, msg): async def websocket_add_group_members(hass, connection, msg): """Add members to a ZHA group.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) group_id = msg[GROUP_ID] members = msg[ATTR_MEMBERS] - zigpy_group = None + zha_group = None - if group_id in zha_gateway.application_controller.groups: - zigpy_group = zha_gateway.application_controller.groups[group_id] - tasks = [] - for ieee in members: - tasks.append(zha_gateway.devices[ieee].async_add_to_group(group_id)) - await asyncio.gather(*tasks) - if not zigpy_group: + if group_id in zha_gateway.groups: + zha_group = zha_gateway.groups.get(group_id) + await zha_group.async_add_members(members) + if not zha_group: connection.send_message( websocket_api.error_message( msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" ) ) return - ret_group = async_get_group_info(hass, zha_gateway, zigpy_group, ha_device_registry) + ret_group = zha_group.async_get_info() connection.send_result(msg[ID], ret_group) @@ -411,88 +368,24 @@ async def websocket_add_group_members(hass, connection, msg): async def websocket_remove_group_members(hass, connection, msg): """Remove members from a ZHA group.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) group_id = msg[GROUP_ID] members = msg[ATTR_MEMBERS] - zigpy_group = None + zha_group = None - if group_id in zha_gateway.application_controller.groups: - zigpy_group = zha_gateway.application_controller.groups[group_id] - tasks = [] - for ieee in members: - tasks.append(zha_gateway.devices[ieee].async_remove_from_group(group_id)) - await asyncio.gather(*tasks) - if not zigpy_group: + if group_id in zha_gateway.groups: + zha_group = zha_gateway.groups.get(group_id) + await zha_group.async_remove_members(members) + if not zha_group: connection.send_message( websocket_api.error_message( msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" ) ) return - ret_group = async_get_group_info(hass, zha_gateway, zigpy_group, ha_device_registry) + ret_group = zha_group.async_get_info() connection.send_result(msg[ID], ret_group) -async def get_groups(hass,): - """Get ZHA Groups.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ha_device_registry = await async_get_registry(hass) - - groups = [] - for group in zha_gateway.application_controller.groups.values(): - groups.append( - async_get_group_info(hass, zha_gateway, group, ha_device_registry) - ) - return groups - - -async def remove_group(group, zha_gateway): - """Remove ZHA Group.""" - if group.members: - tasks = [] - for member_ieee in group.members.keys(): - if member_ieee[0] in zha_gateway.devices: - tasks.append( - zha_gateway.devices[member_ieee[0]].async_remove_from_group( - group.group_id - ) - ) - if tasks: - await asyncio.gather(*tasks) - else: - # we have members but none are tracked by ZHA for whatever reason - zha_gateway.application_controller.groups.pop(group.group_id) - else: - zha_gateway.application_controller.groups.pop(group.group_id) - - -@callback -def async_get_group_info(hass, zha_gateway, group, ha_device_registry): - """Get ZHA group.""" - ret_group = {} - ret_group["group_id"] = group.group_id - ret_group["name"] = group.name - ret_group["members"] = [ - async_get_device_info( - hass, - zha_gateway.get_device(member_ieee[0]), - ha_device_registry=ha_device_registry, - ) - for member_ieee in group.members.keys() - if member_ieee[0] in zha_gateway.devices - ] - return ret_group - - -@callback -def async_get_group_by_name(zha_gateway, group_name): - """Get ZHA group by name.""" - for group in zha_gateway.application_controller.groups.values(): - if group.name == group_name: - return group - return None - - @websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( @@ -712,9 +605,9 @@ async def websocket_get_bindable_devices(hass, connection, msg): zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] source_ieee = msg[ATTR_IEEE] source_device = zha_gateway.get_device(source_ieee) - ha_device_registry = await async_get_registry(hass) + devices = [ - async_get_device_info(hass, device, ha_device_registry=ha_device_registry) + device.async_get_info() for device in zha_gateway.devices.values() if async_is_bindable_target(source_device, device) ] @@ -887,6 +780,7 @@ async def async_binding_operation(zha_gateway, source_ieee, target_ieee, operati zdo.debug(fmt, *(log_msg[2] + (outcome,))) +@callback def async_load_api(hass): """Set up the web socket API.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] @@ -908,10 +802,10 @@ async def permit(service): async def remove(service): """Remove a node from the network.""" - ieee = service.data.get(ATTR_IEEE_ADDRESS) + ieee = service.data[ATTR_IEEE_ADDRESS] zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] zha_device = zha_gateway.get_device(ieee) - if zha_device.is_coordinator: + if zha_device is not None and zha_device.is_coordinator: _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) return _LOGGER.info("Removing node %s", ieee) @@ -1165,6 +1059,7 @@ async def warning_device_warn(service): websocket_api.async_register_command(hass, websocket_unbind_devices) +@callback def async_unload_api(hass): """Unload the ZHA API.""" hass.services.async_remove(DOMAIN, SERVICE_PERMIT) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index d25410a0667365..93baf8e111bb35 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -18,6 +18,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_ACCELEROMETER, CHANNEL_OCCUPANCY, @@ -25,8 +26,8 @@ CHANNEL_ZONE, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -48,41 +49,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation binary sensor from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if binary_sensors is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, binary_sensors.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA binary sensors.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, BinarySensor) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - class BinarySensor(ZhaEntity, BinarySensorDevice): """ZHA BinarySensor.""" @@ -125,6 +102,7 @@ def device_class(self) -> str: """Return device class from component DEVICE_CLASSES.""" return self._device_class + @callback def async_set_state(self, state): """Set the state.""" self._state = bool(state) diff --git a/homeassistant/components/zha/core/__init__.py b/homeassistant/components/zha/core/__init__.py index 1873cd7dc55be3..a416ff2eebe195 100644 --- a/homeassistant/components/zha/core/__init__.py +++ b/homeassistant/components/zha/core/__init__.py @@ -1,9 +1,4 @@ -""" -Core module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Core module for Zigbee Home Automation.""" # flake8: noqa from .device import ZHADevice diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a5ecf21e0c38e1..ea838a05665e06 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -1,408 +1,318 @@ -""" -Channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Channels module for Zigbee Home Automation.""" import asyncio -from concurrent.futures import TimeoutError as Timeout -from enum import Enum -from functools import wraps import logging -from random import uniform - -import zigpy.exceptions +from typing import Any, Dict, List, Optional, Union from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from ..const import ( - CHANNEL_EVENT_RELAY, - CHANNEL_ZDO, - REPORT_CONFIG_DEFAULT, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_RPT_CHANGE, - SIGNAL_ATTR_UPDATED, +from . import ( # noqa: F401 # pylint: disable=unused-import + base, + closures, + general, + homeautomation, + hvac, + lighting, + lightlink, + manufacturerspecific, + measurement, + protocol, + security, + smartenergy, +) +from .. import ( + const, + device as zha_core_device, + discovery as zha_disc, + registries as zha_regs, + typing as zha_typing, ) -from ..helpers import LogMixin, get_attr_id_by_name, safe_read -from ..registries import CLUSTER_REPORT_CONFIGS _LOGGER = logging.getLogger(__name__) +ChannelsDict = Dict[str, zha_typing.ChannelType] -def parse_and_log_command(channel, tsn, command_id, args): - """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] - channel.debug( - "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, - args, - channel.cluster.cluster_id, - tsn, - ) - return cmd - - -def decorate_command(channel, command): - """Wrap a cluster command to make it safe.""" - - @wraps(command) - async def wrapper(*args, **kwds): - try: - result = await command(*args, **kwds) - channel.debug( - "executed command: %s %s %s %s", - command.__name__, - "{}: {}".format("with args", args), - "{}: {}".format("with kwargs", kwds), - "{}: {}".format("and result", result), - ) - return result - - except (zigpy.exceptions.DeliveryError, Timeout) as ex: - channel.debug("command failed: %s exception: %s", command.__name__, str(ex)) - return ex - - return wrapper - +class Channels: + """All discovered channels of a device.""" -class ChannelStatus(Enum): - """Status of a channel.""" - - CREATED = 1 - CONFIGURED = 2 - INITIALIZED = 3 + def __init__(self, zha_device: zha_typing.ZhaDeviceType) -> None: + """Initialize instance.""" + self._pools: List[zha_typing.ChannelPoolType] = [] + self._power_config = None + self._semaphore = asyncio.Semaphore(3) + self._unique_id = str(zha_device.ieee) + self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device) + self._zha_device = zha_device + @property + def pools(self) -> List["ChannelPool"]: + """Return channel pools list.""" + return self._pools -class ZigbeeChannel(LogMixin): - """Base channel for a Zigbee cluster.""" + @property + def power_configuration_ch(self) -> zha_typing.ChannelType: + """Return power configuration channel.""" + return self._power_config - CHANNEL_NAME = None - REPORT_CONFIG = () + @power_configuration_ch.setter + def power_configuration_ch(self, channel: zha_typing.ChannelType) -> None: + """Power configuration channel setter.""" + if self._power_config is None: + self._power_config = channel - def __init__(self, cluster, device): - """Initialize ZigbeeChannel.""" - self._channel_name = cluster.ep_attribute - if self.CHANNEL_NAME: - self._channel_name = self.CHANNEL_NAME - self._generic_id = f"channel_0x{cluster.cluster_id:04x}" - self._cluster = cluster - self._zha_device = device - self._id = f"{cluster.endpoint.endpoint_id}:0x{cluster.cluster_id:04x}" - self._unique_id = f"{str(device.ieee)}:{self._id}" - self._report_config = CLUSTER_REPORT_CONFIGS.get( - self._cluster.cluster_id, self.REPORT_CONFIG - ) - self._status = ChannelStatus.CREATED - self._cluster.add_listener(self) + @property + def semaphore(self) -> asyncio.Semaphore: + """Return semaphore for concurrent tasks.""" + return self._semaphore @property - def id(self) -> str: - """Return channel id unique for this device only.""" - return self._id + def zdo_channel(self) -> zha_typing.ZDOChannelType: + """Return ZDO channel.""" + return self._zdo_channel @property - def generic_id(self): - """Return the generic id for this channel.""" - return self._generic_id + def zha_device(self) -> zha_typing.ZhaDeviceType: + """Return parent zha device.""" + return self._zha_device @property def unique_id(self): """Return the unique id for this channel.""" return self._unique_id - @property - def cluster(self): - """Return the zigpy cluster for this channel.""" - return self._cluster - - @property - def device(self): - """Return the device this channel is linked to.""" - return self._zha_device - - @property - def name(self) -> str: - """Return friendly name.""" - return self._channel_name + @classmethod + def new(cls, zha_device: zha_typing.ZhaDeviceType) -> "Channels": + """Create new instance.""" + channels = cls(zha_device) + for ep_id in sorted(zha_device.device.endpoints): + channels.add_pool(ep_id) + return channels + + def add_pool(self, ep_id: int) -> None: + """Add channels for a specific endpoint.""" + if ep_id == 0: + return + self._pools.append(ChannelPool.new(self, ep_id)) + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed channels.""" + await self.zdo_channel.async_initialize(from_cache) + self.zdo_channel.debug("'async_initialize' stage succeeded") + await asyncio.gather( + *(pool.async_initialize(from_cache) for pool in self.pools) + ) - @property - def status(self): - """Return the status of the channel.""" - return self._status - - def set_report_config(self, report_config): - """Set the reporting configuration.""" - self._report_config = report_config - - async def bind(self): - """Bind a zigbee cluster. - - This also swallows DeliveryError exceptions that are thrown when - devices are unreachable. - """ - try: - res = await self.cluster.bind() - self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) - except (zigpy.exceptions.DeliveryError, Timeout) as ex: - self.debug( - "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex) - ) + async def async_configure(self) -> None: + """Configure claimed channels.""" + await self.zdo_channel.async_configure() + self.zdo_channel.debug("'async_configure' stage succeeded") + await asyncio.gather(*(pool.async_configure() for pool in self.pools)) - async def configure_reporting( + @callback + def async_new_entity( self, - attr, - report_config=( - REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE, - ), + component: str, + entity_class: zha_typing.CALLABLE_T, + unique_id: str, + channels: List[zha_typing.ChannelType], ): - """Configure attribute reporting for a cluster. - - This also swallows DeliveryError exceptions that are thrown when - devices are unreachable. - """ - attr_name = self.cluster.attributes.get(attr, [attr])[0] + """Signal new entity addition.""" + if self.zha_device.status == zha_core_device.DeviceStatus.INITIALIZED: + return - kwargs = {} - if self.cluster.cluster_id >= 0xFC00 and self.device.manufacturer_code: - kwargs["manufacturer"] = self.device.manufacturer_code - - min_report_int, max_report_int, reportable_change = report_config - try: - res = await self.cluster.configure_reporting( - attr, min_report_int, max_report_int, reportable_change, **kwargs - ) - self.debug( - "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - attr_name, - self.cluster.ep_attribute, - min_report_int, - max_report_int, - reportable_change, - res, - ) - except (zigpy.exceptions.DeliveryError, Timeout) as ex: - self.debug( - "failed to set reporting for '%s' attr on '%s' cluster: %s", - attr_name, - self.cluster.ep_attribute, - str(ex), - ) - - async def async_configure(self): - """Set cluster binding and attribute reporting.""" - # Xiaomi devices don't need this and it disrupts pairing - if self._zha_device.manufacturer != "LUMI": - await self.bind() - if self.cluster.is_server: - for report_config in self._report_config: - await self.configure_reporting( - report_config["attr"], report_config["config"] - ) - await asyncio.sleep(uniform(0.1, 0.5)) - - self.debug("finished channel configuration") - self._status = ChannelStatus.CONFIGURED - - async def async_initialize(self, from_cache): - """Initialize channel.""" - self.debug("initializing channel: from_cache: %s", from_cache) - self._status = ChannelStatus.INITIALIZED - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - pass - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - pass + self.zha_device.hass.data[const.DATA_ZHA][component].append( + (entity_class, (unique_id, self.zha_device, channels)) + ) @callback - def zdo_command(self, *args, **kwargs): - """Handle ZDO commands on this cluster.""" - pass + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + async_dispatcher_send(self.zha_device.hass, signal, *args) @callback - def zha_send_event(self, cluster, command, args): + def zha_send_event(self, event_data: Dict[str, Union[str, int]]) -> None: """Relay events to hass.""" - self._zha_device.hass.bus.async_fire( + self.zha_device.hass.bus.async_fire( "zha_event", { - "unique_id": self._unique_id, - "device_ieee": str(self._zha_device.ieee), - "endpoint_id": cluster.endpoint.endpoint_id, - "cluster_id": cluster.cluster_id, - "command": command, - "args": args, + const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), + const.ATTR_UNIQUE_ID: self.unique_id, + **event_data, }, ) - async def async_update(self): - """Retrieve latest state from cluster.""" - pass - - async def get_attribute_value(self, attribute, from_cache=True): - """Get the value for an attribute.""" - manufacturer = None - manufacturer_code = self._zha_device.manufacturer_code - if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: - manufacturer = manufacturer_code - result = await safe_read( - self._cluster, - [attribute], - allow_cache=from_cache, - only_cache=from_cache, - manufacturer=manufacturer, - ) - return result.get(attribute) - def log(self, level, msg, *args): - """Log a message.""" - msg = f"[%s:%s]: {msg}" - args = (self.device.nwk, self._id,) + args - _LOGGER.log(level, msg, *args) +class ChannelPool: + """All channels of an endpoint.""" - def __getattr__(self, name): - """Get attribute or a decorated cluster command.""" - if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)): - command = getattr(self._cluster, name) - command.__name__ = name - return decorate_command(self, command) - return self.__getattribute__(name) + def __init__(self, channels: Channels, ep_id: int): + """Initialize instance.""" + self._all_channels: ChannelsDict = {} + self._channels: Channels = channels + self._claimed_channels: ChannelsDict = {} + self._id: int = ep_id + self._relay_channels: Dict[str, zha_typing.EventRelayChannelType] = {} + self._unique_id: str = f"{channels.unique_id}-{ep_id}" + @property + def all_channels(self) -> ChannelsDict: + """All channels of an endpoint.""" + return self._all_channels -class AttributeListeningChannel(ZigbeeChannel): - """Channel for attribute reports from the cluster.""" + @property + def claimed_channels(self) -> ChannelsDict: + """Channels in use.""" + return self._claimed_channels - REPORT_CONFIG = [{"attr": 0, "config": REPORT_CONFIG_DEFAULT}] + @property + def endpoint(self) -> zha_typing.ZigpyEndpointType: + """Return endpoint of zigpy device.""" + return self._channels.zha_device.device.endpoints[self.id] - def __init__(self, cluster, device): - """Initialize AttributeListeningChannel.""" - super().__init__(cluster, device) - attr = self._report_config[0].get("attr") - if isinstance(attr, str): - self.value_attribute = get_attr_id_by_name(self.cluster, attr) - else: - self.value_attribute = attr + @property + def id(self) -> int: + """Return endpoint id.""" + return self._id - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == self.value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + @property + def nwk(self) -> int: + """Device NWK for logging.""" + return self._channels.zha_device.nwk - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - self._report_config[0].get("attr"), from_cache=from_cache - ) - await super().async_initialize(from_cache) + @property + def manufacturer(self) -> Optional[str]: + """Return device manufacturer.""" + return self._channels.zha_device.manufacturer + @property + def manufacturer_code(self) -> Optional[int]: + """Return device manufacturer.""" + return self._channels.zha_device.manufacturer_code -class ZDOChannel(LogMixin): - """Channel for ZDO events.""" + @property + def model(self) -> Optional[str]: + """Return device model.""" + return self._channels.zha_device.model - def __init__(self, cluster, device): - """Initialize ZDOChannel.""" - self.name = CHANNEL_ZDO - self._cluster = cluster - self._zha_device = device - self._status = ChannelStatus.CREATED - self._unique_id = "{}:{}_ZDO".format(str(device.ieee), device.name) - self._cluster.add_listener(self) + @property + def relay_channels(self) -> Dict[str, zha_typing.EventRelayChannelType]: + """Return a dict of event relay channels.""" + return self._relay_channels + + @property + def skip_configuration(self) -> bool: + """Return True if device does not require channel configuration.""" + return self._channels.zha_device.skip_configuration @property def unique_id(self): """Return the unique id for this channel.""" return self._unique_id - @property - def cluster(self): - """Return the aigpy cluster for this channel.""" - return self._cluster - - @property - def status(self): - """Return the status of the channel.""" - return self._status + @classmethod + def new(cls, channels: Channels, ep_id: int) -> "ChannelPool": + """Create new channels for an endpoint.""" + pool = cls(channels, ep_id) + pool.add_all_channels() + pool.add_relay_channels() + zha_disc.PROBE.discover_entities(pool) + return pool @callback - def device_announce(self, zigpy_device): - """Device announce handler.""" - pass + def add_all_channels(self) -> None: + """Create and add channels for all input clusters.""" + for cluster_id, cluster in self.endpoint.in_clusters.items(): + channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( + cluster_id, base.AttributeListeningChannel + ) + # really ugly hack to deal with xiaomi using the door lock cluster + # incorrectly. + if ( + hasattr(cluster, "ep_attribute") + and cluster.ep_attribute == "multistate_input" + ): + channel_class = base.AttributeListeningChannel + # end of ugly hack + channel = channel_class(cluster, self) + if channel.name == const.CHANNEL_POWER_CONFIGURATION: + if ( + self._channels.power_configuration_ch + or self._channels.zha_device.is_mains_powered + ): + # on power configuration channel per device + continue + self._channels.power_configuration_ch = channel + + self.all_channels[channel.id] = channel @callback - def permit_duration(self, duration): - """Permit handler.""" - pass - - async def async_initialize(self, from_cache): - """Initialize channel.""" - entry = self._zha_device.gateway.zha_storage.async_get_or_create( - self._zha_device - ) - self.debug("entry loaded from storage: %s", entry) - self._status = ChannelStatus.INITIALIZED - - async def async_configure(self): - """Configure channel.""" - self._status = ChannelStatus.CONFIGURED + def add_relay_channels(self) -> None: + """Create relay channels for all output clusters if in the registry.""" + for cluster_id in zha_regs.EVENT_RELAY_CLUSTERS: + cluster = self.endpoint.out_clusters.get(cluster_id) + if cluster is not None: + channel = base.EventRelayChannel(cluster, self) + self.relay_channels[channel.id] = channel + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed channels.""" + await self._execute_channel_tasks("async_initialize", from_cache) + + async def async_configure(self) -> None: + """Configure claimed channels.""" + await self._execute_channel_tasks("async_configure") + + async def _execute_channel_tasks(self, func_name: str, *args: Any) -> None: + """Add a throttled channel task and swallow exceptions.""" + + async def _throttle(coro): + async with self._channels.semaphore: + return await coro + + channels = [*self.claimed_channels.values(), *self.relay_channels.values()] + tasks = [_throttle(getattr(ch, func_name)(*args)) for ch in channels] + results = await asyncio.gather(*tasks, return_exceptions=True) + for channel, outcome in zip(channels, results): + if isinstance(outcome, Exception): + channel.warning("'%s' stage failed: %s", func_name, str(outcome)) + continue + channel.debug("'%s' stage succeeded", func_name) - def log(self, level, msg, *args): - """Log a message.""" - msg = f"[%s:ZDO](%s): {msg}" - args = (self._zha_device.nwk, self._zha_device.model) + args - _LOGGER.log(level, msg, *args) + @callback + def async_new_entity( + self, + component: str, + entity_class: zha_typing.CALLABLE_T, + unique_id: str, + channels: List[zha_typing.ChannelType], + ): + """Signal new entity addition.""" + self._channels.async_new_entity(component, entity_class, unique_id, channels) + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + self._channels.async_send_signal(signal, *args) -class EventRelayChannel(ZigbeeChannel): - """Event relay that can be attached to zigbee clusters.""" + @callback + def claim_channels(self, channels: List[zha_typing.ChannelType]) -> None: + """Claim a channel.""" + self.claimed_channels.update({ch.id: ch for ch in channels}) - CHANNEL_NAME = CHANNEL_EVENT_RELAY + @callback + def unclaimed_channels(self) -> List[zha_typing.ChannelType]: + """Return a list of available (unclaimed) channels.""" + claimed = set(self.claimed_channels) + available = set(self.all_channels) + return [self.all_channels[chan_id] for chan_id in (available - claimed)] @callback - def attribute_updated(self, attrid, value): - """Handle an attribute updated on this cluster.""" - self.zha_send_event( - self._cluster, - SIGNAL_ATTR_UPDATED, + def zha_send_event(self, event_data: Dict[str, Union[str, int]]) -> None: + """Relay events to hass.""" + self._channels.zha_send_event( { - "attribute_id": attrid, - "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[0], - "value": value, - }, + const.ATTR_UNIQUE_ID: self.unique_id, + const.ATTR_ENDPOINT_ID: self.id, + **event_data, + } ) - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle a cluster command received on this cluster.""" - if ( - self._cluster.server_commands is not None - and self._cluster.server_commands.get(command_id) is not None - ): - self.zha_send_event( - self._cluster, self._cluster.server_commands.get(command_id)[0], args - ) - - -# pylint: disable=wrong-import-position, import-outside-toplevel -from . import ( # noqa: F401 isort:skip - closures, - general, - homeautomation, - hvac, - lighting, - lightlink, - manufacturerspecific, - measurement, - protocol, - security, - smartenergy, -) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py new file mode 100644 index 00000000000000..7bb2ad7b57e835 --- /dev/null +++ b/homeassistant/components/zha/core/channels/base.py @@ -0,0 +1,383 @@ +"""Base classes for channels.""" + +import asyncio +from enum import Enum +from functools import wraps +import logging +from random import uniform +from typing import Any, Union + +import zigpy.exceptions + +from homeassistant.core import callback + +from .. import typing as zha_typing +from ..const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE_ID, + ATTR_ATTRIBUTE_NAME, + ATTR_CLUSTER_ID, + ATTR_COMMAND, + ATTR_UNIQUE_ID, + ATTR_VALUE, + CHANNEL_EVENT_RELAY, + CHANNEL_ZDO, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_RPT_CHANGE, + SIGNAL_ATTR_UPDATED, +) +from ..helpers import LogMixin, get_attr_id_by_name, safe_read + +_LOGGER = logging.getLogger(__name__) + + +def parse_and_log_command(channel, tsn, command_id, args): + """Parse and log a zigbee cluster command.""" + cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + channel.debug( + "received '%s' command with %s args on cluster_id '%s' tsn '%s'", + cmd, + args, + channel.cluster.cluster_id, + tsn, + ) + return cmd + + +def decorate_command(channel, command): + """Wrap a cluster command to make it safe.""" + + @wraps(command) + async def wrapper(*args, **kwds): + try: + result = await command(*args, **kwds) + channel.debug( + "executed '%s' command with args: '%s' kwargs: '%s' result: %s", + command.__name__, + args, + kwds, + result, + ) + return result + + except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex: + channel.debug("command failed: %s exception: %s", command.__name__, str(ex)) + return ex + + return wrapper + + +class ChannelStatus(Enum): + """Status of a channel.""" + + CREATED = 1 + CONFIGURED = 2 + INITIALIZED = 3 + + +class ZigbeeChannel(LogMixin): + """Base channel for a Zigbee cluster.""" + + CHANNEL_NAME = None + REPORT_CONFIG = () + + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: + """Initialize ZigbeeChannel.""" + self._channel_name = cluster.ep_attribute + if self.CHANNEL_NAME: + self._channel_name = self.CHANNEL_NAME + self._ch_pool = ch_pool + self._generic_id = f"channel_0x{cluster.cluster_id:04x}" + self._cluster = cluster + self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" + unique_id = ch_pool.unique_id.replace("-", ":") + self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" + self._report_config = self.REPORT_CONFIG + self._status = ChannelStatus.CREATED + self._cluster.add_listener(self) + + @property + def id(self) -> str: + """Return channel id unique for this device only.""" + return self._id + + @property + def generic_id(self): + """Return the generic id for this channel.""" + return self._generic_id + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @property + def cluster(self): + """Return the zigpy cluster for this channel.""" + return self._cluster + + @property + def name(self) -> str: + """Return friendly name.""" + return self._channel_name + + @property + def status(self): + """Return the status of the channel.""" + return self._status + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + self._ch_pool.async_send_signal(signal, *args) + + async def bind(self): + """Bind a zigbee cluster. + + This also swallows DeliveryError exceptions that are thrown when + devices are unreachable. + """ + try: + res = await self.cluster.bind() + self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) + except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex: + self.debug( + "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex) + ) + + async def configure_reporting( + self, + attr, + report_config=( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, + ), + ): + """Configure attribute reporting for a cluster. + + This also swallows DeliveryError exceptions that are thrown when + devices are unreachable. + """ + attr_name = self.cluster.attributes.get(attr, [attr])[0] + + kwargs = {} + if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: + kwargs["manufacturer"] = self._ch_pool.manufacturer_code + + min_report_int, max_report_int, reportable_change = report_config + try: + res = await self.cluster.configure_reporting( + attr, min_report_int, max_report_int, reportable_change, **kwargs + ) + self.debug( + "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", + attr_name, + self.cluster.ep_attribute, + min_report_int, + max_report_int, + reportable_change, + res, + ) + except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex: + self.debug( + "failed to set reporting for '%s' attr on '%s' cluster: %s", + attr_name, + self.cluster.ep_attribute, + str(ex), + ) + + async def async_configure(self): + """Set cluster binding and attribute reporting.""" + if not self._ch_pool.skip_configuration: + await self.bind() + if self.cluster.is_server: + for report_config in self._report_config: + await self.configure_reporting( + report_config["attr"], report_config["config"] + ) + await asyncio.sleep(uniform(0.1, 0.5)) + self.debug("finished channel configuration") + else: + self.debug("skipping channel configuration") + self._status = ChannelStatus.CONFIGURED + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self.debug("initializing channel: from_cache: %s", from_cache) + self._status = ChannelStatus.INITIALIZED + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + pass + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + pass + + @callback + def zdo_command(self, *args, **kwargs): + """Handle ZDO commands on this cluster.""" + pass + + @callback + def zha_send_event(self, command: str, args: Union[int, dict]) -> None: + """Relay events to hass.""" + self._ch_pool.zha_send_event( + { + ATTR_UNIQUE_ID: self.unique_id, + ATTR_CLUSTER_ID: self.cluster.cluster_id, + ATTR_COMMAND: command, + ATTR_ARGS: args, + } + ) + + async def async_update(self): + """Retrieve latest state from cluster.""" + pass + + async def get_attribute_value(self, attribute, from_cache=True): + """Get the value for an attribute.""" + manufacturer = None + manufacturer_code = self._ch_pool.manufacturer_code + if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: + manufacturer = manufacturer_code + result = await safe_read( + self._cluster, + [attribute], + allow_cache=from_cache, + only_cache=from_cache, + manufacturer=manufacturer, + ) + return result.get(attribute) + + def log(self, level, msg, *args): + """Log a message.""" + msg = f"[%s:%s]: {msg}" + args = (self._ch_pool.nwk, self._id) + args + _LOGGER.log(level, msg, *args) + + def __getattr__(self, name): + """Get attribute or a decorated cluster command.""" + if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)): + command = getattr(self._cluster, name) + command.__name__ = name + return decorate_command(self, command) + return self.__getattribute__(name) + + +class AttributeListeningChannel(ZigbeeChannel): + """Channel for attribute reports from the cluster.""" + + REPORT_CONFIG = [{"attr": 0, "config": REPORT_CONFIG_DEFAULT}] + + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: + """Initialize AttributeListeningChannel.""" + super().__init__(cluster, ch_pool) + attr = self._report_config[0].get("attr") + if isinstance(attr, str): + self.value_attribute = get_attr_id_by_name(self.cluster, attr) + else: + self.value_attribute = attr + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == self.value_attribute: + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + self._report_config[0].get("attr"), from_cache=from_cache + ) + await super().async_initialize(from_cache) + + +class ZDOChannel(LogMixin): + """Channel for ZDO events.""" + + def __init__(self, cluster, device): + """Initialize ZDOChannel.""" + self.name = CHANNEL_ZDO + self._cluster = cluster + self._zha_device = device + self._status = ChannelStatus.CREATED + self._unique_id = "{}:{}_ZDO".format(str(device.ieee), device.name) + self._cluster.add_listener(self) + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @property + def cluster(self): + """Return the aigpy cluster for this channel.""" + return self._cluster + + @property + def status(self): + """Return the status of the channel.""" + return self._status + + @callback + def device_announce(self, zigpy_device): + """Device announce handler.""" + pass + + @callback + def permit_duration(self, duration): + """Permit handler.""" + pass + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self._status = ChannelStatus.INITIALIZED + + async def async_configure(self): + """Configure channel.""" + self._status = ChannelStatus.CONFIGURED + + def log(self, level, msg, *args): + """Log a message.""" + msg = f"[%s:ZDO](%s): {msg}" + args = (self._zha_device.nwk, self._zha_device.model) + args + _LOGGER.log(level, msg, *args) + + +class EventRelayChannel(ZigbeeChannel): + """Event relay that can be attached to zigbee clusters.""" + + CHANNEL_NAME = CHANNEL_EVENT_RELAY + + @callback + def attribute_updated(self, attrid, value): + """Handle an attribute updated on this cluster.""" + self.zha_send_event( + SIGNAL_ATTR_UPDATED, + { + ATTR_ATTRIBUTE_ID: attrid, + ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, ["Unknown"])[ + 0 + ], + ATTR_VALUE: value, + }, + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if ( + self._cluster.server_commands is not None + and self._cluster.server_commands.get(command_id) is not None + ): + self.zha_send_event(self._cluster.server_commands.get(command_id)[0], args) diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index 03b1a8450db41e..e25c2253bb3b2f 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -1,19 +1,13 @@ -""" -Closures channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Closures channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.closures as closures from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -29,9 +23,7 @@ async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("lock_state", from_cache=True) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) @callback def attribute_updated(self, attrid, value): @@ -41,9 +33,7 @@ def attribute_updated(self, attrid, value): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -74,9 +64,7 @@ async def async_update(self): ) self.debug("read current position: %s", result) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) @callback def attribute_updated(self, attrid, value): @@ -86,9 +74,7 @@ def attribute_updated(self, attrid, value): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index c1701479a438bc..3e41e961f0a51a 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -1,19 +1,12 @@ -""" -General channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""General channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.general as general from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from . import AttributeListeningChannel, ZigbeeChannel, parse_and_log_command -from .. import registries +from .. import registries, typing as zha_typing from ..const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_BATTERY_SAVE, @@ -25,6 +18,7 @@ SIGNAL_STATE_ATTR, ) from ..helpers import get_attr_id_by_name +from .base import AttributeListeningChannel, ZigbeeChannel, parse_and_log_command _LOGGER = logging.getLogger(__name__) @@ -82,9 +76,11 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize BasicChannel.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._power_source = None async def async_configure(self): @@ -198,9 +194,7 @@ def attribute_updated(self, attrid, value): def dispatch_level_change(self, command, level): """Dispatch level change.""" - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{command}", level - ) + self.async_send_signal(f"{self.unique_id}_{command}", level) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -241,9 +235,11 @@ class OnOffChannel(ZigbeeChannel): ON_OFF = 0 REPORT_CONFIG = ({"attr": "on_off", "config": REPORT_CONFIG_IMMEDIATE},) - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize OnOffChannel.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._state = None self._off_listener = None @@ -284,9 +280,7 @@ def set_to_off(self, *_): def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.ON_OFF: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) self._state = bool(value) async def async_initialize(self, from_cache): @@ -298,10 +292,11 @@ async def async_initialize(self, from_cache): async def async_update(self): """Initialize channel.""" - from_cache = not self.device.is_mains_powered - self.debug("attempting to update onoff state - from cache: %s", from_cache) + if self.cluster.is_client: + return + self.debug("attempting to update onoff state - from cache: False") self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + await self.get_attribute_value(self.ON_OFF, from_cache=False) ) await super().async_update() @@ -353,16 +348,11 @@ def attribute_updated(self, attrid, value): else: attr_id = attr if attrid == attr_id: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) return attr_name = self.cluster.attributes.get(attrid, [attrid])[0] - async_dispatcher_send( - self._zha_device.hass, - f"{self.unique_id}_{SIGNAL_STATE_ATTR}", - attr_name, - value, + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_STATE_ATTR}", attr_name, value ) async def async_initialize(self, from_cache): diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index d9d8f57eaaf5ef..e47aca5eafd523 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -1,23 +1,16 @@ -""" -Home automation channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Home automation channels module for Zigbee Home Automation.""" import logging from typing import Optional import zigpy.zcl.clusters.homeautomation as homeautomation -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from . import AttributeListeningChannel, ZigbeeChannel -from .. import registries +from .. import registries, typing as zha_typing from ..const import ( CHANNEL_ELECTRICAL_MEASUREMENT, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ) +from .base import AttributeListeningChannel, ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -66,9 +59,11 @@ class ElectricalMeasurementChannel(AttributeListeningChannel): REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},) - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize Metering.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._divisor = None self._multiplier = None @@ -78,9 +73,7 @@ async def async_update(self): # This is a polling channel. Don't allow cache. result = await self.get_attribute_value("active_power", from_cache=False) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index db4745d51c382b..e4519d5cb2cd43 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -1,20 +1,14 @@ -""" -HVAC channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""HVAC channels module for Zigbee Home Automation.""" import logging from zigpy.exceptions import DeliveryError import zigpy.zcl.clusters.hvac as hvac from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel from .. import registries from ..const import REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -47,9 +41,7 @@ async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("fan_mode", from_cache=True) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) @callback def attribute_updated(self, attrid, value): @@ -59,9 +51,7 @@ def attribute_updated(self, attrid, value): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 272fa28905cfb9..c87235d9ec02d3 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -1,16 +1,11 @@ -""" -Lighting channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Lighting channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.lighting as lighting -from . import ZigbeeChannel -from .. import registries +from .. import registries, typing as zha_typing from ..const import REPORT_CONFIG_DEFAULT +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -38,9 +33,11 @@ class ColorChannel(ZigbeeChannel): {"attr": "color_temperature", "config": REPORT_CONFIG_DEFAULT}, ) - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize ColorChannel.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._color_capabilities = None def get_color_capabilities(self): diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py index 7cd2134988d367..af0248c9713c5f 100644 --- a/homeassistant/components/zha/core/channels/lightlink.py +++ b/homeassistant/components/zha/core/channels/lightlink.py @@ -1,15 +1,10 @@ -""" -Lightlink channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Lightlink channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.lightlink as lightlink -from . import ZigbeeChannel from .. import registries +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 39f45f6c4a2791..90f81513ec466a 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -1,22 +1,20 @@ -""" -Manufacturer specific channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Manufacturer specific channels module for Zigbee Home Automation.""" import logging from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import AttributeListeningChannel, ZigbeeChannel from .. import registries from ..const import ( + ATTR_ATTRIBUTE_ID, + ATTR_ATTRIBUTE_NAME, + ATTR_VALUE, REPORT_CONFIG_ASAP, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, + UNKNOWN, ) +from .base import AttributeListeningChannel, ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -58,18 +56,14 @@ class SmartThingsAcceleration(AttributeListeningChannel): def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) - else: - self.zha_send_event( - self._cluster, - SIGNAL_ATTR_UPDATED, - { - "attribute_id": attrid, - "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[ - 0 - ], - "value": value, - }, - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + return + + self.zha_send_event( + SIGNAL_ATTR_UPDATED, + { + ATTR_ATTRIBUTE_ID: attrid, + ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, [UNKNOWN])[0], + ATTR_VALUE: value, + }, + ) diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 369ecb69aa1020..68952c64e8d9c4 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -1,14 +1,8 @@ -""" -Measurement channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Measurement channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.measurement as measurement -from . import AttributeListeningChannel from .. import registries from ..const import ( REPORT_CONFIG_DEFAULT, @@ -16,6 +10,7 @@ REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) +from .base import AttributeListeningChannel _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py index aa463392e557c1..db7488e9a7f87a 100644 --- a/homeassistant/components/zha/core/channels/protocol.py +++ b/homeassistant/components/zha/core/channels/protocol.py @@ -1,15 +1,10 @@ -""" -Protocol channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Protocol channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.protocol as protocol from .. import registries -from ..channels import ZigbeeChannel +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 69e4ea1a27ad68..20390c018d8fd6 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -4,18 +4,16 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/integrations/zha/ """ +import asyncio import logging from zigpy.exceptions import DeliveryError import zigpy.zcl.clusters.security as security from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel from .. import registries from ..const import ( - CLUSTER_COMMAND_SERVER, SIGNAL_ATTR_UPDATED, WARNING_DEVICE_MODE_EMERGENCY, WARNING_DEVICE_SOUND_HIGH, @@ -23,6 +21,7 @@ WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -75,13 +74,7 @@ async def squawk( value = IasWd.set_bit(value, 6, mode, 2) value = IasWd.set_bit(value, 7, mode, 3) - await self.device.issue_cluster_command( - self.cluster.endpoint.endpoint_id, - self.cluster.cluster_id, - 0x0001, - CLUSTER_COMMAND_SERVER, - [value], - ) + await self.squawk(value) async def start_warning( self, @@ -116,12 +109,8 @@ async def start_warning( value = IasWd.set_bit(value, 6, mode, 2) value = IasWd.set_bit(value, 7, mode, 3) - await self.device.issue_cluster_command( - self.cluster.endpoint.endpoint_id, - self.cluster.cluster_id, - 0x0000, - CLUSTER_COMMAND_SERVER, - [value, warning_duration, strobe_duty_cycle, strobe_intensity], + await self.start_warning( + value, warning_duration, strobe_duty_cycle, strobe_intensity ) @@ -135,20 +124,18 @@ def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == 0: state = args[0] & 3 - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", state - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", state) self.debug("Updated alarm state: %s", state) elif command_id == 1: self.debug("Enroll requested") res = self._cluster.enroll_response(0, 0) - self._zha_device.hass.async_create_task(res) + asyncio.create_task(res) async def async_configure(self): """Configure IAS device.""" - # Xiaomi devices don't need this and it disrupts pairing - if self._zha_device.manufacturer == "LUMI": - self.debug("finished IASZoneChannel configuration") + await self.get_attribute_value("zone_type", from_cache=False) + if self._ch_pool.skip_configuration: + self.debug("skipping IASZoneChannel configuration") return self.debug("started IASZoneChannel configuration") @@ -173,16 +160,12 @@ async def async_configure(self): ) self.debug("finished IASZoneChannel configuration") - await self.get_attribute_value("zone_type", from_cache=False) - @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == 2: value = value & 3 - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index c7de2943691a5f..c7cad5e455dc9a 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -1,18 +1,14 @@ -""" -Smart energy channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Smart energy channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.smartenergy as smartenergy +from homeassistant.const import TIME_HOURS, TIME_SECONDS from homeassistant.core import callback -from .. import registries -from ..channels import AttributeListeningChannel, ZigbeeChannel +from .. import registries, typing as zha_typing from ..const import REPORT_CONFIG_DEFAULT +from .base import AttributeListeningChannel, ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -81,23 +77,25 @@ class Metering(AttributeListeningChannel): unit_of_measure_map = { 0x00: "kW", - 0x01: "m³/h", - 0x02: "ft³/h", - 0x03: "ccf/h", - 0x04: "US gal/h", - 0x05: "IMP gal/h", - 0x06: "BTU/h", - 0x07: "l/h", + 0x01: f"m³/{TIME_HOURS}", + 0x02: f"ft³/{TIME_HOURS}", + 0x03: f"ccf/{TIME_HOURS}", + 0x04: f"US gal/{TIME_HOURS}", + 0x05: f"IMP gal/{TIME_HOURS}", + 0x06: f"BTU/{TIME_HOURS}", + 0x07: f"l/{TIME_HOURS}", 0x08: "kPa", 0x09: "kPa", - 0x0A: "mcf/h", + 0x0A: f"mcf/{TIME_HOURS}", 0x0B: "unitless", - 0x0C: "MJ/s", + 0x0C: f"MJ/{TIME_SECONDS}", } - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize Metering.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._divisor = None self._multiplier = None self._unit_enum = None diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 3fbb62f84333c2..cb0ac2182ec76e 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -13,11 +13,14 @@ ATTR_ARGS = "args" ATTR_ATTRIBUTE = "attribute" +ATTR_ATTRIBUTE_ID = "attribute_id" +ATTR_ATTRIBUTE_NAME = "attribute_name" ATTR_AVAILABLE = "available" ATTR_CLUSTER_ID = "cluster_id" ATTR_CLUSTER_TYPE = "cluster_type" ATTR_COMMAND = "command" ATTR_COMMAND_TYPE = "command_type" +ATTR_DEVICE_IEEE = "device_ieee" ATTR_DEVICE_TYPE = "device_type" ATTR_ENDPOINT_ID = "endpoint_id" ATTR_IEEE = "ieee" @@ -36,6 +39,7 @@ ATTR_RSSI = "rssi" ATTR_SIGNATURE = "signature" ATTR_TYPE = "type" +ATTR_UNIQUE_ID = "unique_id" ATTR_VALUE = "value" ATTR_WARNING_DEVICE_DURATION = "duration" ATTR_WARNING_DEVICE_MODE = "mode" @@ -47,6 +51,7 @@ BINDINGS = "bindings" CHANNEL_ACCELEROMETER = "accelerometer" +CHANNEL_ANALOG_INPUT = "analog_input" CHANNEL_ATTRIBUTE = "attribute" CHANNEL_BASIC = "basic" CHANNEL_COLOR = "light_color" @@ -92,10 +97,12 @@ DATA_ZHA_CORE_EVENTS = "zha_core_events" DATA_ZHA_DISPATCHERS = "zha_dispatchers" DATA_ZHA_GATEWAY = "zha_gateway" +DATA_ZHA_PLATFORM_LOADED = "platform_loaded" DEBUG_COMP_BELLOWS = "bellows" DEBUG_COMP_ZHA = "homeassistant.components.zha" DEBUG_COMP_ZIGPY = "zigpy" +DEBUG_COMP_ZIGPY_CC = "zigpy_cc" DEBUG_COMP_ZIGPY_DECONZ = "zigpy_deconz" DEBUG_COMP_ZIGPY_XBEE = "zigpy_xbee" DEBUG_COMP_ZIGPY_ZIGATE = "zigpy_zigate" @@ -105,8 +112,9 @@ DEBUG_COMP_BELLOWS: logging.DEBUG, DEBUG_COMP_ZHA: logging.DEBUG, DEBUG_COMP_ZIGPY: logging.DEBUG, - DEBUG_COMP_ZIGPY_XBEE: logging.DEBUG, + DEBUG_COMP_ZIGPY_CC: logging.DEBUG, DEBUG_COMP_ZIGPY_DECONZ: logging.DEBUG, + DEBUG_COMP_ZIGPY_XBEE: logging.DEBUG, DEBUG_COMP_ZIGPY_ZIGATE: logging.DEBUG, } DEBUG_RELAY_LOGGERS = [DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY] @@ -131,9 +139,10 @@ class RadioType(enum.Enum): """Possible options for radio type.""" + deconz = "deconz" ezsp = "ezsp" + ti_cc = "ti_cc" xbee = "xbee" - deconz = "deconz" zigate = "zigate" @classmethod @@ -189,6 +198,7 @@ def list(cls): SENSOR_TEMPERATURE = CHANNEL_TEMPERATURE SENSOR_TYPE = "sensor_type" +SIGNAL_ADD_ENTITIES = "zha_add_new_entities" SIGNAL_ATTR_UPDATED = "attribute_updated" SIGNAL_AVAILABLE = "available" SIGNAL_MOVE_LEVEL = "move_level" @@ -225,13 +235,18 @@ def list(cls): WARNING_DEVICE_SQUAWK_MODE_DISARMED = 1 ZHA_DISCOVERY_NEW = "zha_discovery_new_{}" -ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" ZHA_GW_MSG = "zha_gateway_message" -ZHA_GW_MSG_DEVICE_REMOVED = "device_removed" -ZHA_GW_MSG_DEVICE_INFO = "device_info" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" +ZHA_GW_MSG_DEVICE_INFO = "device_info" ZHA_GW_MSG_DEVICE_JOINED = "device_joined" -ZHA_GW_MSG_LOG_OUTPUT = "log_output" +ZHA_GW_MSG_DEVICE_REMOVED = "device_removed" +ZHA_GW_MSG_GROUP_ADDED = "group_added" +ZHA_GW_MSG_GROUP_INFO = "group_info" +ZHA_GW_MSG_GROUP_MEMBER_ADDED = "group_member_added" +ZHA_GW_MSG_GROUP_MEMBER_REMOVED = "group_member_removed" +ZHA_GW_MSG_GROUP_REMOVED = "group_removed" ZHA_GW_MSG_LOG_ENTRY = "log_entry" +ZHA_GW_MSG_LOG_OUTPUT = "log_output" +ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" ZHA_GW_RADIO = "radio" ZHA_GW_RADIO_DESCRIPTION = "radio_description" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 3ed44a8f2aa67c..54c1bbe49a8305 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -1,9 +1,4 @@ -""" -Device for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Device for Zigbee Home Automation.""" import asyncio from datetime import timedelta from enum import Enum @@ -23,8 +18,9 @@ async_dispatcher_send, ) from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType -from .channels import EventRelayChannel +from . import channels, typing as zha_typing from .const import ( ATTR_ARGS, ATTR_ATTRIBUTE, @@ -47,9 +43,6 @@ ATTR_QUIRK_CLASS, ATTR_RSSI, ATTR_VALUE, - CHANNEL_BASIC, - CHANNEL_POWER_CONFIGURATION, - CHANNEL_ZDO, CLUSTER_COMMAND_SERVER, CLUSTER_COMMANDS_CLIENT, CLUSTER_COMMANDS_SERVER, @@ -80,14 +73,16 @@ class DeviceStatus(Enum): class ZHADevice(LogMixin): """ZHA Zigbee device object.""" - def __init__(self, hass, zigpy_device, zha_gateway): + def __init__( + self, + hass: HomeAssistantType, + zigpy_device: zha_typing.ZigpyDeviceType, + zha_gateway: zha_typing.ZhaGatewayType, + ): """Initialize the gateway.""" self.hass = hass self._zigpy_device = zigpy_device self._zha_gateway = zha_gateway - self.cluster_channels = {} - self._relay_channels = {} - self._all_channels = [] self._available = False self._available_signal = "{}_{}_{}".format( self.name, self.ieee, SIGNAL_AVAILABLE @@ -104,7 +99,34 @@ def __init__(self, hass, zigpy_device, zha_gateway): self._available_check = async_track_time_interval( self.hass, self._check_available, _UPDATE_ALIVE_INTERVAL ) + self._ha_device_id = None self.status = DeviceStatus.CREATED + self._channels = channels.Channels(self) + + @property + def device_id(self): + """Return the HA device registry device id.""" + return self._ha_device_id + + def set_device_id(self, device_id): + """Set the HA device registry device id.""" + self._ha_device_id = device_id + + @property + def device(self) -> zha_typing.ZigpyDeviceType: + """Return underlying Zigpy device.""" + return self._zigpy_device + + @property + def channels(self) -> zha_typing.ChannelsType: + """Return ZHA channels.""" + return self._channels + + @channels.setter + def channels(self, value: zha_typing.ChannelsType) -> None: + """Channels setter.""" + assert isinstance(value, channels.Channels) + self._channels = value @property def name(self): @@ -203,16 +225,16 @@ def is_groupable(self): if Groups.cluster_id in clusters: return True + @property + def skip_configuration(self): + """Return true if the device should not issue configuration related commands.""" + return self._zigpy_device.skip_configuration + @property def gateway(self): """Return the gateway for this device.""" return self._zha_gateway - @property - def all_channels(self): - """Return cluster channels and relay channels for device.""" - return self._all_channels - @property def device_automation_triggers(self): """Return the device automation triggers for this device.""" @@ -234,6 +256,19 @@ def set_available(self, available): """Set availability from restore and prevent signals.""" self._available = available + @classmethod + def new( + cls, + hass: HomeAssistantType, + zigpy_dev: zha_typing.ZigpyDeviceType, + gateway: zha_typing.ZhaGatewayType, + restored: bool = False, + ): + """Create new device.""" + zha_dev = cls(hass, zigpy_dev, gateway) + zha_dev.channels = channels.Channels.new(zha_dev) + return zha_dev + def _check_available(self, *_): if self.last_seen is None: self.update_available(False) @@ -242,16 +277,17 @@ def _check_available(self, *_): if difference > _KEEP_ALIVE_INTERVAL: if self._checkins_missed_count < _CHECKIN_GRACE_PERIODS: self._checkins_missed_count += 1 - if ( - CHANNEL_BASIC in self.cluster_channels - and self.manufacturer != "LUMI" - ): + if self.manufacturer != "LUMI": self.debug( "Attempting to checkin with device - missed checkins: %s", self._checkins_missed_count, ) + if not self._channels.pools: + return + pool = self._channels.pools[0] + basic_ch = pool.all_channels[f"{pool.id}:0"] self.hass.async_create_task( - self.cluster_channels[CHANNEL_BASIC].get_attribute_value( + basic_ch.get_attribute_value( ATTR_MANUFACTURER, from_cache=False ) ) @@ -294,66 +330,10 @@ def device_info(self): ATTR_DEVICE_TYPE: self.device_type, } - def add_cluster_channel(self, cluster_channel): - """Add cluster channel to device.""" - # only keep 1 power configuration channel - if ( - cluster_channel.name is CHANNEL_POWER_CONFIGURATION - and CHANNEL_POWER_CONFIGURATION in self.cluster_channels - ): - return - - if isinstance(cluster_channel, EventRelayChannel): - self._relay_channels[cluster_channel.unique_id] = cluster_channel - self._all_channels.append(cluster_channel) - else: - 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.""" self.debug("started configuration") - await self._execute_channel_tasks( - self.get_channels_to_configure(), "async_configure" - ) + await self._channels.async_configure() self.debug("completed configuration") entry = self.gateway.zha_storage.async_create_or_update(self) self.debug("stored in registry: %s", entry) @@ -361,41 +341,11 @@ async def async_configure(self): async def async_initialize(self, from_cache=False): """Initialize channels.""" self.debug("started initialization") - await self._execute_channel_tasks( - self.all_channels, "async_initialize", from_cache - ) + await self._channels.async_initialize(from_cache) self.debug("power source: %s", self.power_source) self.status = DeviceStatus.INITIALIZED self.debug("completed initialization") - 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 channels: - if channel.name == CHANNEL_ZDO: - 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(semaphore, channel, task_name, *args) - ) - if zdo_task is not None: - await zdo_task - await asyncio.gather(*channel_tasks) - - async def _async_create_task(self, semaphore, channel, func_name, *args): - """Configure a single channel on this device.""" - try: - async with semaphore: - await getattr(channel, func_name)(*args) - channel.debug("channel: '%s' stage succeeded", func_name) - except Exception as ex: # pylint: disable=broad-except - channel.warning("channel: '%s' stage failed ex: %s", func_name, ex) - @callback def async_unsub_dispatcher(self): """Unsubscribe the dispatcher.""" @@ -406,6 +356,25 @@ def async_update_last_seen(self, last_seen): """Set last seen on the zigpy device.""" self._zigpy_device.last_seen = last_seen + @callback + def async_get_info(self): + """Get ZHA device information.""" + device_info = {} + device_info.update(self.device_info) + device_info["entities"] = [ + { + "entity_id": entity_ref.reference_id, + ATTR_NAME: entity_ref.device_info[ATTR_NAME], + } + for entity_ref in self.gateway.device_registry[self.ieee] + ] + reg_device = self.gateway.ha_device_registry.async_get(self.device_id) + if reg_device is not None: + device_info["user_given_name"] = reg_device.name_by_user + device_info["device_reg_id"] = reg_device.id + device_info["area_id"] = reg_device.area_id + return device_info + @callback def async_get_clusters(self): """Get all clusters for this device.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index d128ed274c0ea6..e6b844b9c43d52 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -1,273 +1,150 @@ -""" -Device discovery functions for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Device discovery functions for Zigbee Home Automation.""" import logging - -import zigpy.profiles -from zigpy.zcl.clusters.general import OnOff, PowerConfiguration +from typing import Callable, List, Tuple from homeassistant import const as ha_const from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType -from .channels import AttributeListeningChannel, EventRelayChannel, ZDOChannel -from .const import COMPONENTS, CONF_DEVICE_CONFIG, DATA_ZHA, ZHA_DISCOVERY_NEW -from .registries import ( - CHANNEL_ONLY_CLUSTERS, - COMPONENT_CLUSTERS, - DEVICE_CLASS, - EVENT_RELAY_CLUSTERS, - OUTPUT_CHANNEL_ONLY_CLUSTERS, - REMOTE_DEVICE_TYPES, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - ZIGBEE_CHANNEL_REGISTRY, -) +from . import const as zha_const, registries as zha_regs, typing as zha_typing +from .channels import base _LOGGER = logging.getLogger(__name__) @callback -def async_process_endpoint( - hass, - config, - endpoint_id, - endpoint, - discovery_infos, - device, - zha_device, - is_new_join, -): - """Process an endpoint on a zigpy device.""" - if endpoint_id == 0: # ZDO - _async_create_cluster_channel( - endpoint, zha_device, is_new_join, channel_class=ZDOChannel - ) +async def async_add_entities( + _async_add_entities: Callable, + entities: List[ + Tuple[ + zha_typing.ZhaEntityType, + Tuple[str, zha_typing.ZhaDeviceType, List[zha_typing.ChannelType]], + ] + ], +) -> None: + """Add entities helper.""" + if not entities: return - - component = None - profile_clusters = [] - device_key = f"{device.ieee}-{endpoint_id}" - node_config = {} - if CONF_DEVICE_CONFIG in config: - node_config = config[CONF_DEVICE_CONFIG].get(device_key, {}) - - if endpoint.profile_id in zigpy.profiles.PROFILES: - if DEVICE_CLASS.get(endpoint.profile_id, {}).get(endpoint.device_type, None): - 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] - - 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, endpoint, zha_device, profile_clusters, device_key, is_new_join - ) - ) + to_add = [ent_cls(*args) for ent_cls, args in entities] + _async_add_entities(to_add, update_before_add=True) + entities.clear() -@callback -def _async_create_cluster_channel( - cluster, zha_device, is_new_join, channels=None, channel_class=None -): - """Create a cluster channel and attach it to a device.""" - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if hasattr(cluster, "ep_attribute") and cluster.ep_attribute == "multistate_input": - channel_class = AttributeListeningChannel - # end of ugly hack - if channel_class is None: - channel_class = ZIGBEE_CHANNEL_REGISTRY.get( - cluster.cluster_id, AttributeListeningChannel - ) - channel = channel_class(cluster, zha_device) - zha_device.add_cluster_channel(channel) - if channels is not None: - channels.append(channel) - - -@callback -def async_dispatch_discovery_info(hass, is_new_join, discovery_info): - """Dispatch or store discovery information.""" - if not discovery_info["channels"]: - _LOGGER.warning( - "there are no channels in the discovery info: %s", discovery_info - ) - return - component = discovery_info["component"] - if is_new_join: - async_dispatcher_send(hass, ZHA_DISCOVERY_NEW.format(component), discovery_info) - else: - hass.data[DATA_ZHA][component][discovery_info["unique_id"]] = discovery_info +class ProbeEndpoint: + """All discovered channels and entities of an endpoint.""" + def __init__(self): + """Initialize instance.""" + self._device_configs = {} -@callback -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 if c in endpoint.in_clusters - ] - out_clusters = [ - endpoint.out_clusters[c] for c in profile_clusters if c in endpoint.out_clusters - ] + @callback + def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on a zigpy device.""" + self.discover_by_device_type(channel_pool) + self.discover_by_cluster_id(channel_pool) - channels = [] + @callback + def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on a zigpy device.""" - for cluster in in_clusters: - _async_create_cluster_channel( - cluster, zha_device, is_new_join, channels=channels - ) + unique_id = channel_pool.unique_id - for cluster in out_clusters: - _async_create_cluster_channel( - cluster, zha_device, is_new_join, channels=channels - ) + component = self._device_configs.get(unique_id, {}).get(ha_const.CONF_TYPE) + if component is None: + ep_profile_id = channel_pool.endpoint.profile_id + ep_device_type = channel_pool.endpoint.device_type + component = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) - discovery_info = { - "unique_id": device_key, - "zha_device": zha_device, - "channels": channels, - "component": component, - } - - return discovery_info - - -@callback -def _async_handle_single_cluster_matches( - hass, endpoint, zha_device, profile_clusters, device_key, is_new_join -): - """Dispatch single cluster matches to HA components.""" - cluster_matches = [] - cluster_match_results = [] - matched_power_configuration = False - for cluster in endpoint.in_clusters.values(): - if cluster.cluster_id in CHANNEL_ONLY_CLUSTERS: - cluster_match_results.append( - _async_handle_channel_only_cluster_match( - zha_device, cluster, is_new_join - ) + if component and component in zha_const.COMPONENTS: + channels = channel_pool.unclaimed_channels() + entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + component, channel_pool.manufacturer, channel_pool.model, channels ) - continue - - if cluster.cluster_id not in profile_clusters: - # Only create one battery sensor per device - if cluster.cluster_id == PowerConfiguration.cluster_id and ( - zha_device.is_mains_powered or matched_power_configuration - ): + if entity_class is None: + return + channel_pool.claim_channels(claimed) + channel_pool.async_new_entity(component, entity_class, unique_id, claimed) + + @callback + def discover_by_cluster_id(self, channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on a zigpy device.""" + + items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() + single_input_clusters = { + cluster_class: match + for cluster_class, match in items + if not isinstance(cluster_class, int) + } + remaining_channels = channel_pool.unclaimed_channels() + for channel in remaining_channels: + if channel.cluster.cluster_id in zha_regs.CHANNEL_ONLY_CLUSTERS: + channel_pool.claim_channels([channel]) continue - if ( - cluster.cluster_id == PowerConfiguration.cluster_id - and not zha_device.is_mains_powered - ): - matched_power_configuration = True - - cluster_match_results.append( - _async_handle_single_cluster_match( - hass, - zha_device, - cluster, - device_key, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - is_new_join, - ) + component = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( + channel.cluster.cluster_id ) - - for cluster in endpoint.out_clusters.values(): - if cluster.cluster_id in OUTPUT_CHANNEL_ONLY_CLUSTERS: - cluster_match_results.append( - _async_handle_channel_only_cluster_match( - zha_device, cluster, is_new_join - ) + if component is None: + for cluster_class, match in single_input_clusters.items(): + if isinstance(channel.cluster, cluster_class): + component = match + break + + self.probe_single_cluster(component, channel, channel_pool) + + # until we can get rid off registries + self.handle_on_off_output_cluster_exception(channel_pool) + + @staticmethod + def probe_single_cluster( + component: str, + channel: zha_typing.ChannelType, + ep_channels: zha_typing.ChannelPoolType, + ) -> None: + """Probe specified cluster for specific component.""" + if component is None or component not in zha_const.COMPONENTS: + return + channel_list = [channel] + unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" + + entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + component, ep_channels.manufacturer, ep_channels.model, channel_list + ) + if entity_class is None: + return + ep_channels.claim_channels(claimed) + ep_channels.async_new_entity(component, entity_class, unique_id, claimed) + + def handle_on_off_output_cluster_exception( + self, ep_channels: zha_typing.ChannelPoolType + ) -> None: + """Process output clusters of the endpoint.""" + + profile_id = ep_channels.endpoint.profile_id + device_type = ep_channels.endpoint.device_type + if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []): + return + + for cluster_id, cluster in ep_channels.endpoint.out_clusters.items(): + component = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( + cluster.cluster_id ) - continue - - device_type = cluster.endpoint.device_type - profile_id = cluster.endpoint.profile_id - - if cluster.cluster_id not in profile_clusters: - # prevent remotes and controllers from getting entities - if not ( - cluster.cluster_id == OnOff.cluster_id - and profile_id in REMOTE_DEVICE_TYPES - and device_type in REMOTE_DEVICE_TYPES[profile_id] - ): - cluster_match_results.append( - _async_handle_single_cluster_match( - hass, - zha_device, - cluster, - device_key, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - is_new_join, - ) - ) + if component is None: + continue - if cluster.cluster_id in EVENT_RELAY_CLUSTERS: - _async_create_cluster_channel( - cluster, zha_device, is_new_join, channel_class=EventRelayChannel + channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( + cluster_id, base.AttributeListeningChannel ) + channel = channel_class(cluster, ep_channels) + self.probe_single_cluster(component, channel, ep_channels) - for cluster_match in cluster_match_results: - if cluster_match is not None: - cluster_matches.append(cluster_match) - return cluster_matches - - -@callback -def _async_handle_channel_only_cluster_match(zha_device, cluster, is_new_join): - """Handle a channel only cluster match.""" - _async_create_cluster_channel(cluster, zha_device, is_new_join) - - -@callback -def _async_handle_single_cluster_match( - hass, zha_device, cluster, device_key, device_classes, is_new_join -): - """Dispatch a single cluster match to a HA component.""" - component = None # sub_component = None - for cluster_type, candidate_component in device_classes.items(): - if isinstance(cluster_type, int): - if cluster.cluster_id == cluster_type: - component = candidate_component - elif isinstance(cluster, cluster_type): - component = candidate_component - break - - if component is None or component not in COMPONENTS: - return - channels = [] - _async_create_cluster_channel(cluster, zha_device, is_new_join, channels=channels) + def initialize(self, hass: HomeAssistantType) -> None: + """Update device overrides config.""" + zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {}) + overrides = zha_config.get(zha_const.CONF_DEVICE_CONFIG) + if overrides: + self._device_configs.update(overrides) - cluster_key = f"{device_key}-{cluster.cluster_id}" - discovery_info = { - "unique_id": cluster_key, - "zha_device": zha_device, - "channels": channels, - "entity_suffix": f"_{cluster.cluster_id}", - "component": component, - } - return discovery_info +PROBE = ProbeEndpoint() diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 72b5aa8732933a..90d8165c6404b4 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -1,9 +1,4 @@ -""" -Virtual gateway for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Virtual gateway for Zigbee Home Automation.""" import asyncio import collections @@ -12,6 +7,8 @@ import os import traceback +import zigpy.device as zigpy_dev + from homeassistant.components.system_log import LogEntry, _figure_out_source from homeassistant.core import callback from homeassistant.helpers.device_registry import ( @@ -19,7 +16,9 @@ async_get_registry as get_dev_reg, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg +from . import discovery, typing as zha_typing from .const import ( ATTR_IEEE, ATTR_MANUFACTURER, @@ -35,9 +34,11 @@ DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_GATEWAY, + DATA_ZHA_PLATFORM_LOADED, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, + DEBUG_COMP_ZIGPY_CC, DEBUG_COMP_ZIGPY_DECONZ, DEBUG_COMP_ZIGPY_XBEE, DEBUG_COMP_ZIGPY_ZIGATE, @@ -48,6 +49,7 @@ DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DOMAIN, + SIGNAL_ADD_ENTITIES, SIGNAL_REMOVE, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, @@ -56,6 +58,11 @@ ZHA_GW_MSG_DEVICE_INFO, ZHA_GW_MSG_DEVICE_JOINED, ZHA_GW_MSG_DEVICE_REMOVED, + ZHA_GW_MSG_GROUP_ADDED, + ZHA_GW_MSG_GROUP_INFO, + ZHA_GW_MSG_GROUP_MEMBER_ADDED, + ZHA_GW_MSG_GROUP_MEMBER_REMOVED, + ZHA_GW_MSG_GROUP_REMOVED, ZHA_GW_MSG_LOG_ENTRY, ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_RAW_INIT, @@ -63,8 +70,7 @@ ZHA_GW_RADIO_DESCRIPTION, ) from .device import DeviceStatus, ZHADevice -from .discovery import async_dispatch_discovery_info, async_process_endpoint -from .helpers import async_get_device_info +from .group import ZHAGroup from .patches import apply_application_controller_patch from .registries import RADIO_TYPES from .store import async_get_registry @@ -85,9 +91,11 @@ def __init__(self, hass, config, config_entry): self._hass = hass self._config = config self._devices = {} + self._groups = {} self._device_registry = collections.defaultdict(list) self.zha_storage = None self.ha_device_registry = None + self.ha_entity_registry = None self.application_controller = None self.radio_description = None hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self @@ -101,8 +109,11 @@ def __init__(self, hass, config, config_entry): async def async_initialize(self): """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + self.zha_storage = await async_get_registry(self._hass) self.ha_device_registry = await get_dev_reg(self._hass) + self.ha_entity_registry = await get_ent_reg(self._hass) usb_path = self._config_entry.data.get(CONF_USB_PATH) baudrate = self._config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE) @@ -121,24 +132,39 @@ async def async_initialize(self): self.application_controller = radio_details[CONTROLLER](radio, database) apply_application_controller_patch(self) self.application_controller.add_listener(self) + self.application_controller.groups.add_listener(self) await self.application_controller.startup(auto_form=True) self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str( self.application_controller.ieee ) + self._initialize_groups() + + async def async_load_devices(self) -> None: + """Restore ZHA devices from zigpy application state.""" + await self._hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED].wait() - init_tasks = [] semaphore = asyncio.Semaphore(2) - async def init_with_semaphore(coro, semaphore): - """Don't flood the zigbee network during initialization.""" + async def _throttle(device: zha_typing.ZigpyDeviceType): async with semaphore: - await coro + await self.async_device_restored(device) + + zigpy_devices = self.application_controller.devices.values() + _LOGGER.debug("Loading battery powered devices") + await asyncio.gather( + *[ + _throttle(dev) + for dev in zigpy_devices + if not dev.node_desc.is_mains_powered + ] + ) + async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) - for device in self.application_controller.devices.values(): - init_tasks.append( - init_with_semaphore(self.async_device_restored(device), semaphore) - ) - await asyncio.gather(*init_tasks) + _LOGGER.debug("Loading mains powered devices") + await asyncio.gather( + *[_throttle(dev) for dev in zigpy_devices if dev.node_desc.is_mains_powered] + ) + async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) def device_joined(self, device): """Handle device joined. @@ -176,9 +202,49 @@ def device_initialized(self, device): """Handle device joined and basic information discovered.""" self._hass.async_create_task(self.async_device_initialized(device)) - def device_left(self, device): + def device_left(self, device: zigpy_dev.Device): """Handle device leaving the network.""" - pass + self.async_update_device(device, False) + + def group_member_removed(self, zigpy_group, endpoint): + """Handle zigpy group member removed event.""" + # need to handle endpoint correctly on groups + zha_group = self._async_get_or_create_group(zigpy_group) + zha_group.info("group_member_removed - endpoint: %s", endpoint) + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) + + def group_member_added(self, zigpy_group, endpoint): + """Handle zigpy group member added event.""" + # need to handle endpoint correctly on groups + zha_group = self._async_get_or_create_group(zigpy_group) + zha_group.info("group_member_added - endpoint: %s", endpoint) + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) + + def group_added(self, zigpy_group): + """Handle zigpy group added event.""" + zha_group = self._async_get_or_create_group(zigpy_group) + zha_group.info("group_added") + # need to dispatch for entity creation here + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_ADDED) + + def group_removed(self, zigpy_group): + """Handle zigpy group added event.""" + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) + zha_group = self._groups.pop(zigpy_group.group_id, None) + zha_group.info("group_removed") + + def _send_group_gateway_message(self, zigpy_group, gateway_message_type): + """Send the gareway event for a zigpy group event.""" + zha_group = self._groups.get(zigpy_group.group_id, None) + if zha_group is not None: + async_dispatcher_send( + self._hass, + ZHA_GW_MSG, + { + ATTR_TYPE: gateway_message_type, + ZHA_GW_MSG_GROUP_INFO: zha_group.async_get_info(), + }, + ) async def _async_remove_device(self, device, entity_refs): if entity_refs is not None: @@ -186,9 +252,7 @@ async def _async_remove_device(self, device, entity_refs): for entity_ref in entity_refs: remove_tasks.append(entity_ref.remove_future) await asyncio.wait(remove_tasks) - reg_device = self.ha_device_registry.async_get_device( - {(DOMAIN, str(device.ieee))}, set() - ) + reg_device = self.ha_device_registry.async_get(device.device_id) if reg_device is not None: self.ha_device_registry.async_remove_device(reg_device.id) @@ -197,7 +261,7 @@ def device_removed(self, device): zha_device = self._devices.pop(device.ieee, None) entity_refs = self._device_registry.pop(device.ieee, None) if zha_device is not None: - device_info = async_get_device_info(self._hass, zha_device) + device_info = zha_device.async_get_info() zha_device.async_unsub_dispatcher() async_dispatcher_send( self._hass, "{}_{}".format(SIGNAL_REMOVE, str(zha_device.ieee)) @@ -219,7 +283,15 @@ def get_device(self, ieee): def get_group(self, group_id): """Return Group for given group id.""" - return self.application_controller.groups[group_id] + return self.groups.get(group_id) + + @callback + def async_get_group_by_name(self, group_name): + """Get ZHA group by name.""" + for group in self.groups.values(): + if group.name == group_name: + return group + return None def get_entity_reference(self, entity_id): """Return entity reference for given entity_id if found.""" @@ -242,6 +314,11 @@ def devices(self): """Return devices.""" return self._devices + @property + def groups(self): + """Return groups.""" + return self._groups + @property def device_registry(self): """Return entities by ieee.""" @@ -288,14 +365,22 @@ def async_disable_debug_mode(self): logging.getLogger(logger_name).removeHandler(self._log_relay_handler) self.debug_enabled = False + def _initialize_groups(self): + """Initialize ZHA groups.""" + for group_id in self.application_controller.groups: + group = self.application_controller.groups[group_id] + self._async_get_or_create_group(group) + @callback - def _async_get_or_create_device(self, zigpy_device): + def _async_get_or_create_device( + self, zigpy_device: zha_typing.ZigpyDeviceType, restored: bool = False + ): """Get or create a ZHA device.""" zha_device = self._devices.get(zigpy_device.ieee) if zha_device is None: - zha_device = ZHADevice(self._hass, zigpy_device, self) + zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored) self._devices[zigpy_device.ieee] = zha_device - self.ha_device_registry.async_get_or_create( + device_registry_device = self.ha_device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, connections={(CONNECTION_ZIGBEE, str(zha_device.ieee))}, identifiers={(DOMAIN, str(zha_device.ieee))}, @@ -303,10 +388,20 @@ def _async_get_or_create_device(self, zigpy_device): manufacturer=zha_device.manufacturer, model=zha_device.model, ) + zha_device.set_device_id(device_registry_device.id) entry = self.zha_storage.async_get_or_create(zha_device) zha_device.async_update_last_seen(entry.last_seen) return zha_device + @callback + def _async_get_or_create_group(self, zigpy_group): + """Get or create a ZHA group.""" + zha_group = self._groups.get(zigpy_group.group_id) + if zha_group is None: + zha_group = ZHAGroup(self._hass, self, zigpy_group) + self._groups[zigpy_group.group_id] = zha_group + return zha_group + @callback def async_device_became_available( self, sender, profile, cluster, src_ep, dst_ep, message @@ -315,13 +410,13 @@ def async_device_became_available( self.async_update_device(sender) @callback - def async_update_device(self, sender): + def async_update_device(self, sender: zigpy_dev.Device, available: bool = True): """Update device that has just become available.""" if sender.ieee in self.devices: device = self.devices[sender.ieee] # avoid a race condition during new joins if device.status is DeviceStatus.INITIALIZED: - device.update_available(True) + device.update_available(available) async def async_update_device_storage(self): """Update the devices in the store.""" @@ -329,13 +424,14 @@ async def async_update_device_storage(self): self.zha_storage.async_update(device) await self.zha_storage.async_save() - async def async_device_initialized(self, device): + async def async_device_initialized(self, device: zha_typing.ZigpyDeviceType): """Handle device joined and basic information discovered (async).""" zha_device = self._async_get_or_create_device(device) _LOGGER.debug( - "device - %s entering async_device_initialized - is_new_join: %s", - f"0x{device.nwk:04x}:{device.ieee}", + "device - %s:%s entering async_device_initialized - is_new_join: %s", + device.nwk, + device.ieee, zha_device.status is not DeviceStatus.INITIALIZED, ) @@ -343,20 +439,21 @@ async def async_device_initialized(self, device): # ZHA already has an initialized device so either the device was assigned a # new nwk or device was physically reset and added again without being removed _LOGGER.debug( - "device - %s has been reset and readded or its nwk address changed", - f"0x{device.nwk:04x}:{device.ieee}", + "device - %s:%s has been reset and re-added or its nwk address changed", + device.nwk, + device.ieee, ) await self._async_device_rejoined(zha_device) else: _LOGGER.debug( - "device - %s has joined the ZHA zigbee network", - f"0x{device.nwk:04x}:{device.ieee}", + "device - %s:%s has joined the ZHA zigbee network", + device.nwk, + device.ieee, ) - await self._async_device_joined(device, zha_device) + await self._async_device_joined(zha_device) + + device_info = zha_device.async_get_info() - device_info = async_get_device_info( - self._hass, zha_device, self.ha_device_registry - ) async_dispatcher_send( self._hass, ZHA_GW_MSG, @@ -366,70 +463,74 @@ async def async_device_initialized(self, device): }, ) - async def _async_device_joined(self, device, zha_device): - 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, - True, - ) - + async def _async_device_joined(self, zha_device: zha_typing.ZhaDeviceType) -> None: await zha_device.async_configure() # will cause async_init to fire so don't explicitly call it zha_device.update_available(True) - - for discovery_info in discovery_infos: - async_dispatch_discovery_info(self._hass, True, discovery_info) + async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) # only public for testing - async def async_device_restored(self, device): + async def async_device_restored(self, device: zha_typing.ZigpyDeviceType): """Add an existing device to the ZHA zigbee network when ZHA first starts.""" - zha_device = self._async_get_or_create_device(device) - 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, - False, - ) + zha_device = self._async_get_or_create_device(device, restored=True) if zha_device.is_mains_powered: # the device isn't a battery powered device so we should be able # to update it now _LOGGER.debug( - "attempting to request fresh state for device - %s %s %s", - f"0x{zha_device.nwk:04x}:{zha_device.ieee}", + "attempting to request fresh state for device - %s:%s %s with power source %s", + zha_device.nwk, + zha_device.ieee, zha_device.name, - f"with power source: {zha_device.power_source}", + zha_device.power_source, ) await zha_device.async_initialize(from_cache=False) else: await zha_device.async_initialize(from_cache=True) - for discovery_info in discovery_infos: - async_dispatch_discovery_info(self._hass, False, discovery_info) - async def _async_device_rejoined(self, zha_device): _LOGGER.debug( - "skipping discovery for previously discovered device - %s", - f"0x{zha_device.nwk:04x}:{zha_device.ieee}", + "skipping discovery for previously discovered device - %s:%s", + zha_device.nwk, + zha_device.ieee, ) # we don't have to do this on a nwk swap but we don't have a way to tell currently await zha_device.async_configure() # will cause async_init to fire so don't explicitly call it zha_device.update_available(True) + async def async_create_zigpy_group(self, name, members): + """Create a new Zigpy Zigbee group.""" + # we start with one to fill any gaps from a user removing existing groups + group_id = 1 + while group_id in self.groups: + group_id += 1 + + # guard against group already existing + if self.async_get_group_by_name(name) is None: + self.application_controller.groups.add_group(group_id, name) + if members is not None: + tasks = [] + for ieee in members: + tasks.append(self.devices[ieee].async_add_to_group(group_id)) + await asyncio.gather(*tasks) + return self.groups.get(group_id) + + async def async_remove_zigpy_group(self, group_id): + """Remove a Zigbee group from Zigpy.""" + group = self.groups.get(group_id) + if group and group.members: + tasks = [] + for member in group.members: + tasks.append(member.async_remove_from_group(group_id)) + if tasks: + await asyncio.gather(*tasks) + else: + # we have members but none are tracked by ZHA for whatever reason + self.application_controller.groups.pop(group_id) + else: + self.application_controller.groups.pop(group_id) + async def shutdown(self): """Stop ZHA Controller Application.""" _LOGGER.debug("Shutting down ZHA ControllerApplication") @@ -443,12 +544,13 @@ def async_capture_log_levels(): DEBUG_COMP_BELLOWS: logging.getLogger(DEBUG_COMP_BELLOWS).getEffectiveLevel(), DEBUG_COMP_ZHA: logging.getLogger(DEBUG_COMP_ZHA).getEffectiveLevel(), DEBUG_COMP_ZIGPY: logging.getLogger(DEBUG_COMP_ZIGPY).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_XBEE: logging.getLogger( - DEBUG_COMP_ZIGPY_XBEE - ).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_CC: logging.getLogger(DEBUG_COMP_ZIGPY_CC).getEffectiveLevel(), DEBUG_COMP_ZIGPY_DECONZ: logging.getLogger( DEBUG_COMP_ZIGPY_DECONZ ).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_XBEE: logging.getLogger( + DEBUG_COMP_ZIGPY_XBEE + ).getEffectiveLevel(), DEBUG_COMP_ZIGPY_ZIGATE: logging.getLogger( DEBUG_COMP_ZIGPY_ZIGATE ).getEffectiveLevel(), @@ -461,8 +563,9 @@ def async_set_logger_levels(levels): logging.getLogger(DEBUG_COMP_BELLOWS).setLevel(levels[DEBUG_COMP_BELLOWS]) logging.getLogger(DEBUG_COMP_ZHA).setLevel(levels[DEBUG_COMP_ZHA]) logging.getLogger(DEBUG_COMP_ZIGPY).setLevel(levels[DEBUG_COMP_ZIGPY]) - logging.getLogger(DEBUG_COMP_ZIGPY_XBEE).setLevel(levels[DEBUG_COMP_ZIGPY_XBEE]) + logging.getLogger(DEBUG_COMP_ZIGPY_CC).setLevel(levels[DEBUG_COMP_ZIGPY_CC]) logging.getLogger(DEBUG_COMP_ZIGPY_DECONZ).setLevel(levels[DEBUG_COMP_ZIGPY_DECONZ]) + logging.getLogger(DEBUG_COMP_ZIGPY_XBEE).setLevel(levels[DEBUG_COMP_ZIGPY_XBEE]) logging.getLogger(DEBUG_COMP_ZIGPY_ZIGATE).setLevel(levels[DEBUG_COMP_ZIGPY_ZIGATE]) diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py new file mode 100644 index 00000000000000..ca2cc0ff1d369b --- /dev/null +++ b/homeassistant/components/zha/core/group.py @@ -0,0 +1,90 @@ +"""Group for Zigbee Home Automation.""" +import asyncio +import logging + +from homeassistant.core import callback + +from .helpers import LogMixin + +_LOGGER = logging.getLogger(__name__) + + +class ZHAGroup(LogMixin): + """ZHA Zigbee group object.""" + + def __init__(self, hass, zha_gateway, zigpy_group): + """Initialize the group.""" + self.hass = hass + self._zigpy_group = zigpy_group + self._zha_gateway = zha_gateway + + @property + def name(self): + """Return group name.""" + return self._zigpy_group.name + + @property + def group_id(self): + """Return group name.""" + return self._zigpy_group.group_id + + @property + def endpoint(self): + """Return the endpoint for this group.""" + return self._zigpy_group.endpoint + + @property + def members(self): + """Return the ZHA devices that are members of this group.""" + return [ + self._zha_gateway.devices.get(member_ieee[0]) + for member_ieee in self._zigpy_group.members.keys() + if member_ieee[0] in self._zha_gateway.devices + ] + + async def async_add_members(self, member_ieee_addresses): + """Add members to this group.""" + if len(member_ieee_addresses) > 1: + tasks = [] + for ieee in member_ieee_addresses: + tasks.append( + self._zha_gateway.devices[ieee].async_add_to_group(self.group_id) + ) + await asyncio.gather(*tasks) + else: + await self._zha_gateway.devices[ + member_ieee_addresses[0] + ].async_add_to_group(self.group_id) + + async def async_remove_members(self, member_ieee_addresses): + """Remove members from this group.""" + if len(member_ieee_addresses) > 1: + tasks = [] + for ieee in member_ieee_addresses: + tasks.append( + self._zha_gateway.devices[ieee].async_remove_from_group( + self.group_id + ) + ) + await asyncio.gather(*tasks) + else: + await self._zha_gateway.devices[ + member_ieee_addresses[0] + ].async_remove_from_group(self.group_id) + + @callback + def async_get_info(self): + """Get ZHA group info.""" + group_info = {} + group_info["group_id"] = self.group_id + group_info["name"] = self.name + group_info["members"] = [ + zha_device.async_get_info() for zha_device in self.members + ] + return group_info + + def log(self, level, msg, *args): + """Log a message.""" + msg = f"[%s](%s): {msg}" + args = (self.name, self.group_id) + args + _LOGGER.log(level, msg, *args) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 981a03fe7b56fb..c0008b055dbe8c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -1,9 +1,4 @@ -""" -Helpers for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Helpers for Zigbee Home Automation.""" import collections import logging @@ -11,14 +6,7 @@ from homeassistant.core import callback -from .const import ( - ATTR_NAME, - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, - DATA_ZHA, - DATA_ZHA_GATEWAY, - DOMAIN, -) +from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, DATA_ZHA, DATA_ZHA_GATEWAY from .registries import BINDABLE_CLUSTERS _LOGGER = logging.getLogger(__name__) @@ -131,28 +119,3 @@ def warning(self, msg, *args): def error(self, msg, *args): """Error level log.""" return self.log(logging.ERROR, msg, *args) - - -@callback -def async_get_device_info(hass, device, ha_device_registry=None): - """Get ZHA device.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ret_device = {} - ret_device.update(device.device_info) - ret_device["entities"] = [ - { - "entity_id": entity_ref.reference_id, - ATTR_NAME: entity_ref.device_info[ATTR_NAME], - } - for entity_ref in zha_gateway.device_registry[device.ieee] - ] - - if ha_device_registry is not None: - reg_device = ha_device_registry.async_get_device( - {(DOMAIN, str(device.ieee))}, set() - ) - if reg_device is not None: - ret_device["user_given_name"] = reg_device.name_by_user - ret_device["device_reg_id"] = reg_device.id - ret_device["area_id"] = reg_device.area_id - return ret_device diff --git a/homeassistant/components/zha/core/patches.py b/homeassistant/components/zha/core/patches.py index a4e84e83105805..3d8c84e9bf3d67 100644 --- a/homeassistant/components/zha/core/patches.py +++ b/homeassistant/components/zha/core/patches.py @@ -1,9 +1,4 @@ -""" -Patch functions for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Patch functions for Zigbee Home Automation.""" def apply_application_controller_patch(zha_gateway): diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index e89c0b8189b0d0..3b08d1acd37981 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -1,11 +1,6 @@ -""" -Mapping registries for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Mapping registries for Zigbee Home Automation.""" import collections -from typing import Callable, Set, Union +from typing import Callable, Dict, List, Set, Tuple, Union import attr import bellows.ezsp @@ -13,6 +8,8 @@ import zigpy.profiles.zha import zigpy.profiles.zll import zigpy.zcl as zcl +import zigpy_cc.api +import zigpy_cc.zigbee.application import zigpy_deconz.api import zigpy_deconz.zigbee.application import zigpy_xbee.api @@ -30,9 +27,10 @@ from homeassistant.components.switch import DOMAIN as SWITCH # importing channels updates registries -from . import channels # noqa: F401 pylint: disable=unused-import +from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .const import CONTROLLER, ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, RadioType from .decorators import CALLABLE_T, DictRegistry, SetRegistry +from .typing import ChannelType SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 @@ -44,8 +42,11 @@ zigpy.profiles.zha.DeviceType.COLOR_DIMMER_SWITCH, zigpy.profiles.zha.DeviceType.COLOR_SCENE_CONTROLLER, zigpy.profiles.zha.DeviceType.DIMMER_SWITCH, + zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, zigpy.profiles.zha.DeviceType.NON_COLOR_CONTROLLER, zigpy.profiles.zha.DeviceType.NON_COLOR_SCENE_CONTROLLER, + zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT_SWITCH, zigpy.profiles.zha.DeviceType.REMOTE_CONTROL, zigpy.profiles.zha.DeviceType.SCENE_SELECTOR, ], @@ -57,30 +58,33 @@ zigpy.profiles.zll.DeviceType.SCENE_CONTROLLER, ], } +REMOTE_DEVICE_TYPES = collections.defaultdict(list, REMOTE_DEVICE_TYPES) SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { # this works for now but if we hit conflicts we can break it out to # a different dict that is keyed by manufacturer SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR, SMARTTHINGS_HUMIDITY_CLUSTER: SENSOR, - zcl.clusters.closures.DoorLock: LOCK, - zcl.clusters.closures.WindowCovering: COVER, + zcl.clusters.closures.DoorLock.cluster_id: LOCK, + zcl.clusters.closures.WindowCovering.cluster_id: COVER, zcl.clusters.general.AnalogInput.cluster_id: SENSOR, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, - zcl.clusters.general.OnOff: SWITCH, - zcl.clusters.general.PowerConfiguration: SENSOR, - zcl.clusters.homeautomation.ElectricalMeasurement: SENSOR, - zcl.clusters.hvac.Fan: FAN, - zcl.clusters.measurement.IlluminanceMeasurement: SENSOR, - zcl.clusters.measurement.OccupancySensing: BINARY_SENSOR, - zcl.clusters.measurement.PressureMeasurement: SENSOR, - zcl.clusters.measurement.RelativeHumidity: SENSOR, - zcl.clusters.measurement.TemperatureMeasurement: SENSOR, - zcl.clusters.security.IasZone: BINARY_SENSOR, - zcl.clusters.smartenergy.Metering: SENSOR, + zcl.clusters.general.OnOff.cluster_id: SWITCH, + zcl.clusters.general.PowerConfiguration.cluster_id: SENSOR, + zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: SENSOR, + zcl.clusters.hvac.Fan.cluster_id: FAN, + zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: SENSOR, + zcl.clusters.measurement.OccupancySensing.cluster_id: BINARY_SENSOR, + zcl.clusters.measurement.PressureMeasurement.cluster_id: SENSOR, + zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR, + zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, + zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, + zcl.clusters.smartenergy.Metering.cluster_id: SENSOR, } -SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {zcl.clusters.general.OnOff: BINARY_SENSOR} +SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { + zcl.clusters.general.OnOff.cluster_id: BINARY_SENSOR +} SWITCH_CLUSTERS = SetRegistry() @@ -89,7 +93,6 @@ BINDABLE_CLUSTERS = SetRegistry() CHANNEL_ONLY_CLUSTERS = SetRegistry() -CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} DEVICE_CLASS = { @@ -104,7 +107,6 @@ zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT, zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST: SWITCH, zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT: LIGHT, - zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT_SWITCH: SWITCH, zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH, zigpy.profiles.zha.DeviceType.SMART_PLUG: SWITCH, }, @@ -118,6 +120,7 @@ zigpy.profiles.zll.DeviceType.ON_OFF_PLUGIN_UNIT: SWITCH, }, } +DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) DEVICE_TRACKER_CLUSTERS = SetRegistry() EVENT_RELAY_CLUSTERS = SetRegistry() @@ -125,15 +128,20 @@ OUTPUT_CHANNEL_ONLY_CLUSTERS = SetRegistry() RADIO_TYPES = { + RadioType.deconz.name: { + ZHA_GW_RADIO: zigpy_deconz.api.Deconz, + CONTROLLER: zigpy_deconz.zigbee.application.ControllerApplication, + ZHA_GW_RADIO_DESCRIPTION: "Deconz", + }, RadioType.ezsp.name: { ZHA_GW_RADIO: bellows.ezsp.EZSP, CONTROLLER: bellows.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "EZSP", }, - RadioType.deconz.name: { - ZHA_GW_RADIO: zigpy_deconz.api.Deconz, - CONTROLLER: zigpy_deconz.zigbee.application.ControllerApplication, - ZHA_GW_RADIO_DESCRIPTION: "Deconz", + RadioType.ti_cc.name: { + ZHA_GW_RADIO: zigpy_cc.api.API, + CONTROLLER: zigpy_cc.zigbee.application.ControllerApplication, + ZHA_GW_RADIO_DESCRIPTION: "TI CC", }, RadioType.xbee.name: { ZHA_GW_RADIO: zigpy_xbee.api.XBee, @@ -184,6 +192,63 @@ class MatchRule: models: Union[Callable, Set[str], str] = attr.ib( factory=frozenset, converter=set_or_callable ) + aux_channels: Union[Callable, Set[str], str] = attr.ib( + factory=frozenset, converter=set_or_callable + ) + + def claim_channels(self, channel_pool: List[ChannelType]) -> List[ChannelType]: + """Return a list of channels this rule matches + aux channels.""" + claimed = [] + if isinstance(self.channel_names, frozenset): + claimed.extend([ch for ch in channel_pool if ch.name in self.channel_names]) + if isinstance(self.generic_ids, frozenset): + claimed.extend( + [ch for ch in channel_pool if ch.generic_id in self.generic_ids] + ) + if isinstance(self.aux_channels, frozenset): + claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels]) + return claimed + + def strict_matched(self, manufacturer: str, model: str, channels: List) -> bool: + """Return True if this device matches the criteria.""" + return all(self._matched(manufacturer, model, channels)) + + def loose_matched(self, manufacturer: str, model: str, channels: List) -> bool: + """Return True if this device matches the criteria.""" + return any(self._matched(manufacturer, model, channels)) + + def _matched(self, manufacturer: str, model: str, channels: List) -> list: + """Return a list of field matches.""" + if not any(attr.asdict(self).values()): + return [False] + + matches = [] + if self.channel_names: + channel_names = {ch.name for ch in channels} + matches.append(self.channel_names.issubset(channel_names)) + + if self.generic_ids: + all_generic_ids = {ch.generic_id for ch in channels} + matches.append(self.generic_ids.issubset(all_generic_ids)) + + if self.manufacturers: + if callable(self.manufacturers): + matches.append(self.manufacturers(manufacturer)) + else: + matches.append(manufacturer in self.manufacturers) + + if self.models: + if callable(self.models): + matches.append(self.models(model)) + else: + matches.append(model in self.models) + + return matches + + +RegistryDictType = Dict[ + str, Dict[MatchRule, CALLABLE_T] +] # pylint: disable=invalid-name class ZHAEntityRegistry: @@ -191,18 +256,24 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" - self._strict_registry = collections.defaultdict(dict) - self._loose_registry = collections.defaultdict(dict) + self._strict_registry: RegistryDictType = collections.defaultdict(dict) + self._loose_registry: RegistryDictType = collections.defaultdict(dict) def get_entity( - self, component: str, zha_device, chnls: dict, default: CALLABLE_T = None - ) -> CALLABLE_T: + self, + component: str, + manufacturer: str, + model: str, + channels: List[ChannelType], + default: CALLABLE_T = None, + ) -> Tuple[CALLABLE_T, List[ChannelType]]: """Match a ZHA Channels to a ZHA Entity class.""" for match in self._strict_registry[component]: - if self._strict_matched(zha_device, chnls, match): - return self._strict_registry[component][match] + if match.strict_matched(manufacturer, model, channels): + claimed = match.claim_channels(channels) + return self._strict_registry[component][match], claimed - return default + return default, [] def strict_match( self, @@ -211,10 +282,13 @@ def strict_match( generic_ids: Union[Callable, Set[str], str] = None, manufacturers: Union[Callable, Set[str], str] = None, models: Union[Callable, Set[str], str] = None, + aux_channels: Union[Callable, Set[str], str] = None, ) -> Callable[[CALLABLE_T], CALLABLE_T]: """Decorate a strict match rule.""" - rule = MatchRule(channel_names, generic_ids, manufacturers, models) + rule = MatchRule( + channel_names, generic_ids, manufacturers, models, aux_channels + ) def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: """Register a strict match rule. @@ -233,10 +307,13 @@ def loose_match( generic_ids: Union[Callable, Set[str], str] = None, manufacturers: Union[Callable, Set[str], str] = None, models: Union[Callable, Set[str], str] = None, + aux_channels: Union[Callable, Set[str], str] = None, ) -> Callable[[CALLABLE_T], CALLABLE_T]: """Decorate a loose match rule.""" - rule = MatchRule(channel_names, generic_ids, manufacturers, models) + rule = MatchRule( + channel_names, generic_ids, manufacturers, models, aux_channels + ) def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: """Register a loose match rule. @@ -248,42 +325,5 @@ def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: return decorator - def _strict_matched(self, zha_device, chnls: dict, rule: MatchRule) -> bool: - """Return True if this device matches the criteria.""" - return all(self._matched(zha_device, chnls, rule)) - - def _loose_matched(self, zha_device, chnls: dict, rule: MatchRule) -> bool: - """Return True if this device matches the criteria.""" - return any(self._matched(zha_device, chnls, rule)) - - @staticmethod - def _matched(zha_device, chnls: dict, rule: MatchRule) -> list: - """Return a list of field matches.""" - if not any(attr.asdict(rule).values()): - return [False] - - matches = [] - if rule.channel_names: - channel_names = {ch.name for ch in chnls} - matches.append(rule.channel_names.issubset(channel_names)) - - if rule.generic_ids: - all_generic_ids = {ch.generic_id for ch in chnls} - matches.append(rule.generic_ids.issubset(all_generic_ids)) - - if rule.manufacturers: - if callable(rule.manufacturers): - matches.append(rule.manufacturers(zha_device.manufacturer)) - else: - matches.append(zha_device.manufacturer in rule.manufacturers) - - if rule.models: - if callable(rule.models): - matches.append(rule.models(zha_device.model)) - else: - matches.append(zha_device.model in rule.models) - - return matches - ZHA_ENTITIES = ZHAEntityRegistry() diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py new file mode 100644 index 00000000000000..fb397ea15ae54c --- /dev/null +++ b/homeassistant/components/zha/core/typing.py @@ -0,0 +1,40 @@ +"""Typing helpers for ZHA component.""" + +from typing import TYPE_CHECKING, Callable, TypeVar + +import zigpy.device +import zigpy.endpoint +import zigpy.zcl +import zigpy.zdo + +# pylint: disable=invalid-name +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) +ChannelType = "ZigbeeChannel" +ChannelsType = "Channels" +ChannelPoolType = "ChannelPool" +EventRelayChannelType = "EventRelayChannel" +ZDOChannelType = "ZDOChannel" +ZhaDeviceType = "ZHADevice" +ZhaEntityType = "ZHAEntity" +ZhaGatewayType = "ZHAGateway" +ZigpyClusterType = zigpy.zcl.Cluster +ZigpyDeviceType = zigpy.device.Device +ZigpyEndpointType = zigpy.endpoint.Endpoint +ZigpyZdoType = zigpy.zdo.ZDO + +if TYPE_CHECKING: + import homeassistant.components.zha.core.channels as channels + import homeassistant.components.zha.core.channels.base as base_channels + import homeassistant.components.zha.core.device + import homeassistant.components.zha.core.gateway + import homeassistant.components.zha.entity + import homeassistant.components.zha.core.channels + + ChannelType = base_channels.ZigbeeChannel + ChannelsType = channels.Channels + ChannelPoolType = channels.ChannelPool + EventRelayChannelType = base_channels.EventRelayChannel + ZDOChannelType = base_channels.ZDOChannel + ZhaDeviceType = homeassistant.components.zha.core.device.ZHADevice + ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity + ZhaGatewayType = homeassistant.components.zha.core.gateway.ZHAGateway diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 5b83b8cefcbbfe..13de445cf37ff6 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -10,12 +10,13 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_COVER, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -28,41 +29,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation cover from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - covers = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if covers is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, covers.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA covers.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaCover) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_COVER) class ZhaCover(ZhaEntity, CoverDevice): @@ -95,6 +72,16 @@ def is_closed(self): return None return self.current_cover_position == 0 + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + @property def current_cover_position(self): """Return the current position of ZHA cover. @@ -103,6 +90,7 @@ def current_cover_position(self): """ return self._current_position + @callback def async_set_position(self, pos): """Handle position update from channel.""" _LOGGER.debug("setting position: %s", pos) @@ -113,6 +101,7 @@ def async_set_position(self, pos): self._state = STATE_OPEN self.async_schedule_update_ha_state() + @callback def async_set_state(self, state): """Handle state update from channel.""" _LOGGER.debug("state=%s", state) @@ -133,7 +122,7 @@ async def async_close_cover(self, **kwargs): async def async_set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" - new_pos = kwargs.get(ATTR_POSITION) + new_pos = kwargs[ATTR_POSITION] res = await self._cover_channel.go_to_lift_percentage(100 - new_pos) if isinstance(res, list) and res[1] is Status.SUCCESS: self.async_set_state( diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 60cfa0eec0004a..5a2e0c408817b9 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -57,11 +57,16 @@ async def async_call_action_from_config( async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: """List device actions.""" zha_device = await async_get_zha_device(hass, device_id) + cluster_channels = [ + ch.name + for pool in zha_device.channels.pools + for ch in pool.claimed_channels.values() + ] actions = [ action for channel in DEVICE_ACTIONS for action in DEVICE_ACTIONS[channel] - if channel in zha_device.cluster_channels + if channel in cluster_channels ] for action in actions: action[CONF_DEVICE_ID] = device_id diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 7654893581437b..5481ec70f52afd 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -8,12 +8,13 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_POWER_CONFIGURATION, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -25,51 +26,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation device tracker from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - device_trackers = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if device_trackers is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, device_trackers.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA device trackers.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity( - DOMAIN, zha_dev, channels, ZHADeviceScannerEntity - ) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """Represent a tracked device.""" - def __init__(self, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA device tracker.""" - super().__init__(**kwargs) + super().__init__(unique_id, zha_device, channels, **kwargs) self._battery_channel = self.cluster_channels.get(CHANNEL_POWER_CONFIGURATION) self._connected = False self._keepalive_interval = 60 diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 0b001bdedbc7bf..76d0908000b90b 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -44,7 +44,6 @@ def __init__(self, unique_id, zha_device, channels, skip_entity_id=False, **kwar self._zha_device = zha_device self.cluster_channels = {} self._available = False - self._component = kwargs["component"] self._unsubs = [] self.remove_future = None for channel in channels: @@ -99,16 +98,19 @@ def available(self): """Return entity availability.""" return self._available + @callback def async_set_available(self, available): """Set entity availability.""" self._available = available self.async_schedule_update_ha_state() + @callback def async_update_state_attribute(self, key, value): """Update a single device state attribute.""" self._device_state_attributes.update({key: value}) self.async_schedule_update_ha_state() + @callback def async_set_state(self, state): """Set the entity state.""" pass diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 50e9f63a067dee..59a6bfb9c4723e 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -14,12 +14,13 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_FAN, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -52,41 +53,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation fan from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if fans is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, fans.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA fans.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaFan) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_FAN) class ZhaFan(ZhaEntity, FanEntity): @@ -136,6 +113,7 @@ def device_state_attributes(self): """Return state attributes.""" return self.state_attributes + @callback def async_set_state(self, state): """Handle state update from channel.""" self._state = VALUE_TO_SPEED.get(state, self._state) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 11fa87d4618764..dc2e156dbf577e 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -12,15 +12,16 @@ from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util +from .core import discovery from .core.const import ( CHANNEL_COLOR, CHANNEL_LEVEL, CHANNEL_ON_OFF, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -44,43 +45,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation light from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][light.DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN) - if lights is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, lights.values() - ) - del hass.data[DATA_ZHA][light.DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA lights.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - entity = ZHA_ENTITIES.get_entity(light.DOMAIN, zha_dev, channels, Light) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - - -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF) +@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}) class Light(ZhaEntity, light.Light): """Representation of a ZHA or ZLL light.""" @@ -170,6 +147,7 @@ def supported_features(self): """Flag supported features.""" return self._supported_features + @callback def async_set_state(self, state): """Set the state.""" self._state = bool(state) diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 584df99fe0829c..7ba31158fc34a0 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -13,12 +13,13 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_DOORLOCK, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -35,41 +36,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation Door Lock from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - locks = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if locks is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, locks.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA locks.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaDoorLock) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_DOORLOCK) class ZhaDoorLock(ZhaEntity, LockDevice): @@ -125,6 +102,7 @@ async def async_update(self): await super().async_update() await self.async_get_state() + @callback def async_set_state(self, state): """Handle state update from channel.""" self._state = VALUE_TO_STATE.get(state, self._state) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index b436f677f6b73a..16c5604587d137 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,11 +4,12 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows-homeassistant==0.12.0", - "zha-quirks==0.0.31", + "bellows-homeassistant==0.13.2", + "zha-quirks==0.0.33", + "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", - "zigpy-homeassistant==0.12.0", - "zigpy-xbee-homeassistant==0.8.0", + "zigpy-homeassistant==0.13.2", + "zigpy-xbee-homeassistant==0.9.0", "zigpy-zigate==0.5.1" ], "dependencies": [], diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 52d4660a467897..b98c50d1fa406c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -22,7 +22,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.temperature import fahrenheit_to_celsius +from .core import discovery from .core.const import ( + CHANNEL_ANALOG_INPUT, CHANNEL_ELECTRICAL_MEASUREMENT, CHANNEL_HUMIDITY, CHANNEL_ILLUMINANCE, @@ -33,9 +35,9 @@ CHANNEL_TEMPERATURE, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR, - ZHA_DISCOVERY_NEW, ) from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity @@ -65,46 +67,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation sensor from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if sensors is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, sensors.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA sensors.""" - entities = [] - for discovery_info in discovery_infos: - entities.append(await make_sensor(discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - - -async def make_sensor(discovery_info): - """Create ZHA sensors factory.""" - - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, Sensor) - return entity(**discovery_info) - class Sensor(ZhaEntity): """Base ZHA sensor.""" @@ -149,6 +122,7 @@ def state(self) -> str: return None return self._state + @callback def async_set_state(self, state): """Handle state update from channel.""" if state is not None: @@ -175,6 +149,13 @@ def formatter(self, value): return round(float(value * self._multiplier) / self._divisor) +@STRICT_MATCH(channel_names=CHANNEL_ANALOG_INPUT) +class AnalogInput(Sensor): + """Sensor that displays analog input values.""" + + pass + + @STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -202,6 +183,7 @@ async def async_state_attr_provider(self): state_attrs["battery_quantity"] = battery_quantity return state_attrs + @callback def async_update_state_attribute(self, key, value): """Update a single device state attribute.""" if key == "battery_voltage": diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index a68fca76af4ad2..e6a82fe027067c 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -9,12 +9,13 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -25,49 +26,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation switch from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if switches is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, switches.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA switches.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, Switch) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_ON_OFF) class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" - def __init__(self, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA switch.""" - super().__init__(**kwargs) + super().__init__(unique_id, zha_device, channels, **kwargs) self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) @property @@ -93,6 +70,7 @@ async def async_turn_off(self, **kwargs): self._state = False self.async_schedule_update_ha_state() + @callback def async_set_state(self, state): """Handle state update from channel.""" self._state = bool(state) diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index 203131afdb1d28..62f5b9acbaff32 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -88,7 +88,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hub_is_initialized = False async def startup(): - """Start hub socket after all climate entity is setted up.""" + """Start hub socket after all climate entity is set up.""" nonlocal hub_is_initialized if not all([device.is_initialized for device in devices]): return diff --git a/homeassistant/components/zone/.translations/pl.json b/homeassistant/components/zone/.translations/pl.json index e649de4c75ed97..5c013d5da8fc28 100644 --- a/homeassistant/components/zone/.translations/pl.json +++ b/homeassistant/components/zone/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Nazwa ju\u017c istnieje" + "name_exists": "Nazwa ju\u017c istnieje." }, "step": { "init": { diff --git a/homeassistant/components/zone/.translations/zh-Hans.json b/homeassistant/components/zone/.translations/zh-Hans.json index 6d06b68dad8d4f..6972b2946e40d5 100644 --- a/homeassistant/components/zone/.translations/zh-Hans.json +++ b/homeassistant/components/zone/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + "name_exists": "\u8be5\u540d\u79f0\u5df2\u5b58\u5728" }, "step": { "init": { @@ -13,7 +13,7 @@ "passive": "\u88ab\u52a8", "radius": "\u534a\u5f84" }, - "title": "\u5b9a\u4e49\u533a\u57df\u76f8\u5173\u53d8\u91cf" + "title": "\u5b9a\u4e49\u533a\u57df\u53c2\u6570" } }, "title": "\u533a\u57df" diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 91a1338b6712ae..d14e31273b7afc 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,6 +1,6 @@ """Support for the definition of zones.""" import logging -from typing import Dict, List, Optional, cast +from typing import Dict, Optional, cast import voluptuous as vol @@ -159,32 +159,12 @@ async def _update_data(self, data: dict, update_data: Dict) -> Dict: return {**data, **update_data} -class IDLessCollection(collection.ObservableCollection): - """A collection without IDs.""" - - counter = 0 - - async def async_load(self, data: List[dict]) -> None: - """Load the collection. Overrides existing data.""" - for item_id in list(self.data): - await self.notify_change(collection.CHANGE_REMOVED, item_id, None) - - self.data.clear() - - for item in data: - self.counter += 1 - item_id = f"fakeid-{self.counter}" - - self.data[item_id] = item - await self.notify_change(collection.CHANGE_ADDED, item_id, item) - - async def async_setup(hass: HomeAssistant, config: Dict) -> bool: """Set up configured zones as well as Home Assistant zone if necessary.""" component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() - yaml_collection = IDLessCollection( + yaml_collection = collection.IDLessCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) collection.attach_entity_component_collection( @@ -228,7 +208,7 @@ async def reload_service_handler(service_call: ServiceCall) -> None: conf = await component.async_prepare_reload(skip_reset=True) if conf is None: return - await yaml_collection.async_load(conf[DOMAIN]) + await yaml_collection.async_load(conf.get(DOMAIN, [])) service.async_register_admin_service( hass, diff --git a/homeassistant/components/zone/services.yaml b/homeassistant/components/zone/services.yaml new file mode 100644 index 00000000000000..550eee24fab765 --- /dev/null +++ b/homeassistant/components/zone/services.yaml @@ -0,0 +1,2 @@ +reload: + description: Reload the YAML-based zone configuration. diff --git a/homeassistant/components/zwave/.translations/pl.json b/homeassistant/components/zwave/.translations/pl.json index 254008ddb4c642..a985405c009d3d 100644 --- a/homeassistant/components/zwave/.translations/pl.json +++ b/homeassistant/components/zwave/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Z-Wave jest ju\u017c skonfigurowany", + "already_configured": "Z-Wave jest ju\u017c skonfigurowany.", "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 Z-Wave" }, "error": { diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 9b9236de1c2c17..ba7e26ee58c022 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -678,7 +678,7 @@ def set_config_parameter(service): if value.type == const.TYPE_BOOL: value.data = int(selection == "True") _LOGGER.info( - "Setting config parameter %s on Node %s with bool selection %s", + "Setting configuration parameter %s on Node %s with bool selection %s", param, node_id, str(selection), @@ -687,7 +687,7 @@ def set_config_parameter(service): if value.type == const.TYPE_LIST: value.data = str(selection) _LOGGER.info( - "Setting config parameter %s on Node %s with list selection %s", + "Setting configuration parameter %s on Node %s with list selection %s", param, node_id, str(selection), @@ -697,7 +697,7 @@ def set_config_parameter(service): network.manager.pressButton(value.value_id) network.manager.releaseButton(value.value_id) _LOGGER.info( - "Setting config parameter %s on Node %s " + "Setting configuration parameter %s on Node %s " "with button selection %s", param, node_id, @@ -706,7 +706,7 @@ def set_config_parameter(service): return value.data = int(selection) _LOGGER.info( - "Setting config parameter %s on Node %s with selection %s", + "Setting configuration parameter %s on Node %s with selection %s", param, node_id, selection, @@ -714,7 +714,7 @@ def set_config_parameter(service): return node.set_config_param(param, selection, size) _LOGGER.info( - "Setting unknown config parameter %s on Node %s with selection %s", + "Setting unknown configuration parameter %s on Node %s with selection %s", param, node_id, selection, diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index 840418fb0639e7..4ee9b8b9cc9e1e 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -529,7 +529,7 @@ def set_hvac_mode(self, hvac_mode): self._mode().data = operation_mode def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" if not self._aux_heat: return operation_mode = AUX_HEAT_ZWAVE_MODE @@ -537,7 +537,7 @@ def turn_aux_heat_on(self): self._mode().data = operation_mode def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" if not self._aux_heat: return if HVAC_MODE_HEAT in self._hvac_mapping: diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py index 9c582eba89a7b2..b32daf71f54e09 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -380,7 +380,9 @@ def turn_on(self, **kwargs): # white LED must be off in order for color to work self._white = 0 - if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs: + if ( + ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs + ) and self._hs is not None: rgbw = "#" for colorval in color_util.color_hs_to_RGB(*self._hs): rgbw += format(colorval, "02x") diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py index 44e73da320f430..382d2c4dbf2895 100644 --- a/homeassistant/components/zwave/lock.py +++ b/homeassistant/components/zwave/lock.py @@ -180,7 +180,7 @@ def set_usercode(service): if len(str(usercode)) < 4: _LOGGER.error( "Invalid code provided: (%s) " - "usercode must be atleast 4 and at most" + "usercode must be at least 4 and at most" " %s digits", usercode, len(value.data), diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index c781a493b55088..1fc6401f25bf24 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave", - "requirements": ["homeassistant-pyozw==0.1.7", "pydispatcher==2.0.5"], + "requirements": ["homeassistant-pyozw==0.1.8", "pydispatcher==2.0.5"], "dependencies": [], "codeowners": ["@home-assistant/z-wave"] } diff --git a/homeassistant/config.py b/homeassistant/config.py index f5870d683a0e4c..abb8511cab0007 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -89,7 +89,7 @@ """ DEFAULT_SECRETS = """ # Use this file to store secrets like usernames and passwords. -# Learn more at https://home-assistant.io/docs/configuration/secrets/ +# Learn more at https://www.home-assistant.io/docs/configuration/secrets/ some_password: welcome """ TTS_PRE_92 = """ @@ -697,6 +697,9 @@ async def async_process_component_config( except (vol.Invalid, HomeAssistantError) as ex: async_log_exception(ex, domain, config, hass, integration.documentation) return None + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error calling %s config validator", domain) + return None # No custom config validator, proceed with schema validation if hasattr(component, "CONFIG_SCHEMA"): @@ -705,6 +708,9 @@ async def async_process_component_config( except vol.Invalid as ex: async_log_exception(ex, domain, config, hass, integration.documentation) return None + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error calling %s CONFIG_SCHEMA", domain) + return None component_platform_schema = getattr( component, "PLATFORM_SCHEMA_BASE", getattr(component, "PLATFORM_SCHEMA", None) @@ -721,6 +727,13 @@ async def async_process_component_config( except vol.Invalid as ex: async_log_exception(ex, domain, p_config, hass, integration.documentation) continue + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error validating %s platform config with %s component platform schema", + p_name, + domain, + ) + continue # Not all platform components follow same pattern for platforms # So if p_name is None we are not going to validate platform @@ -756,6 +769,13 @@ async def async_process_component_config( p_integration.documentation, ) continue + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error validating config for %s platform for %s component with PLATFORM_SCHEMA", + p_name, + domain, + ) + continue platforms.append(p_validated) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 793e8be004569a..1cec1e75fe9d09 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -183,7 +183,7 @@ async def async_setup( component = integration.get_component() except ImportError as err: _LOGGER.error( - "Error importing integration %s to set up %s config entry: %s", + "Error importing integration %s to set up %s configuration entry: %s", integration.domain, self.domain, err, @@ -197,7 +197,7 @@ async def async_setup( integration.get_platform("config_flow") except ImportError as err: _LOGGER.error( - "Error importing platform config_flow from integration %s to set up %s config entry: %s", + "Error importing platform config_flow from integration %s to set up %s configuration entry: %s", integration.domain, self.domain, err, @@ -503,7 +503,7 @@ async def async_create_flow( integration.get_platform("config_flow") except ImportError as err: _LOGGER.error( - "Error occurred loading config flow for integration %s: %s", + "Error occurred loading configuration flow for integration %s: %s", handler_key, err, ) @@ -775,6 +775,7 @@ async def async_forward_entry_unload(self, entry: ConfigEntry, domain: str) -> b return await entry.async_unload(self.hass, integration=integration) + @callback def _async_schedule_save(self) -> None: """Save the entity registry to a file.""" self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @@ -947,7 +948,7 @@ def update(self, *, disable_new_entities: bool) -> None: self.disable_new_entities = disable_new_entities def as_dict(self) -> Dict[str, Any]: - """Return dictionary version of this config entrys system options.""" + """Return dictionary version of this config entries system options.""" return {"disable_new_entities": self.disable_new_entities} @@ -1024,7 +1025,7 @@ async def _handle_reload(self, _now: Any) -> None: self.changed = set() _LOGGER.info( - "Reloading config entries because disabled_by changed in entity registry: %s", + "Reloading configuration entries because disabled_by changed in entity registry: %s", ", ".join(self.changed), ) diff --git a/homeassistant/const.py b/homeassistant/const.py index facb365f75c12c..155eff79bb3ab4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,6 +1,6 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 105 +MINOR_VERSION = 107 PATCH_VERSION = "0.dev0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" @@ -16,6 +16,7 @@ MATCH_ALL = "*" # Entity target all constant +ENTITY_MATCH_NONE = "none" ENTITY_MATCH_ALL = "all" # If no name is specified @@ -342,6 +343,17 @@ TEMP_CELSIUS = "°C" TEMP_FAHRENHEIT = "°F" +# Time units +TIME_MICROSECONDS = "μs" +TIME_MILLISECONDS = "ms" +TIME_SECONDS = "s" +TIME_MINUTES = "min" +TIME_HOURS = "h" +TIME_DAYS = "d" +TIME_WEEKS = "w" +TIME_MONTHS = "m" +TIME_YEARS = "y" + # Length units LENGTH_CENTIMETERS: str = "cm" LENGTH_METERS: str = "m" @@ -363,13 +375,19 @@ # Volume units VOLUME_LITERS: str = "L" VOLUME_MILLILITERS: str = "mL" +VOLUME_CUBIC_METERS = f"{LENGTH_METERS}³" VOLUME_GALLONS: str = "gal" VOLUME_FLUID_OUNCE: str = "fl. oz." +# Area units +AREA_SQUARE_METERS = f"{LENGTH_METERS}²" + # Mass units MASS_GRAMS: str = "g" MASS_KILOGRAMS: str = "kg" +MASS_MILLIGRAMS = "mg" +MASS_MICROGRAMS = "µg" MASS_OUNCES: str = "oz" MASS_POUNDS: str = "lb" @@ -377,6 +395,54 @@ # UV Index units UNIT_UV_INDEX: str = "UV index" +# Irradiation units +IRRADIATION_WATTS_PER_SQUARE_METER = f"{POWER_WATT}/{AREA_SQUARE_METERS}" + +# Concentration units +CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = f"{MASS_MICROGRAMS}/{VOLUME_CUBIC_METERS}" +CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER = f"{MASS_MILLIGRAMS}/{VOLUME_CUBIC_METERS}" +CONCENTRATION_PARTS_PER_MILLION = "ppm" +CONCENTRATION_PARTS_PER_BILLION = "ppb" + +# Speed units +SPEED_METERS_PER_SECOND = f"{LENGTH_METERS}/{TIME_SECONDS}" +SPEED_KILOMETERS_PER_HOUR = f"{LENGTH_KILOMETERS}/{TIME_HOURS}" +SPEED_MILES_PER_HOUR = "mph" + +# Data units +DATA_BITS = "bit" +DATA_KILOBITS = "kbit" +DATA_MEGABITS = "Mbit" +DATA_GIGABITS = "Gbit" +DATA_BYTES = "B" +DATA_KILOBYTES = "kB" +DATA_MEGABYTES = "MB" +DATA_GIGABYTES = "GB" +DATA_TERABYTES = "TB" +DATA_PETABYTES = "PB" +DATA_EXABYTES = "EB" +DATA_ZETTABYTES = "ZB" +DATA_YOTTABYTES = "YB" +DATA_KIBIBYTES = "KiB" +DATA_MEBIBYTES = "MiB" +DATA_GIBIBYTES = "GiB" +DATA_TEBIBYTES = "TiB" +DATA_PEBIBYTES = "PiB" +DATA_EXBIBYTES = "EiB" +DATA_ZEBIBYTES = "ZiB" +DATA_YOBIBYTES = "YiB" +DATA_RATE_BITS_PER_SECOND = f"{DATA_BITS}/{TIME_SECONDS}" +DATA_RATE_KILOBITS_PER_SECOND = f"{DATA_KILOBITS}/{TIME_SECONDS}" +DATA_RATE_MEGABITS_PER_SECOND = f"{DATA_MEGABITS}/{TIME_SECONDS}" +DATA_RATE_GIGABITS_PER_SECOND = f"{DATA_GIGABITS}/{TIME_SECONDS}" +DATA_RATE_BYTES_PER_SECOND = f"{DATA_BYTES}/{TIME_SECONDS}" +DATA_RATE_KILOBYTES_PER_SECOND = f"{DATA_KILOBYTES}/{TIME_SECONDS}" +DATA_RATE_MEGABYTES_PER_SECOND = f"{DATA_MEGABYTES}/{TIME_SECONDS}" +DATA_RATE_GIGABYTES_PER_SECOND = f"{DATA_GIGABYTES}/{TIME_SECONDS}" +DATA_RATE_KIBIBYTES_PER_SECOND = f"{DATA_KIBIBYTES}/{TIME_SECONDS}" +DATA_RATE_MEBIBYTES_PER_SECOND = f"{DATA_MEBIBYTES}/{TIME_SECONDS}" +DATA_RATE_GIBIBYTES_PER_SECOND = f"{DATA_GIBIBYTES}/{TIME_SECONDS}" + # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = "stop" SERVICE_HOMEASSISTANT_RESTART = "restart" diff --git a/homeassistant/core.py b/homeassistant/core.py index 3f561cdfab8206..a1d9a83d1ad423 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -12,6 +12,7 @@ import logging import os import pathlib +import re import threading from time import monotonic from types import MappingProxyType @@ -63,7 +64,7 @@ ServiceNotFound, Unauthorized, ) -from homeassistant.util import location, slugify +from homeassistant.util import location from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem @@ -103,12 +104,15 @@ def split_entity_id(entity_id: str) -> List[str]: return entity_id.split(".", 1) +VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool: """Test if an entity ID is a valid format. Format: . where both are slugs. """ - return "." in entity_id and slugify(entity_id) == entity_id.replace(".", "_", 1) + return VALID_ENTITY_ID.match(entity_id) is not None def valid_state(state: str) -> bool: @@ -298,10 +302,10 @@ def async_add_job( if asyncio.iscoroutine(check_target): task = self.loop.create_task(target) # type: ignore - elif is_callback(check_target): - self.loop.call_soon(target, *args) elif asyncio.iscoroutinefunction(check_target): task = self.loop.create_task(target(*args)) + elif is_callback(check_target): + self.loop.call_soon(target, *args) else: task = self.loop.run_in_executor( # type: ignore None, target, *args @@ -360,7 +364,11 @@ def async_run_job(self, target: Callable[..., None], *args: Any) -> None: target: target to call. args: parameters for method to call. """ - if not asyncio.iscoroutine(target) and is_callback(target): + if ( + not asyncio.iscoroutine(target) + and not asyncio.iscoroutinefunction(target) + and is_callback(target) + ): target(*args) else: self.async_add_job(target, *args) @@ -1245,10 +1253,10 @@ async def _execute_service( self, handler: Service, service_call: ServiceCall ) -> None: """Execute a service.""" - if handler.is_callback: - handler.func(service_call) - elif handler.is_coroutinefunction: + if handler.is_coroutinefunction: await handler.func(service_call) + elif handler.is_callback: + handler.func(service_call) else: await self._hass.async_add_executor_job(handler.func, service_call) @@ -1284,6 +1292,9 @@ def __init__(self, hass: HomeAssistant) -> None: # List of allowed external dirs to access self.whitelist_external_dirs: Set[str] = set() + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> Optional[float]: """Calculate distance from Home Assistant. @@ -1346,6 +1357,7 @@ def as_dict(self) -> Dict: "whitelist_external_dirs": self.whitelist_external_dirs, "version": __version__, "config_source": self.config_source, + "safe_mode": self.safe_mode, } def set_time_zone(self, time_zone_str: str) -> None: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 4dd1c7acf5082a..4a115762be40b0 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -30,7 +30,7 @@ class UnknownHandler(FlowError): class UnknownFlow(FlowError): - """Uknown flow specified.""" + """Unknown flow specified.""" class UnknownStep(FlowError): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2a013b16ae25d6..d0162f84737c2a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -12,6 +12,7 @@ "almond", "ambiclimate", "ambient_station", + "august", "axis", "brother", "cast", @@ -20,10 +21,13 @@ "daikin", "deconz", "dialogflow", + "dynalite", "ecobee", "elgato", "emulated_roku", "esphome", + "garmin_connect", + "gdacs", "geofency", "geonetnz_quakes", "geonetnz_volcano", @@ -44,6 +48,7 @@ "ipma", "iqvia", "izone", + "konnected", "life360", "lifx", "linky", @@ -52,7 +57,11 @@ "logi_circle", "luftdaten", "mailgun", + "melcloud", "met", + "meteo_france", + "mikrotik", + "minecraft_server", "mobile_app", "mqtt", "neato", @@ -69,6 +78,7 @@ "rainmachine", "ring", "samsungtv", + "sense", "sentry", "simplisafe", "smartthings", @@ -78,6 +88,7 @@ "soma", "somfy", "sonos", + "spotify", "starline", "tellduslive", "tesla", @@ -92,6 +103,7 @@ "upnp", "velbus", "vesync", + "vilfo", "vizio", "wemo", "withings", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 5e09a241a9e661..0eb9af0231d64d 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -30,11 +30,20 @@ { "manufacturer": "Royal Philips Electronics", "modelName": "Philips hue bridge 2015" + }, + { + "manufacturer": "Signify", + "modelName": "Philips hue bridge 2015" + } + ], + "konnected": [ + { + "manufacturer": "konnected.io" } ], "samsungtv": [ { - "deviceType": "urn:samsung.com:device:RemoteControlReceiver:1" + "st": "urn:samsung.com:device:RemoteControlReceiver:1" } ], "sonos": [ diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8d3bff42d1284b..9817dd69f8166f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -27,6 +27,9 @@ "_printer._tcp.local.": [ "brother" ], + "_spotify-connect._tcp.local.": [ + "spotify" + ], "_viziocast._tcp.local.": [ "vizio" ], diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index ad97456968b864..7189f519724c9e 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -1,10 +1,10 @@ """Helper methods for components within Home Assistant.""" import re -from typing import Any, Dict, Iterable, Sequence, Tuple +from typing import Any, Iterable, Sequence, Tuple from homeassistant.const import CONF_PLATFORM -ConfigType = Dict[str, Any] +from .typing import ConfigType def config_per_platform(config: ConfigType, domain: str) -> Iterable[Tuple[Any, Any]]: diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 1b3721788f515d..d03469e20bb4b6 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -35,7 +35,7 @@ Optional[dict], ], Awaitable[None], -] # pylint: disable=invalid-name +] class CollectionError(HomeAssistantError): @@ -158,9 +158,13 @@ def hass(self) -> HomeAssistant: """Home Assistant object.""" return self.store.hass + async def _async_load_data(self) -> Optional[dict]: + """Load the data.""" + return cast(Optional[dict], await self.store.async_load()) + async def async_load(self) -> None: """Load the storage Manager.""" - raw_storage = cast(Optional[dict], await self.store.async_load()) + raw_storage = await self._async_load_data() if raw_storage is None: raw_storage = {"items": []} @@ -231,6 +235,26 @@ def _data_to_save(self) -> dict: return {"items": list(self.data.values())} +class IDLessCollection(ObservableCollection): + """A collection without IDs.""" + + counter = 0 + + async def async_load(self, data: List[dict]) -> None: + """Load the collection. Overrides existing data.""" + for item_id in list(self.data): + await self.notify_change(CHANGE_REMOVED, item_id, None) + + self.data.clear() + + for item in data: + self.counter += 1 + item_id = f"fakeid-{self.counter}" + + self.data[item_id] = item + await self.notify_change(CHANGE_ADDED, item_id, item) + + @callback def attach_entity_component_collection( entity_component: EntityComponent, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index c3d098539608f9..3500a3a4e3d7a7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1,10 +1,11 @@ """Offer reusable conditions.""" import asyncio +from collections import deque from datetime import datetime, timedelta import functools as ft import logging import sys -from typing import Callable, Container, Optional, Union, cast +from typing import Callable, Container, Optional, Set, Union, cast from homeassistant.components import zone as zone_cmp from homeassistant.components.device_automation import ( @@ -19,6 +20,7 @@ CONF_BEFORE, CONF_BELOW, CONF_CONDITION, + CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_STATE, @@ -31,7 +33,7 @@ SUN_EVENT_SUNSET, WEEKDAYS, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError, TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.sun import get_astral_event_date @@ -529,3 +531,50 @@ async def async_validate_condition_config( return cast(ConfigType, platform.CONDITION_SCHEMA(config)) # type: ignore return config + + +@callback +def async_extract_entities(config: ConfigType) -> Set[str]: + """Extract entities from a condition.""" + referenced = set() + to_process = deque([config]) + + while to_process: + config = to_process.popleft() + condition = config[CONF_CONDITION] + + if condition in ("and", "or"): + to_process.extend(config["conditions"]) + continue + + entity_id = config.get(CONF_ENTITY_ID) + + if entity_id is not None: + referenced.add(entity_id) + + return referenced + + +@callback +def async_extract_devices(config: ConfigType) -> Set[str]: + """Extract devices from a condition.""" + referenced = set() + to_process = deque([config]) + + while to_process: + config = to_process.popleft() + condition = config[CONF_CONDITION] + + if condition in ("and", "or"): + to_process.extend(config["conditions"]) + continue + + if condition != "device": + continue + + device_id = config.get(CONF_DEVICE_ID) + + if device_id is not None: + referenced.add(device_id) + + return referenced diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e357a2ba622cfb..565cac4058cd1d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -52,6 +52,7 @@ CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE, ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, TEMP_CELSIUS, @@ -231,7 +232,9 @@ def entity_ids(value: Union[str, List]) -> List[str]: return [entity_id(ent_id) for ent_id in value] -comp_entity_ids = vol.Any(vol.All(vol.Lower, ENTITY_MATCH_ALL), entity_ids) +comp_entity_ids = vol.Any( + vol.All(vol.Lower, vol.Any(ENTITY_MATCH_ALL, ENTITY_MATCH_NONE)), entity_ids +) def entity_domain(domain: str) -> Callable[[Any], str]: @@ -399,7 +402,20 @@ def service(value: Any) -> str: raise vol.Invalid(f"Service {value} does not match format .") -def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable: +def slug(value: Any) -> str: + """Validate value is a valid slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + str_value = str(value) + slg = util_slugify(str_value) + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + + +def schema_with_slug_keys( + value_schema: Union[T, Callable], *, slug_validator: Callable[[Any], str] = slug +) -> Callable: """Ensure dicts have slugs as keys. Replacement of vol.Schema({cv.slug: value_schema}) to prevent misleading @@ -413,24 +429,13 @@ def verify(value: Dict) -> Dict: raise vol.Invalid("expected dictionary") for key in value.keys(): - slug(key) + slug_validator(key) return cast(Dict, schema(value)) return verify -def slug(value: Any) -> str: - """Validate value is a valid slug.""" - if value is None: - raise vol.Invalid("Slug should not be None") - str_value = str(value) - slg = util_slugify(str_value) - if str_value == slg: - return str_value - raise vol.Invalid(f"invalid slug {value} (try {slg})") - - def slugify(value: Any) -> str: """Coerce a value to a slug.""" if value is None: @@ -585,6 +590,25 @@ def ensure_list_csv(value: Any) -> List: return ensure_list(value) +class multi_select: + """Multi select validator returning list of selected values.""" + + def __init__(self, options: dict) -> None: + """Initialize multi select.""" + self.options = options + + def __call__(self, selected: list) -> list: + """Validate input.""" + if not isinstance(selected, list): + raise vol.Invalid("Not a list") + + for value in selected: + if value not in self.options: + raise vol.Invalid(f"{value} is not a valid option") + + return selected + + def deprecated( key: str, replacement_key: Optional[str] = None, @@ -682,6 +706,30 @@ def validator(config: Dict) -> Dict: return validator +def key_value_schemas( + key: str, value_schemas: Dict[str, vol.Schema] +) -> Callable[[Any], Dict[str, Any]]: + """Create a validator that validates based on a value for specific key. + + This gives better error messages. + """ + + def key_value_validator(value: Any) -> Dict[str, Any]: + if not isinstance(value, dict): + raise vol.Invalid("Expected a dictionary") + + key_value = value.get(key) + + if key_value not in value_schemas: + raise vol.Invalid( + f"Unexpected key {key_value}. Expected {', '.join(value_schemas)}" + ) + + return cast(Dict[str, Any], value_schemas[key_value](value)) + + return key_value_validator + + # Validator helpers @@ -710,6 +758,9 @@ def custom_serializer(schema: Any) -> Any: if schema is positive_time_period_dict: return {"type": "positive_time_period_dict"} + if isinstance(schema, multi_select): + return {"type": "multi_select", "options": schema.options} + return voluptuous_serialize.UNSUPPORTED @@ -724,6 +775,8 @@ def custom_serializer(schema: Any) -> Any: PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) +ENTITY_SERVICE_FIELDS = (ATTR_ENTITY_ID, ATTR_AREA_ID) + def make_entity_service_schema( schema: dict, *, extra: int = vol.PREVENT_EXTRA @@ -734,11 +787,13 @@ def make_entity_service_schema( { **schema, vol.Optional(ATTR_ENTITY_ID): comp_entity_ids, - vol.Optional(ATTR_AREA_ID): vol.All(ensure_list, [str]), + vol.Optional(ATTR_AREA_ID): vol.Any( + ENTITY_MATCH_NONE, vol.All(ensure_list, [str]) + ), }, extra=extra, ), - has_at_least_one_key(ATTR_ENTITY_ID, ATTR_AREA_ID), + has_at_least_one_key(*ENTITY_SERVICE_FIELDS), ) @@ -870,16 +925,19 @@ def make_entity_service_schema( DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -CONDITION_SCHEMA: vol.Schema = vol.Any( - NUMERIC_STATE_CONDITION_SCHEMA, - STATE_CONDITION_SCHEMA, - SUN_CONDITION_SCHEMA, - TEMPLATE_CONDITION_SCHEMA, - TIME_CONDITION_SCHEMA, - ZONE_CONDITION_SCHEMA, - AND_CONDITION_SCHEMA, - OR_CONDITION_SCHEMA, - DEVICE_CONDITION_SCHEMA, +CONDITION_SCHEMA: vol.Schema = key_value_schemas( + CONF_CONDITION, + { + "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, + "state": STATE_CONDITION_SCHEMA, + "sun": SUN_CONDITION_SCHEMA, + "template": TEMPLATE_CONDITION_SCHEMA, + "time": TIME_CONDITION_SCHEMA, + "zone": ZONE_CONDITION_SCHEMA, + "and": AND_CONDITION_SCHEMA, + "or": OR_CONDITION_SCHEMA, + "device": DEVICE_CONDITION_SCHEMA, + }, ) _SCRIPT_DELAY_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index ac5fb6086756a4..05f49cd9f53158 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -5,6 +5,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator +import homeassistant.helpers.config_validation as cv # mypy: allow-untyped-calls, allow-untyped-defs @@ -36,7 +37,9 @@ def _prepare_result_json(self, result): if schema is None: data["data_schema"] = [] else: - data["data_schema"] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert( + schema, custom_serializer=cv.custom_serializer + ) return data diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py new file mode 100644 index 00000000000000..bbaf6dacfeb20e --- /dev/null +++ b/homeassistant/helpers/debounce.py @@ -0,0 +1,78 @@ +"""Debounce helper.""" +import asyncio +from logging import Logger +from typing import Any, Awaitable, Callable, Optional + +from homeassistant.core import HomeAssistant, callback + + +class Debouncer: + """Class to rate limit calls to a specific command.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + cooldown: float, + immediate: bool, + function: Optional[Callable[..., Awaitable[Any]]] = None, + ): + """Initialize debounce. + + immediate: indicate if the function needs to be called right away and + wait 0.3s until executing next invocation. + function: optional and can be instantiated later. + """ + self.hass = hass + self.logger = logger + self.function = function + self.cooldown = cooldown + self.immediate = immediate + self._timer_task: Optional[asyncio.TimerHandle] = None + self._execute_at_end_of_timer: bool = False + + async def async_call(self) -> None: + """Call the function.""" + assert self.function is not None + + if self._timer_task: + if not self._execute_at_end_of_timer: + self._execute_at_end_of_timer = True + + return + + if self.immediate: + await self.hass.async_add_job(self.function) # type: ignore + else: + self._execute_at_end_of_timer = True + + self._timer_task = self.hass.loop.call_later( + self.cooldown, + lambda: self.hass.async_create_task(self._handle_timer_finish()), + ) + + async def _handle_timer_finish(self) -> None: + """Handle a finished timer.""" + assert self.function is not None + + self._timer_task = None + + if not self._execute_at_end_of_timer: + return + + self._execute_at_end_of_timer = False + + try: + await self.hass.async_add_job(self.function) # type: ignore + except Exception: # pylint: disable=broad-except + self.logger.exception("Unexpected exception from %s", self.function) + + @callback + def async_cancel(self) -> None: + """Cancel any scheduled call.""" + if self._timer_task: + self._timer_task.cancel() + self._timer_task = None + + self._execute_at_end_of_timer = False diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 41c78a2f0700a9..0821b909dc7535 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -260,6 +260,7 @@ def _async_update_device( return new + @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" del self.devices[device_id] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b9d1a73351c2b1..186aecd78f433b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -337,7 +337,7 @@ def _async_write_ha_state(self): if name is not None: attr[ATTR_FRIENDLY_NAME] = name - icon = self.icon + icon = (entry and entry.icon) or self.icon if icon is not None: attr[ATTR_ICON] = icon @@ -365,13 +365,25 @@ def _async_write_ha_state(self): if end - start > 0.4 and not self._slow_reported: self._slow_reported = True + extra = "" + if "custom_components" in type(self).__module__: + extra = "Please report it to the custom component author." + else: + extra = ( + "Please create a bug report at " + "https://github.com/home-assistant/home-assistant/issues?q=is%3Aopen+is%3Aissue" + ) + if self.platform: + extra += ( + f"+label%3A%22integration%3A+{self.platform.platform_name}%22" + ) + _LOGGER.warning( - "Updating state for %s (%s) took %.3f seconds. " - "Please report platform to the developers at " - "https://goo.gl/Nvioub", + "Updating state for %s (%s) took %.3f seconds. %s", self.entity_id, type(self), end - start, + extra, ) # Overwrite properties that have been set in the config file. @@ -429,7 +441,10 @@ def async_schedule_update_ha_state(self, force_refresh=False): If state is changed more than once before the ha state change task has been executed, the intermediate state transitions will be missed. """ - self.hass.async_create_task(self.async_update_ha_state(force_refresh)) + if force_refresh: + self.hass.async_create_task(self.async_update_ha_state(force_refresh)) + else: + self.async_write_ha_state() async def async_device_update(self, warning=True): """Process 'update' or 'async_update' from entity. @@ -473,6 +488,12 @@ def async_on_remove(self, func: CALLBACK_TYPE) -> None: self._on_remove = [] self._on_remove.append(func) + async def async_removed_from_registry(self) -> None: + """Run when entity has been removed from entity registry. + + To be extended by integrations. + """ + async def async_remove(self) -> None: """Remove entity from Home Assistant.""" assert self.hass is not None @@ -519,6 +540,9 @@ async def async_internal_will_remove_from_hass(self) -> None: async def _async_registry_updated(self, event): """Handle entity registry update.""" data = event.data + if data["action"] == "remove" and data["entity_id"] == self.entity_id: + await self.async_removed_from_registry() + if ( data["action"] != "update" or data.get("old_entity_id", data["entity_id"]) != self.entity_id @@ -568,7 +592,6 @@ def __repr__(self) -> str: # call an requests async def async_request_call(self, coro): """Process request batched.""" - if self.parallel_updates: await self.parallel_updates.acquire() @@ -596,23 +619,17 @@ def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" raise NotImplementedError() - def async_turn_on(self, **kwargs): - """Turn the entity on. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.turn_on, **kwargs)) + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + await self.hass.async_add_job(ft.partial(self.turn_on, **kwargs)) def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" raise NotImplementedError() - def async_turn_off(self, **kwargs): - """Turn the entity off. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(ft.partial(self.turn_off, **kwargs)) + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + await self.hass.async_add_job(ft.partial(self.turn_off, **kwargs)) def toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" @@ -621,11 +638,9 @@ def toggle(self, **kwargs: Any) -> None: else: self.turn_on(**kwargs) - def async_toggle(self, **kwargs): - """Toggle the entity. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_toggle(self, **kwargs): + """Toggle the entity.""" if self.is_on: - return self.async_turn_off(**kwargs) - return self.async_turn_on(**kwargs) + await self.async_turn_off(**kwargs) + else: + await self.async_turn_on(**kwargs) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index e26dc5dfbeace2..f6c473dd418f59 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -273,6 +273,7 @@ async def async_prepare_reload(self, *, skip_reset: bool = False) -> Optional[di return processed_conf + @callback def _async_init_entity_platform( self, platform_type: str, diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 8fedc198fe225b..e1e046eaa6d655 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -62,22 +62,42 @@ def __init__( # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities if platform is None: - self.parallel_updates = None - self.parallel_updates_semaphore: Optional[asyncio.Semaphore] = None + self.parallel_updates_created = True + self.parallel_updates: Optional[asyncio.Semaphore] = None return - self.parallel_updates = getattr(platform, "PARALLEL_UPDATES", None) - # semaphore will be created on demand - self.parallel_updates_semaphore = None + self.parallel_updates_created = False + self.parallel_updates = None - def _get_parallel_updates_semaphore(self) -> asyncio.Semaphore: - """Get or create a semaphore for parallel updates.""" - if self.parallel_updates_semaphore is None: - self.parallel_updates_semaphore = asyncio.Semaphore( - self.parallel_updates if self.parallel_updates else 1, - loop=self.hass.loop, - ) - return self.parallel_updates_semaphore + @callback + def _get_parallel_updates_semaphore( + self, entity_has_async_update: bool + ) -> Optional[asyncio.Semaphore]: + """Get or create a semaphore for parallel updates. + + Semaphore will be created on demand because we base it off if update method is async or not. + + If parallel updates is set to 0, we skip the semaphore. + If parallel updates is set to a number, we initialize the semaphore to that number. + Default for entities with `async_update` method is 1. Otherwise it's 0. + """ + if self.parallel_updates_created: + return self.parallel_updates + + self.parallel_updates_created = True + + parallel_updates = getattr(self.platform, "PARALLEL_UPDATES", None) + + if parallel_updates is None and not entity_has_async_update: + parallel_updates = 1 + + if parallel_updates == 0: + parallel_updates = None + + if parallel_updates is not None: + self.parallel_updates = asyncio.Semaphore(parallel_updates) + + return self.parallel_updates async def async_setup(self, platform_config, discovery_info=None): """Set up the platform from a config file.""" @@ -282,21 +302,9 @@ async def _async_add_entity( entity.hass = self.hass entity.platform = self - - # Async entity - # PARALLEL_UPDATES == None: entity.parallel_updates = None - # PARALLEL_UPDATES == 0: entity.parallel_updates = None - # PARALLEL_UPDATES > 0: entity.parallel_updates = Semaphore(p) - # Sync entity - # PARALLEL_UPDATES == None: entity.parallel_updates = Semaphore(1) - # PARALLEL_UPDATES == 0: entity.parallel_updates = None - # PARALLEL_UPDATES > 0: entity.parallel_updates = Semaphore(p) - if hasattr(entity, "async_update") and not self.parallel_updates: - entity.parallel_updates = None - elif not hasattr(entity, "async_update") and self.parallel_updates == 0: - entity.parallel_updates = None - else: - entity.parallel_updates = self._get_parallel_updates_semaphore() + entity.parallel_updates = self._get_parallel_updates_semaphore( + hasattr(entity, "async_update") + ) # Update properties before we generate the entity_id if update_before_add: @@ -361,6 +369,8 @@ async def _async_add_entity( supported_features=entity.supported_features, device_class=entity.device_class, unit_of_measurement=entity.unit_of_measurement, + original_name=entity.name, + original_icon=entity.icon, ) entity.registry_entry = entry diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 635f7feba130cb..5996fb6eaf7080 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -17,6 +17,8 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, @@ -60,6 +62,7 @@ class RegistryEntry: unique_id = attr.ib(type=str) platform = attr.ib(type=str) name = attr.ib(type=str, default=None) + icon = attr.ib(type=str, default=None) device_id: Optional[str] = attr.ib(default=None) config_entry_id: Optional[str] = attr.ib(default=None) disabled_by = attr.ib( @@ -79,6 +82,9 @@ class RegistryEntry: supported_features: int = attr.ib(default=0) device_class: Optional[str] = attr.ib(default=None) unit_of_measurement: Optional[str] = attr.ib(default=None) + # As set by integration + original_name: Optional[str] = attr.ib(default=None) + original_icon: Optional[str] = attr.ib(default=None) domain = attr.ib(type=str, init=False, repr=False) @domain.default @@ -167,6 +173,8 @@ def async_get_or_create( supported_features: Optional[int] = None, device_class: Optional[str] = None, unit_of_measurement: Optional[str] = None, + original_name: Optional[str] = None, + original_icon: Optional[str] = None, ) -> RegistryEntry: """Get entity. Create if it doesn't exist.""" config_entry_id = None @@ -184,6 +192,8 @@ def async_get_or_create( supported_features=supported_features or _UNDEF, device_class=device_class or _UNDEF, unit_of_measurement=unit_of_measurement or _UNDEF, + original_name=original_name or _UNDEF, + original_icon=original_icon or _UNDEF, # When we changed our slugify algorithm, we invalidated some # stored entity IDs with either a __ or ending in _. # Fix introduced in 0.86 (Jan 23, 2019). Next line can be @@ -215,6 +225,8 @@ def async_get_or_create( supported_features=supported_features or 0, device_class=device_class, unit_of_measurement=unit_of_measurement, + original_name=original_name, + original_icon=original_icon, ) self.entities[entity_id] = entity _LOGGER.info("Registered new %s.%s entity: %s", domain, platform, entity_id) @@ -254,6 +266,7 @@ def async_update_entity( entity_id, *, name=_UNDEF, + icon=_UNDEF, new_entity_id=_UNDEF, new_unique_id=_UNDEF, disabled_by=_UNDEF, @@ -264,6 +277,7 @@ def async_update_entity( self._async_update_entity( entity_id, name=name, + icon=icon, new_entity_id=new_entity_id, new_unique_id=new_unique_id, disabled_by=disabled_by, @@ -276,6 +290,7 @@ def _async_update_entity( entity_id, *, name=_UNDEF, + icon=_UNDEF, config_entry_id=_UNDEF, new_entity_id=_UNDEF, device_id=_UNDEF, @@ -285,6 +300,8 @@ def _async_update_entity( supported_features=_UNDEF, device_class=_UNDEF, unit_of_measurement=_UNDEF, + original_name=_UNDEF, + original_icon=_UNDEF, ): """Private facing update properties method.""" old = self.entities[entity_id] @@ -293,6 +310,7 @@ def _async_update_entity( for attr_name, value in ( ("name", name), + ("icon", icon), ("config_entry_id", config_entry_id), ("device_id", device_id), ("disabled_by", disabled_by), @@ -300,6 +318,8 @@ def _async_update_entity( ("supported_features", supported_features), ("device_class", device_class), ("unit_of_measurement", unit_of_measurement), + ("original_name", original_name), + ("original_icon", original_icon), ): if value is not _UNDEF and value != getattr(old, attr_name): changes[attr_name] = value @@ -372,11 +392,14 @@ async def async_load(self) -> None: unique_id=entity["unique_id"], platform=entity["platform"], name=entity.get("name"), + icon=entity.get("icon"), disabled_by=entity.get("disabled_by"), capabilities=entity.get("capabilities") or {}, supported_features=entity.get("supported_features", 0), device_class=entity.get("device_class"), unit_of_measurement=entity.get("unit_of_measurement"), + original_name=entity.get("original_name"), + original_icon=entity.get("original_icon"), ) self.entities = entities @@ -399,11 +422,14 @@ def _data_to_save(self) -> Dict[str, Any]: "unique_id": entry.unique_id, "platform": entry.platform, "name": entry.name, + "icon": entry.icon, "disabled_by": entry.disabled_by, "capabilities": entry.capabilities, "supported_features": entry.supported_features, "device_class": entry.device_class, "unit_of_measurement": entry.unit_of_measurement, + "original_name": entry.original_name, + "original_icon": entry.original_icon, } for entry in self.entities.values() ] @@ -523,6 +549,14 @@ def _write_unavailable_states(_: Event) -> None: if entry.unit_of_measurement is not None: attrs[ATTR_UNIT_OF_MEASUREMENT] = entry.unit_of_measurement + name = entry.name or entry.original_name + if name is not None: + attrs[ATTR_FRIENDLY_NAME] = name + + icon = entry.icon or entry.original_icon + if icon is not None: + attrs[ATTR_ICON] = icon + states.async_set(entry.entity_id, STATE_UNAVAILABLE, attrs) hass.bus.async_listen(EVENT_HOMEASSISTANT_START, _write_unavailable_states) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b3c8af6f50cdd2..74faca6a1d2f35 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -225,7 +225,7 @@ def utc_converter(utc_now: datetime) -> None: @callback @bind_hass def async_track_point_in_utc_time( - hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime + hass: HomeAssistant, action: Callable[..., Any], point_in_time: datetime ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC diff --git a/homeassistant/helpers/logging.py b/homeassistant/helpers/logging.py index 0b274458045939..2e3270879f084e 100644 --- a/homeassistant/helpers/logging.py +++ b/homeassistant/helpers/logging.py @@ -42,7 +42,7 @@ def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: def process( self, msg: Any, kwargs: MutableMapping[str, Any] ) -> Tuple[Any, MutableMapping[str, Any]]: - """Process the keyward args in preparation for logging.""" + """Process the keyword args in preparation for logging.""" return ( msg, { diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0c3dbe96bc530a..d57d3ad99207a3 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -2,7 +2,7 @@ import asyncio from datetime import datetime, timedelta import logging -from typing import Any, Dict, List, Optional, Set +from typing import Any, Awaitable, Dict, List, Optional, Set, cast from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( @@ -20,9 +20,6 @@ from homeassistant.helpers.storage import Store import homeassistant.util.dt as dt_util -# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs -# mypy: no-warn-return-any - DATA_RESTORE_STATE_TASK = "restore_state_task" _LOGGER = logging.getLogger(__name__) @@ -45,7 +42,7 @@ def __init__(self, state: State, last_seen: datetime) -> None: self.state = state self.last_seen = last_seen - def as_dict(self) -> Dict: + def as_dict(self) -> Dict[str, Any]: """Return a dict representation of the stored state.""" return {"state": self.state.as_dict(), "last_seen": self.last_seen} @@ -104,7 +101,7 @@ async def load_instance(hass: HomeAssistant) -> "RestoreStateData": load_instance(hass) ) - return await task + return await cast(Awaitable["RestoreStateData"], task) def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" @@ -115,6 +112,7 @@ def __init__(self, hass: HomeAssistant) -> None: self.last_states: Dict[str, StoredState] = {} self.entity_ids: Set[str] = set() + @callback def async_get_stored_states(self) -> List[StoredState]: """Get the set of states which should be stored. @@ -173,6 +171,7 @@ async def async_dump_states(self) -> None: def async_setup_dump(self, *args: Any) -> None: """Set up the restore state listeners.""" + @callback def _async_dump_states(*_: Any) -> None: self.hass.async_create_task(self.async_dump_states()) @@ -209,15 +208,18 @@ def async_restore_entity_removed(self, entity_id: str) -> None: self.entity_ids.remove(entity_id) -def _encode(value): +def _encode(value: Any) -> Any: """Little helper to JSON encode a value.""" try: - return JSONEncoder.default(None, value) + return JSONEncoder.default( + None, # type: ignore + value, + ) except TypeError: return value -def _encode_complex(value): +def _encode_complex(value: Any) -> Any: """Recursively encode all values with the JSONEncoder.""" if isinstance(value, dict): return {_encode(key): _encode_complex(value) for key, value in value.items()} diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 837a561181d407..1ce9d2b87bbedc 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,10 +1,11 @@ """Helpers to execute scripts.""" +from abc import ABC, abstractmethod import asyncio from contextlib import suppress from datetime import datetime from itertools import islice import logging -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast import voluptuous as vol @@ -31,13 +32,10 @@ async_track_template, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as date_util +from homeassistant.util.dt import utcnow # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs -_LOGGER = logging.getLogger(__name__) - CONF_ALIAS = "alias" CONF_SERVICE = "service" CONF_SERVICE_DATA = "data" @@ -50,7 +48,6 @@ CONF_CONTINUE = "continue_on_timeout" CONF_SCENE = "scene" - ACTION_DELAY = "delay" ACTION_WAIT_TEMPLATE = "wait_template" ACTION_CHECK_CONDITION = "condition" @@ -59,6 +56,31 @@ ACTION_DEVICE_AUTOMATION = "device" ACTION_ACTIVATE_SCENE = "scene" +IF_RUNNING_ERROR = "error" +IF_RUNNING_IGNORE = "ignore" +IF_RUNNING_PARALLEL = "parallel" +IF_RUNNING_RESTART = "restart" +# First choice is default +IF_RUNNING_CHOICES = [ + IF_RUNNING_PARALLEL, + IF_RUNNING_ERROR, + IF_RUNNING_IGNORE, + IF_RUNNING_RESTART, +] + +RUN_MODE_BACKGROUND = "background" +RUN_MODE_BLOCKING = "blocking" +RUN_MODE_LEGACY = "legacy" +# First choice is default +RUN_MODE_CHOICES = [ + RUN_MODE_BLOCKING, + RUN_MODE_BACKGROUND, + RUN_MODE_LEGACY, +] + +_LOG_EXCEPTION = logging.ERROR + 1 +_TIMEOUT_MSG = "Timeout reached, abort script." + def _determine_action(action): """Determine action type.""" @@ -83,16 +105,6 @@ def _determine_action(action): return ACTION_CALL_SERVICE -def call_from_config( - hass: HomeAssistant, - config: ConfigType, - variables: Optional[Sequence] = None, - context: Optional[Context] = None, -) -> None: - """Call a script based on a config entry.""" - Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables, context) - - async def async_validate_action_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: @@ -121,121 +133,55 @@ class _SuspendScript(Exception): """Throw if script needs to suspend.""" -class Script: - """Representation of a script.""" +class _ScriptRunBase(ABC): + """Common data & methods for managing Script sequence run.""" def __init__( self, hass: HomeAssistant, - sequence: Sequence[Dict[str, Any]], - name: Optional[str] = None, - change_listener: Optional[Callable[..., Any]] = None, + script: "Script", + variables: Optional[Sequence], + context: Optional[Context], + log_exceptions: bool, ) -> None: - """Initialize the script.""" - self.hass = hass - self.sequence = sequence - template.attach(hass, self.sequence) - self.name = name - self._change_listener = change_listener - self._cur = -1 - self._exception_step: Optional[int] = None - self.last_action = None - self.last_triggered: Optional[datetime] = None - self.can_cancel = any( - CONF_DELAY in action or CONF_WAIT_TEMPLATE in action - for action in self.sequence - ) - self._async_listener: List[CALLBACK_TYPE] = [] - self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {} - self._actions = { - ACTION_DELAY: self._async_delay, - ACTION_WAIT_TEMPLATE: self._async_wait_template, - ACTION_CHECK_CONDITION: self._async_check_condition, - ACTION_FIRE_EVENT: self._async_fire_event, - ACTION_CALL_SERVICE: self._async_call_service, - ACTION_DEVICE_AUTOMATION: self._async_device_automation, - ACTION_ACTIVATE_SCENE: self._async_activate_scene, - } + self._hass = hass + self._script = script + self._variables = variables + self._context = context + self._log_exceptions = log_exceptions + self._step = -1 + self._action: Optional[Dict[str, Any]] = None + + def _changed(self): + self._script._changed() # pylint: disable=protected-access @property - def is_running(self) -> bool: - """Return true if script is on.""" - return self._cur != -1 + def _config_cache(self): + return self._script._config_cache # pylint: disable=protected-access - def run(self, variables=None, context=None): + @abstractmethod + async def async_run(self) -> None: """Run script.""" - asyncio.run_coroutine_threadsafe( - self.async_run(variables, context), self.hass.loop - ).result() - - async def async_run( - self, variables: Optional[Sequence] = None, context: Optional[Context] = None - ) -> None: - """Run script. - This method is a coroutine. - """ - self.last_triggered = date_util.utcnow() - if self._cur == -1: - self._log("Running script") - self._cur = 0 - - # Unregister callback if we were in a delay or wait but turn on is - # called again. In that case we just continue execution. - self._async_remove_listener() - - for cur, action in islice(enumerate(self.sequence), self._cur, None): - try: - await self._handle_action(action, variables, context) - except _SuspendScript: - # Store next step to take and notify change listeners - self._cur = cur + 1 - if self._change_listener: - self.hass.async_add_job(self._change_listener) - return - except _StopScript: - break - except Exception: - # Store the step that had an exception - self._exception_step = cur - # Set script to not running - self._cur = -1 - self.last_action = None - # Pass exception on. - raise - - # Set script to not-running. - self._cur = -1 - self.last_action = None - if self._change_listener: - self.hass.async_add_job(self._change_listener) - - def stop(self) -> None: - """Stop running script.""" - run_callback_threadsafe(self.hass.loop, self.async_stop).result() - - def async_stop(self) -> None: - """Stop running script.""" - if self._cur == -1: - return - - self._cur = -1 - self._async_remove_listener() - if self._change_listener: - self.hass.async_add_job(self._change_listener) + async def _async_step(self, log_exceptions): + try: + await getattr(self, f"_async_{_determine_action(self._action)}_step")() + except Exception as err: + if not isinstance(err, (_SuspendScript, _StopScript)) and ( + self._log_exceptions or log_exceptions + ): + self._log_exception(err) + raise - @callback - def async_log_exception(self, logger, message_base, exception): - """Log an exception for this script. + @abstractmethod + async def async_stop(self) -> None: + """Stop script run.""" - Should only be called on exceptions raised by this scripts async_run. - """ - step = self._exception_step - action = self.sequence[step] - action_type = _determine_action(action) + def _log_exception(self, exception): + action_type = _determine_action(self._action) - error = None - meth = logger.error + error = str(exception) + level = logging.ERROR if isinstance(exception, vol.Invalid): error_desc = "Invalid data" @@ -250,173 +196,348 @@ def async_log_exception(self, logger, message_base, exception): error_desc = "Service not found" else: - # Print the full stack trace, unknown error - error_desc = "Unknown error" - meth = logger.exception - error = "" - - if error is None: - error = str(exception) + error_desc = "Unexpected error" + level = _LOG_EXCEPTION - meth( - "%s. %s for %s at pos %s: %s", - message_base, + self._log( + "Error executing script. %s for %s at pos %s: %s", error_desc, action_type, - step + 1, + self._step + 1, error, + level=level, ) - async def _handle_action(self, action, variables, context): - """Handle an action.""" - await self._actions[_determine_action(action)](action, variables, context) - - async def _async_delay(self, action, variables, context): + @abstractmethod + async def _async_delay_step(self): """Handle delay.""" - # Call ourselves in the future to continue work - unsub = None - - @callback - def async_script_delay(now): - """Handle delay.""" - with suppress(ValueError): - self._async_listener.remove(unsub) - - self.hass.async_create_task(self.async_run(variables, context)) - - delay = action[CONF_DELAY] + def _prep_delay_step(self): try: - if isinstance(delay, template.Template): - delay = vol.All(cv.time_period, cv.positive_timedelta)( - delay.async_render(variables) - ) - elif isinstance(delay, dict): - delay_data = {} - delay_data.update(template.render_complex(delay, variables)) - delay = cv.time_period(delay_data) + delay = vol.All(cv.time_period, cv.positive_timedelta)( + template.render_complex(self._action[CONF_DELAY], self._variables) + ) except (exceptions.TemplateError, vol.Invalid) as ex: - _LOGGER.error("Error rendering '%s' delay template: %s", self.name, ex) - raise _StopScript + self._raise( + "Error rendering %s delay template: %s", + self._script.name, + ex, + exception=_StopScript, + ) - self.last_action = action.get(CONF_ALIAS, f"delay {delay}") - self._log("Executing step %s" % self.last_action) + self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}") + self._log("Executing step %s", self._script.last_action) - unsub = async_track_point_in_utc_time( - self.hass, async_script_delay, date_util.utcnow() + delay - ) - self._async_listener.append(unsub) - raise _SuspendScript + return delay - async def _async_wait_template(self, action, variables, context): + @abstractmethod + async def _async_wait_template_step(self): """Handle a wait template.""" - # Call ourselves in the future to continue work - wait_template = action[CONF_WAIT_TEMPLATE] - wait_template.hass = self.hass - self.last_action = action.get(CONF_ALIAS, "wait template") - self._log("Executing step %s" % self.last_action) + def _prep_wait_template_step(self, async_script_wait): + wait_template = self._action[CONF_WAIT_TEMPLATE] + wait_template.hass = self._hass - # check if condition already okay - if condition.async_template(self.hass, wait_template, variables): - return + self._script.last_action = self._action.get(CONF_ALIAS, "wait template") + self._log("Executing step %s", self._script.last_action) - @callback - def async_script_wait(entity_id, from_s, to_s): - """Handle script after template condition is true.""" - self._async_remove_listener() - self.hass.async_create_task(self.async_run(variables, context)) + # check if condition already okay + if condition.async_template(self._hass, wait_template, self._variables): + return None - self._async_listener.append( - async_track_template(self.hass, wait_template, async_script_wait, variables) + return async_track_template( + self._hass, wait_template, async_script_wait, self._variables ) - if CONF_TIMEOUT in action: - self._async_set_timeout( - action, variables, context, action.get(CONF_CONTINUE, True) - ) - - raise _SuspendScript - - async def _async_call_service(self, action, variables, context): - """Call the service specified in the action. - - This method is a coroutine. - """ - self.last_action = action.get(CONF_ALIAS, "call service") - self._log("Executing step %s" % self.last_action) + async def _async_call_service_step(self): + """Call the service specified in the action.""" + self._script.last_action = self._action.get(CONF_ALIAS, "call service") + self._log("Executing step %s", self._script.last_action) await service.async_call_from_config( - self.hass, - action, + self._hass, + self._action, blocking=True, - variables=variables, + variables=self._variables, validate_config=False, - context=context, + context=self._context, ) - async def _async_device_automation(self, action, variables, context): - """Perform the device automation specified in the action. - - This method is a coroutine. - """ - self.last_action = action.get(CONF_ALIAS, "device automation") - self._log("Executing step %s" % self.last_action) + async def _async_device_step(self): + """Perform the device automation specified in the action.""" + self._script.last_action = self._action.get(CONF_ALIAS, "device automation") + self._log("Executing step %s", self._script.last_action) platform = await device_automation.async_get_device_automation_platform( - self.hass, action[CONF_DOMAIN], "action" + self._hass, self._action[CONF_DOMAIN], "action" ) await platform.async_call_action_from_config( - self.hass, action, variables, context + self._hass, self._action, self._variables, self._context ) - async def _async_activate_scene(self, action, variables, context): - """Activate the scene specified in the action. - - This method is a coroutine. - """ - self.last_action = action.get(CONF_ALIAS, "activate scene") - self._log("Executing step %s" % self.last_action) - await self.hass.services.async_call( + async def _async_scene_step(self): + """Activate the scene specified in the action.""" + self._script.last_action = self._action.get(CONF_ALIAS, "activate scene") + self._log("Executing step %s", self._script.last_action) + await self._hass.services.async_call( scene.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: action[CONF_SCENE]}, + {ATTR_ENTITY_ID: self._action[CONF_SCENE]}, blocking=True, - context=context, + context=self._context, ) - async def _async_fire_event(self, action, variables, context): + async def _async_event_step(self): """Fire an event.""" - self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) - self._log("Executing step %s" % self.last_action) - event_data = dict(action.get(CONF_EVENT_DATA, {})) - if CONF_EVENT_DATA_TEMPLATE in action: + self._script.last_action = self._action.get( + CONF_ALIAS, self._action[CONF_EVENT] + ) + self._log("Executing step %s", self._script.last_action) + event_data = dict(self._action.get(CONF_EVENT_DATA, {})) + if CONF_EVENT_DATA_TEMPLATE in self._action: try: event_data.update( - template.render_complex(action[CONF_EVENT_DATA_TEMPLATE], variables) + template.render_complex( + self._action[CONF_EVENT_DATA_TEMPLATE], self._variables + ) ) except exceptions.TemplateError as ex: - _LOGGER.error("Error rendering event data template: %s", ex) + self._log( + "Error rendering event data template: %s", ex, level=logging.ERROR + ) - self.hass.bus.async_fire(action[CONF_EVENT], event_data, context=context) + self._hass.bus.async_fire( + self._action[CONF_EVENT], event_data, context=self._context + ) - async def _async_check_condition(self, action, variables, context): + async def _async_condition_step(self): """Test if condition is matching.""" - config_cache_key = frozenset((k, str(v)) for k, v in action.items()) + config_cache_key = frozenset((k, str(v)) for k, v in self._action.items()) config = self._config_cache.get(config_cache_key) if not config: - config = await condition.async_from_config(self.hass, action, False) + config = await condition.async_from_config(self._hass, self._action, False) self._config_cache[config_cache_key] = config - self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) - check = config(self.hass, variables) - self._log(f"Test condition {self.last_action}: {check}") - + self._script.last_action = self._action.get( + CONF_ALIAS, self._action[CONF_CONDITION] + ) + check = config(self._hass, self._variables) + self._log("Test condition %s: %s", self._script.last_action, check) if not check: raise _StopScript - def _async_set_timeout(self, action, variables, context, continue_on_timeout): - """Schedule a timeout to abort or continue script.""" - timeout = action[CONF_TIMEOUT] - unsub = None + def _log(self, msg, *args, level=logging.INFO): + self._script._log(msg, *args, level=level) # pylint: disable=protected-access + + def _raise(self, msg, *args, exception=None): + # pylint: disable=protected-access + self._script._raise(msg, *args, exception=exception) + + +class _ScriptRun(_ScriptRunBase): + """Manage Script sequence run.""" + + def __init__( + self, + hass: HomeAssistant, + script: "Script", + variables: Optional[Sequence], + context: Optional[Context], + log_exceptions: bool, + ) -> None: + super().__init__(hass, script, variables, context, log_exceptions) + self._stop = asyncio.Event() + self._stopped = asyncio.Event() + + async def _async_run(self, propagate_exceptions=True): + self._log("Running script") + try: + for self._step, self._action in enumerate(self._script.sequence): + if self._stop.is_set(): + break + await self._async_step(not propagate_exceptions) + except _StopScript: + pass + except Exception: # pylint: disable=broad-except + if propagate_exceptions: + raise + finally: + if not self._stop.is_set(): + self._changed() + self._script.last_action = None + self._script._runs.remove(self) # pylint: disable=protected-access + self._stopped.set() + + async def async_stop(self) -> None: + """Stop script run.""" + self._stop.set() + await self._stopped.wait() + + async def _async_delay_step(self): + """Handle delay.""" + timeout = self._prep_delay_step().total_seconds() + if not self._stop.is_set(): + self._changed() + await asyncio.wait({self._stop.wait()}, timeout=timeout) + + async def _async_wait_template_step(self): + """Handle a wait template.""" + + @callback + def async_script_wait(entity_id, from_s, to_s): + """Handle script after template condition is true.""" + done.set() + + unsub = self._prep_wait_template_step(async_script_wait) + if not unsub: + return + + if not self._stop.is_set(): + self._changed() + try: + timeout = self._action[CONF_TIMEOUT].total_seconds() + except KeyError: + timeout = None + done = asyncio.Event() + try: + await asyncio.wait_for( + asyncio.wait( + {self._stop.wait(), done.wait()}, + return_when=asyncio.FIRST_COMPLETED, + ), + timeout, + ) + except asyncio.TimeoutError: + if not self._action.get(CONF_CONTINUE, True): + self._log(_TIMEOUT_MSG) + raise _StopScript + finally: + unsub() + + +class _BackgroundScriptRun(_ScriptRun): + """Manage background Script sequence run.""" + + async def async_run(self) -> None: + """Run script.""" + self._hass.async_create_task(self._async_run(False)) + + +class _BlockingScriptRun(_ScriptRun): + """Manage blocking Script sequence run.""" + + async def async_run(self) -> None: + """Run script.""" + try: + await asyncio.shield(self._async_run()) + except asyncio.CancelledError: + await self.async_stop() + raise + + +class _LegacyScriptRun(_ScriptRunBase): + """Manage legacy Script sequence run.""" + + def __init__( + self, + hass: HomeAssistant, + script: "Script", + variables: Optional[Sequence], + context: Optional[Context], + log_exceptions: bool, + shared: Optional["_LegacyScriptRun"], + ) -> None: + super().__init__(hass, script, variables, context, log_exceptions) + if shared: + self._shared = shared + else: + # To implement legacy behavior we need to share the following "run state" + # amongst all runs, so it will only exist in the first instantiation of + # concurrent runs, and the rest will use it, too. + self._current = -1 + self._async_listeners: List[CALLBACK_TYPE] = [] + self._shared = self + + @property + def _cur(self): + return self._shared._current # pylint: disable=protected-access + + @_cur.setter + def _cur(self, value): + self._shared._current = value # pylint: disable=protected-access + + @property + def _async_listener(self): + return self._shared._async_listeners # pylint: disable=protected-access + + async def async_run(self) -> None: + """Run script.""" + await self._async_run() + + async def _async_run(self, propagate_exceptions=True): + if self._cur == -1: + self._log("Running script") + self._cur = 0 + + # Unregister callback if we were in a delay or wait but turn on is + # called again. In that case we just continue execution. + self._async_remove_listener() + + suspended = False + try: + for self._step, self._action in islice( + enumerate(self._script.sequence), self._cur, None + ): + await self._async_step(not propagate_exceptions) + except _StopScript: + pass + except _SuspendScript: + # Store next step to take and notify change listeners + self._cur = self._step + 1 + suspended = True + return + except Exception: # pylint: disable=broad-except + if propagate_exceptions: + raise + finally: + if self._cur != -1: + self._changed() + if not suspended: + self._script.last_action = None + await self.async_stop() + + async def async_stop(self) -> None: + """Stop script run.""" + if self._cur == -1: + return + + self._cur = -1 + self._async_remove_listener() + self._script._runs.clear() # pylint: disable=protected-access + + async def _async_delay_step(self): + """Handle delay.""" + delay = self._prep_delay_step() + + @callback + def async_script_delay(now): + """Handle delay.""" + with suppress(ValueError): + self._async_listener.remove(unsub) + self._hass.async_create_task(self._async_run(False)) + + unsub = async_track_point_in_utc_time( + self._hass, async_script_delay, utcnow() + delay + ) + self._async_listener.append(unsub) + raise _SuspendScript + + async def _async_wait_template_step(self): + """Handle a wait template.""" + + @callback + def async_script_wait(entity_id, from_s, to_s): + """Handle script after template condition is true.""" + self._async_remove_listener() + self._hass.async_create_task(self._async_run(False)) @callback def async_script_timeout(now): @@ -426,26 +547,197 @@ def async_script_timeout(now): # Check if we want to continue to execute # the script after the timeout - if continue_on_timeout: - self.hass.async_create_task(self.async_run(variables, context)) + if self._action.get(CONF_CONTINUE, True): + self._hass.async_create_task(self._async_run(False)) else: - self._log("Timeout reached, abort script.") - self.async_stop() + self._log(_TIMEOUT_MSG) + self._hass.async_create_task(self.async_stop()) - unsub = async_track_point_in_utc_time( - self.hass, async_script_timeout, date_util.utcnow() + timeout - ) - self._async_listener.append(unsub) + unsub_wait = self._prep_wait_template_step(async_script_wait) + if not unsub_wait: + return + self._async_listener.append(unsub_wait) + + if CONF_TIMEOUT in self._action: + unsub = async_track_point_in_utc_time( + self._hass, async_script_timeout, utcnow() + self._action[CONF_TIMEOUT] + ) + self._async_listener.append(unsub) + + raise _SuspendScript def _async_remove_listener(self): - """Remove point in time listener, if any.""" + """Remove listeners, if any.""" for unsub in self._async_listener: unsub() self._async_listener.clear() - def _log(self, msg): - """Logger helper.""" - if self.name is not None: - msg = f"Script {self.name}: {msg}" - _LOGGER.info(msg) +class Script: + """Representation of a script.""" + + def __init__( + self, + hass: HomeAssistant, + sequence: Sequence[Dict[str, Any]], + name: Optional[str] = None, + change_listener: Optional[Callable[..., Any]] = None, + if_running: Optional[str] = None, + run_mode: Optional[str] = None, + logger: Optional[logging.Logger] = None, + log_exceptions: bool = True, + ) -> None: + """Initialize the script.""" + self._logger = logger or logging.getLogger(__name__) + self._hass = hass + self.sequence = sequence + template.attach(hass, self.sequence) + self.name = name + self._change_listener = change_listener + self.last_action = None + self.last_triggered: Optional[datetime] = None + self.can_cancel = any( + CONF_DELAY in action or CONF_WAIT_TEMPLATE in action + for action in self.sequence + ) + if not if_running and not run_mode: + self._if_running = IF_RUNNING_PARALLEL + self._run_mode = RUN_MODE_LEGACY + elif if_running and run_mode == RUN_MODE_LEGACY: + self._raise('Cannot use if_running if run_mode is "legacy"') + else: + self._if_running = if_running or IF_RUNNING_CHOICES[0] + self._run_mode = run_mode or RUN_MODE_CHOICES[0] + self._runs: List[_ScriptRunBase] = [] + self._log_exceptions = log_exceptions + self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {} + self._referenced_entities: Optional[Set[str]] = None + self._referenced_devices: Optional[Set[str]] = None + + def _changed(self): + if self._change_listener: + self._hass.async_add_job(self._change_listener) + + @property + def is_running(self) -> bool: + """Return true if script is on.""" + return len(self._runs) > 0 + + @property + def referenced_devices(self): + """Return a set of referenced devices.""" + if self._referenced_devices is not None: + return self._referenced_devices + + referenced = set() + + for step in self.sequence: + action = _determine_action(step) + + if action == ACTION_CHECK_CONDITION: + referenced |= condition.async_extract_devices(step) + + elif action == ACTION_DEVICE_AUTOMATION: + referenced.add(step[CONF_DEVICE_ID]) + + self._referenced_devices = referenced + return referenced + + @property + def referenced_entities(self): + """Return a set of referenced entities.""" + if self._referenced_entities is not None: + return self._referenced_entities + + referenced = set() + + for step in self.sequence: + action = _determine_action(step) + + if action == ACTION_CALL_SERVICE: + data = step.get(service.CONF_SERVICE_DATA) + if not data: + continue + + entity_ids = data.get(ATTR_ENTITY_ID) + + if entity_ids is None: + continue + + if isinstance(entity_ids, str): + entity_ids = [entity_ids] + + for entity_id in entity_ids: + referenced.add(entity_id) + + elif action == ACTION_CHECK_CONDITION: + referenced |= condition.async_extract_entities(step) + + elif action == ACTION_ACTIVATE_SCENE: + referenced.add(step[CONF_SCENE]) + + self._referenced_entities = referenced + return referenced + + def run(self, variables=None, context=None): + """Run script.""" + asyncio.run_coroutine_threadsafe( + self.async_run(variables, context), self._hass.loop + ).result() + + async def async_run( + self, variables: Optional[Sequence] = None, context: Optional[Context] = None + ) -> None: + """Run script.""" + if self.is_running: + if self._if_running == IF_RUNNING_IGNORE: + self._log("Skipping script") + return + + if self._if_running == IF_RUNNING_ERROR: + self._raise("Already running") + if self._if_running == IF_RUNNING_RESTART: + self._log("Restarting script") + await self.async_stop() + + self.last_triggered = utcnow() + if self._run_mode == RUN_MODE_LEGACY: + if self._runs: + shared = cast(Optional[_LegacyScriptRun], self._runs[0]) + else: + shared = None + run: _ScriptRunBase = _LegacyScriptRun( + self._hass, self, variables, context, self._log_exceptions, shared + ) + else: + if self._run_mode == RUN_MODE_BACKGROUND: + run = _BackgroundScriptRun( + self._hass, self, variables, context, self._log_exceptions + ) + else: + run = _BlockingScriptRun( + self._hass, self, variables, context, self._log_exceptions + ) + self._runs.append(run) + await run.async_run() + + async def async_stop(self) -> None: + """Stop running script.""" + if not self.is_running: + return + await asyncio.shield(asyncio.gather(*(run.async_stop() for run in self._runs))) + self._changed() + + def _log(self, msg, *args, level=logging.INFO): + if self.name: + msg = f"{self.name}: {msg}" + if level == _LOG_EXCEPTION: + self._logger.exception(msg, *args) + else: + self._logger.log(level, msg, *args) + + def _raise(self, msg, *args, exception=None): + if not exception: + exception = exceptions.HomeAssistantError + self._log(msg, *args, level=logging.ERROR) + raise exception(msg % args) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index d621d4e6242448..9085c929651dc1 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,13 +1,18 @@ """Service calling related helpers.""" import asyncio -from functools import wraps +from functools import partial, wraps import logging from typing import Callable import voluptuous as vol from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL -from homeassistant.const import ATTR_AREA_ID, ATTR_ENTITY_ID, ENTITY_MATCH_ALL +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, +) import homeassistant.core as ha from homeassistant.exceptions import ( HomeAssistantError, @@ -121,11 +126,25 @@ async def async_extract_entities(hass, entities, service_call, expand_group=True entity_ids = await async_extract_entity_ids(hass, service_call, expand_group) - return [ - entity - for entity in entities - if entity.available and entity.entity_id in entity_ids - ] + found = [] + + for entity in entities: + if entity.entity_id not in entity_ids: + continue + + entity_ids.remove(entity.entity_id) + + if not entity.available: + continue + + found.append(entity) + + if entity_ids: + _LOGGER.warning( + "Unable to find referenced entities %s", ", ".join(sorted(entity_ids)) + ) + + return found @bind_hass @@ -137,12 +156,15 @@ async def async_extract_entity_ids(hass, service_call, expand_group=True): entity_ids = service_call.data.get(ATTR_ENTITY_ID) area_ids = service_call.data.get(ATTR_AREA_ID) - if not entity_ids and not area_ids: - return [] - extracted = set() - if entity_ids: + if entity_ids in (None, ENTITY_MATCH_NONE) and area_ids in ( + None, + ENTITY_MATCH_NONE, + ): + return extracted + + if entity_ids and entity_ids != ENTITY_MATCH_NONE: # Entity ID attr can be a list or a string if isinstance(entity_ids, str): entity_ids = [entity_ids] @@ -152,7 +174,7 @@ async def async_extract_entity_ids(hass, service_call, expand_group=True): extracted.update(entity_ids) - if area_ids: + if area_ids and area_ids != ENTITY_MATCH_NONE: if isinstance(area_ids, str): area_ids = [area_ids] @@ -283,23 +305,26 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non # If the service function is a string, we'll pass it the service call data if isinstance(func, str): - data = {key: val for key, val in call.data.items() if key != ATTR_ENTITY_ID} + data = { + key: val + for key, val in call.data.items() + if key not in cv.ENTITY_SERVICE_FIELDS + } # If the service function is not a string, we pass the service call else: data = call # Check the permissions - # A list with for each platform in platforms a list of entities to call - # the service on. - platforms_entities = [] + # A list with entities to call the service on. + entity_candidates = [] if entity_perms is None: for platform in platforms: if target_all_entities: - platforms_entities.append(list(platform.entities.values())) + entity_candidates.extend(platform.entities.values()) else: - platforms_entities.append( + entity_candidates.extend( [ entity for entity in platform.entities.values() @@ -311,7 +336,7 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non # If we target all entities, we will select all entities the user # is allowed to control. for platform in platforms: - platforms_entities.append( + entity_candidates.extend( [ entity for entity in platform.entities.values() @@ -323,6 +348,7 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non for platform in platforms: platform_entities = [] for entity in platform.entities.values(): + if entity.entity_id not in entity_ids: continue @@ -335,29 +361,20 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non platform_entities.append(entity) - platforms_entities.append(platform_entities) - - tasks = [ - _handle_service_platform_call( - func, data, entities, call.context, required_features - ) - for platform, entities in zip(platforms, platforms_entities) - ] + entity_candidates.extend(platform_entities) - if tasks: - done, pending = await asyncio.wait(tasks) - assert not pending - for future in done: - future.result() # pop exception if have + if not target_all_entities: + for entity in entity_candidates: + entity_ids.remove(entity.entity_id) + if entity_ids: + _LOGGER.warning( + "Unable to find referenced entities %s", ", ".join(sorted(entity_ids)) + ) -async def _handle_service_platform_call( - func, data, entities, context, required_features -): - """Handle a function call.""" - tasks = [] + entities = [] - for entity in entities: + for entity in entity_candidates: if not entity.available: continue @@ -367,15 +384,33 @@ async def _handle_service_platform_call( ): continue - entity.async_set_context(context) + entities.append(entity) - if isinstance(func, str): - await getattr(entity, func)(**data) - else: - await func(entity, data) + if not entities: + return + + done, pending = await asyncio.wait( + [ + entity.async_request_call( + _handle_entity_call(hass, entity, func, data, call.context) + ) + for entity in entities + ] + ) + assert not pending + for future in done: + future.result() # pop exception if have + + tasks = [] + + for entity in entities: + if not entity.should_poll: + continue - if entity.should_poll: - tasks.append(entity.async_update_ha_state(True)) + # Context expires if the turn on commands took a long time. + # Set context again so it's there when we update + entity.async_set_context(call.context) + tasks.append(entity.async_update_ha_state(True)) if tasks: done, pending = await asyncio.wait(tasks) @@ -384,6 +419,28 @@ async def _handle_service_platform_call( future.result() # pop exception if have +async def _handle_entity_call(hass, entity, func, data, context): + """Handle calling service method.""" + entity.async_set_context(context) + + if isinstance(func, str): + result = hass.async_add_job(partial(getattr(entity, func), **data)) + else: + result = hass.async_add_job(func, entity, data) + + # Guard because callback functions do not return a task when passed to async_add_job. + if result is not None: + await result + + if asyncio.iscoroutine(result): + _LOGGER.error( + "Service %s for %s incorrectly returns a coroutine object. Await result instead in service handler. Report bug to integration author.", + func, + entity.entity_id, + ) + await result + + @bind_hass @ha.callback def async_register_admin_service( @@ -404,7 +461,9 @@ async def admin_handler(call): if not user.is_admin: raise Unauthorized(context=call.context) - await hass.async_add_job(service_func, call) + result = hass.async_add_job(service_func, call) + if result is not None: + await result hass.services.async_register(domain, service, admin_handler, schema) @@ -425,6 +484,7 @@ async def check_permissions(call): return await service_handler(call) user = await hass.auth.async_get_user(call.context.user_id) + if user is None: raise UnknownUser( context=call.context, @@ -433,14 +493,12 @@ async def check_permissions(call): ) reg = await hass.helpers.entity_registry.async_get_registry() - entities = [ - entity.entity_id - for entity in reg.entities.values() - if entity.platform == domain - ] - - for entity_id in entities: - if user.permissions.check_entity(entity_id, POLICY_CONTROL): + + for entity in reg.entities.values(): + if entity.platform != domain: + continue + + if user.permissions.check_entity(entity.entity_id, POLICY_CONTROL): return await service_handler(call) raise Unauthorized( diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8565315f87f5c9..e7f89b482e2f4f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -378,7 +378,7 @@ def __getattr__(self, name): raise TemplateError(f"Invalid entity ID '{entity_id}'") return _get_state(self._hass, entity_id) - def _collect_domain(self): + def _collect_domain(self) -> None: entity_collect = self._hass.data.get(_RENDER_INFO) if entity_collect is not None: # pylint: disable=protected-access @@ -398,12 +398,12 @@ def __iter__(self): ) ) - def __len__(self): + def __len__(self) -> int: """Return number of states.""" self._collect_domain() return len(self._hass.states.async_entity_ids(self._domain)) - def __repr__(self): + def __repr__(self) -> str: """Representation of Domain States.""" return f"