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""
@@ -426,7 +426,7 @@ def _access_state(self):
return state
@property
- def state_with_unit(self):
+ def state_with_unit(self) -> str:
"""Return the state concatenated with the unit if available."""
state = object.__getattribute__(self, "_access_state")()
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@@ -447,7 +447,7 @@ def __getattribute__(self, name):
state = object.__getattribute__(self, "_access_state")()
return getattr(state, name)
- def __repr__(self):
+ def __repr__(self) -> str:
"""Representation of Template State."""
state = object.__getattribute__(self, "_access_state")()
rep = state.__repr__()
@@ -469,7 +469,7 @@ def _wrap_state(hass, state):
def _get_state(hass, entity_id):
state = hass.states.get(entity_id)
if state is None:
- # Only need to collect if none, if not none collect first actuall
+ # Only need to collect if none, if not none collect first actual
# access to the state properties in the state wrapper.
_collect_state(hass, entity_id)
return None
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
new file mode 100644
index 00000000000000..fe877fe9bb8797
--- /dev/null
+++ b/homeassistant/helpers/update_coordinator.py
@@ -0,0 +1,142 @@
+"""Helpers to help coordinate updates."""
+import asyncio
+from datetime import datetime, timedelta
+import logging
+from time import monotonic
+from typing import Any, Awaitable, Callable, List, Optional
+
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+from .debounce import Debouncer
+
+REQUEST_REFRESH_DEFAULT_COOLDOWN = 10
+REQUEST_REFRESH_DEFAULT_IMMEDIATE = True
+
+
+class UpdateFailed(Exception):
+ """Raised when an update has failed."""
+
+
+class DataUpdateCoordinator:
+ """Class to manage fetching data from single endpoint."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ logger: logging.Logger,
+ *,
+ name: str,
+ update_method: Callable[[], Awaitable],
+ update_interval: timedelta,
+ request_refresh_debouncer: Optional[Debouncer] = None,
+ ):
+ """Initialize global data updater."""
+ self.hass = hass
+ self.logger = logger
+ self.name = name
+ self.update_method = update_method
+ self.update_interval = update_interval
+
+ self.data: Optional[Any] = None
+
+ self._listeners: List[CALLBACK_TYPE] = []
+ self._unsub_refresh: Optional[CALLBACK_TYPE] = None
+ self._request_refresh_task: Optional[asyncio.TimerHandle] = None
+ self.last_update_success = True
+
+ if request_refresh_debouncer is None:
+ request_refresh_debouncer = Debouncer(
+ hass,
+ logger,
+ cooldown=REQUEST_REFRESH_DEFAULT_COOLDOWN,
+ immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE,
+ function=self.async_refresh,
+ )
+ else:
+ request_refresh_debouncer.function = self.async_refresh
+
+ self._debounced_refresh = request_refresh_debouncer
+
+ @callback
+ def async_add_listener(self, update_callback: CALLBACK_TYPE) -> None:
+ """Listen for data updates."""
+ schedule_refresh = not self._listeners
+
+ self._listeners.append(update_callback)
+
+ # This is the first listener, set up interval.
+ if schedule_refresh:
+ self._schedule_refresh()
+
+ @callback
+ def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None:
+ """Remove data update."""
+ self._listeners.remove(update_callback)
+
+ if not self._listeners and self._unsub_refresh:
+ self._unsub_refresh()
+ self._unsub_refresh = None
+
+ @callback
+ def _schedule_refresh(self) -> None:
+ """Schedule a refresh."""
+ if self._unsub_refresh:
+ self._unsub_refresh()
+ self._unsub_refresh = None
+
+ self._unsub_refresh = async_track_point_in_utc_time(
+ self.hass, self._handle_refresh_interval, utcnow() + self.update_interval
+ )
+
+ async def _handle_refresh_interval(self, _now: datetime) -> None:
+ """Handle a refresh interval occurrence."""
+ self._unsub_refresh = None
+ await self.async_refresh()
+
+ async def async_request_refresh(self) -> None:
+ """Request a refresh.
+
+ Refresh will wait a bit to see if it can batch them.
+ """
+ await self._debounced_refresh.async_call()
+
+ async def async_refresh(self) -> None:
+ """Update data."""
+ if self._unsub_refresh:
+ self._unsub_refresh()
+ self._unsub_refresh = None
+
+ self._debounced_refresh.async_cancel()
+
+ try:
+ start = monotonic()
+ self.data = await self.update_method()
+
+ except UpdateFailed as err:
+ if self.last_update_success:
+ self.logger.error("Error fetching %s data: %s", self.name, err)
+ self.last_update_success = False
+
+ except Exception as err: # pylint: disable=broad-except
+ self.last_update_success = False
+ self.logger.exception(
+ "Unexpected error fetching %s data: %s", self.name, err
+ )
+
+ else:
+ if not self.last_update_success:
+ self.last_update_success = True
+ self.logger.info("Fetching %s data recovered", self.name)
+
+ finally:
+ self.logger.debug(
+ "Finished fetching %s data in %.3f seconds",
+ self.name,
+ monotonic() - start,
+ )
+ self._schedule_refresh()
+
+ for update_callback in self._listeners:
+ update_callback()
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 7a15410f96a15e..4c46d4377606f4 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -41,7 +41,6 @@
DATA_CUSTOM_COMPONENTS = "custom_components"
PACKAGE_CUSTOM_COMPONENTS = "custom_components"
PACKAGE_BUILTIN = "homeassistant.components"
-LOOKUP_PATHS = [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN]
CUSTOM_WARNING = (
"You are using a custom integration for %s which has not "
"been tested by Home Assistant. This component might "
@@ -67,6 +66,9 @@ async def _async_get_custom_components(
hass: "HomeAssistant",
) -> Dict[str, "Integration"]:
"""Return list of custom integrations."""
+ if hass.config.safe_mode:
+ return {}
+
try:
import custom_components
except ImportError:
@@ -178,7 +180,7 @@ def resolve_legacy(
Will create a stub manifest.
"""
- comp = _load_file(hass, domain, LOOKUP_PATHS)
+ comp = _load_file(hass, domain, _lookup_path(hass))
if comp is None:
return None
@@ -244,6 +246,16 @@ def quality_scale(self) -> Optional[str]:
"""Return Integration Quality Scale."""
return cast(str, self.manifest.get("quality_scale"))
+ @property
+ def logo(self) -> Optional[str]:
+ """Return Integration Logo."""
+ return cast(str, self.manifest.get("logo"))
+
+ @property
+ def icon(self) -> Optional[str]:
+ """Return Integration Icon."""
+ return cast(str, self.manifest.get("icon"))
+
@property
def is_built_in(self) -> bool:
"""Test if package is a built-in integration."""
@@ -454,7 +466,7 @@ def __getattr__(self, comp_name: str) -> ModuleWrapper:
component: Optional[ModuleType] = integration.get_component()
else:
# Fallback to importing old-school
- component = _load_file(self._hass, comp_name, LOOKUP_PATHS)
+ component = _load_file(self._hass, comp_name, _lookup_path(self._hass))
if component is None:
raise ImportError(f"Unable to load {comp_name}")
@@ -531,8 +543,15 @@ def _async_mount_config_dir(hass: "HomeAssistant") -> bool:
Async friendly but not a coroutine.
"""
if hass.config.config_dir is None:
- _LOGGER.error("Can't load integrations - config dir is not set")
+ _LOGGER.error("Can't load integrations - configuration directory is not set")
return False
if hass.config.config_dir not in sys.path:
sys.path.insert(0, hass.config.config_dir)
return True
+
+
+def _lookup_path(hass: "HomeAssistant") -> List[str]:
+ """Return the lookup paths for legacy lookups."""
+ if hass.config.safe_mode:
+ return [PACKAGE_BUILTIN]
+ return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN]
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 4dc67061954150..746b4be0e6166d 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -7,19 +7,20 @@ async_timeout==3.0.1
attrs==19.3.0
bcrypt==3.1.7
certifi>=2019.11.28
+ciso8601==2.1.3
cryptography==2.8
defusedxml==0.6.0
distro==1.4.0
hass-nabucasa==0.31
-home-assistant-frontend==20200108.2
-importlib-metadata==1.4.0
+home-assistant-frontend==20200220.3
+importlib-metadata==1.5.0
jinja2>=2.10.3
netdisco==2.6.0
pip>=8.0.3
python-slugify==4.0.0
pytz>=2019.03
pyyaml==5.3
-requests==2.22.0
+requests==2.23.0
ruamel.yaml==0.15.100
sqlalchemy==1.3.13
voluptuous-serialize==2.3.0
@@ -28,6 +29,9 @@ zeroconf==0.24.4
pycryptodome>=3.6.6
+# Constrain urllib3 to ensure we deal with CVE-2019-11236 & CVE-2019-11324
+urllib3>=1.24.3
+
# Not needed for our supported Python versions
enum34==1000000000.0.0
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
index 58125bc4829a68..4d7df6d72481cc 100644
--- a/homeassistant/scripts/benchmark/__init__.py
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -185,3 +185,12 @@ def yield_events(event):
list(logbook.humanify(None, yield_events(event)))
return timer() - start
+
+
+@benchmark
+async def valid_entity_id(hass):
+ """Run valid entity ID a million times."""
+ start = timer()
+ for _ in range(10 ** 6):
+ core.valid_entity_id("light.kitchen")
+ return timer() - start
diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py
index f39fa5f1e5593b..07b6a8d48f8ee9 100644
--- a/homeassistant/util/__init__.py
+++ b/homeassistant/util/__init__.py
@@ -44,9 +44,9 @@ def sanitize_path(path: str) -> str:
return RE_SANITIZE_PATH.sub("", path)
-def slugify(text: str) -> str:
+def slugify(text: str, *, separator: str = "_") -> str:
"""Slugify a given text."""
- return unicode_slug.slugify(text, separator="_") # type: ignore
+ return unicode_slug.slugify(text, separator=separator) # type: ignore
def repr_helper(inp: Any) -> str:
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index dde18688d9f1d0..084888c188cbc4 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -3,6 +3,7 @@
import re
from typing import Any, Dict, List, Optional, Tuple, Union, cast
+import ciso8601
import pytz
import pytz.exceptions as pytzexceptions
import pytz.tzinfo as pytzinfo
@@ -122,6 +123,10 @@ def parse_datetime(dt_str: str) -> Optional[dt.datetime]:
Raises ValueError if the input is well formatted but not a valid datetime.
Returns None if the input isn't well formatted.
"""
+ try:
+ return ciso8601.parse_datetime(dt_str)
+ except (ValueError, IndexError):
+ pass
match = DATETIME_RE.match(dt_str)
if not match:
return None
diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py
index e975c87867210a..94dc816e03c72f 100644
--- a/homeassistant/util/json.py
+++ b/homeassistant/util/json.py
@@ -1,9 +1,10 @@
"""JSON utility functions."""
+from collections import deque
import json
import logging
import os
import tempfile
-from typing import Dict, List, Optional, Type, Union
+from typing import Any, Dict, List, Optional, Type, Union
from homeassistant.exceptions import HomeAssistantError
@@ -51,10 +52,17 @@ def save_json(
Returns True on success.
"""
+ try:
+ json_data = json.dumps(data, sort_keys=True, indent=4, cls=encoder)
+ except TypeError:
+ # pylint: disable=no-member
+ msg = f"Failed to serialize to JSON: {filename}. Bad data found at {', '.join(find_paths_unserializable_data(data))}"
+ _LOGGER.error(msg)
+ raise SerializationError(msg)
+
tmp_filename = ""
tmp_path = os.path.split(filename)[0]
try:
- json_data = json.dumps(data, sort_keys=True, indent=4, cls=encoder)
# Modern versions of Python tempfile create this file with mode 0o600
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=tmp_path, delete=False
@@ -64,9 +72,6 @@ def save_json(
if not private:
os.chmod(tmp_filename, 0o644)
os.replace(tmp_filename, filename)
- except TypeError as error:
- _LOGGER.exception("Failed to serialize to JSON: %s", filename)
- raise SerializationError(error)
except OSError as error:
_LOGGER.exception("Saving JSON file failed: %s", filename)
raise WriteError(error)
@@ -78,3 +83,39 @@ def save_json(
# If we are cleaning up then something else went wrong, so
# we should suppress likely follow-on errors in the cleanup
_LOGGER.error("JSON replacement cleanup failed: %s", err)
+
+
+def find_paths_unserializable_data(bad_data: Any) -> List[str]:
+ """Find the paths to unserializable data.
+
+ This method is slow! Only use for error handling.
+ """
+ to_process = deque([(bad_data, "$")])
+ invalid = []
+
+ while to_process:
+ obj, obj_path = to_process.popleft()
+
+ try:
+ json.dumps(obj)
+ continue
+ except TypeError:
+ pass
+
+ if isinstance(obj, dict):
+ for key, value in obj.items():
+ try:
+ # Is key valid?
+ json.dumps({key: None})
+ except TypeError:
+ invalid.append(f"{obj_path}")
+ else:
+ # Process value
+ to_process.append((value, f"{obj_path}.{key}"))
+ elif isinstance(obj, list):
+ for idx, value in enumerate(obj):
+ to_process.append((value, f"{obj_path}[{idx}]"))
+ else:
+ invalid.append(obj_path)
+
+ return invalid
diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py
index de04f23d9dd9f2..1a46a34c1a899f 100644
--- a/homeassistant/util/logging.py
+++ b/homeassistant/util/logging.py
@@ -80,16 +80,19 @@ def __repr__(self) -> str:
def _process(self) -> None:
"""Process log in a thread."""
- while True:
- record = asyncio.run_coroutine_threadsafe(
- self._queue.get(), self.loop
- ).result()
-
- if record is None:
- self.handler.close()
- return
-
- self.handler.emit(record)
+ try:
+ while True:
+ record = asyncio.run_coroutine_threadsafe(
+ self._queue.get(), self.loop
+ ).result()
+
+ if record is None:
+ self.handler.close()
+ return
+
+ self.handler.emit(record)
+ except asyncio.CancelledError:
+ self.handler.close()
def createLock(self) -> None:
"""Ignore lock stuff."""
diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py
index 24cf83092281c1..9a5ae82d4a2700 100644
--- a/homeassistant/util/package.py
+++ b/homeassistant/util/package.py
@@ -15,7 +15,7 @@
def is_virtual_env() -> bool:
- """Return if we run in a virtual environtment."""
+ """Return if we run in a virtual environment."""
# Check supports venv && virtualenv
return getattr(sys, "base_prefix", sys.prefix) != sys.prefix or hasattr(
sys, "real_prefix"
diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py
index 6b921ade961f0f..ba4d1e77576310 100644
--- a/homeassistant/util/yaml/loader.py
+++ b/homeassistant/util/yaml/loader.py
@@ -41,7 +41,6 @@ def clear_secret_cache() -> None:
__SECRET_CACHE.clear()
-# pylint: disable=too-many-ancestors
class SafeLineLoader(yaml.SafeLoader):
"""Loader class that keeps track of line numbers."""
diff --git a/pylintrc b/pylintrc
index 0ffbb138f9e976..125062c8cfeb53 100644
--- a/pylintrc
+++ b/pylintrc
@@ -3,7 +3,9 @@ ignore=tests
# Use a conservative default here; 2 should speed up most setups and not hurt
# any too bad. Override on command line as appropriate.
jobs=2
+load-plugins=pylint_strict_informational
persistent=no
+extension-pkg-whitelist=ciso8601
[BASIC]
good-names=id,i,j,k,ex,Run,_,fp
diff --git a/requirements_all.txt b/requirements_all.txt
index 643972487e8b92..9889faf292ae37 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -5,7 +5,8 @@ async_timeout==3.0.1
attrs==19.3.0
bcrypt==3.1.7
certifi>=2019.11.28
-importlib-metadata==1.4.0
+ciso8601==2.1.3
+importlib-metadata==1.5.0
jinja2>=2.10.3
PyJWT==1.7.1
cryptography==2.8
@@ -13,7 +14,7 @@ pip>=8.0.3
python-slugify==4.0.0
pytz>=2019.03
pyyaml==5.3
-requests==2.22.0
+requests==2.23.0
ruamel.yaml==0.15.100
voluptuous==0.11.7
voluptuous-serialize==2.3.0
@@ -34,7 +35,7 @@ Adafruit-SHT31==1.0.2
# Adafruit_BBIO==1.1.1
# homeassistant.components.homekit
-HAP-python==2.6.0
+HAP-python==2.7.0
# homeassistant.components.mastodon
Mastodon.py==1.5.0
@@ -77,7 +78,7 @@ PySocks==1.7.1
PyTransportNSW==0.1.1
# homeassistant.components.vicare
-PyViCare==0.1.2
+PyViCare==0.1.7
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.12.4
@@ -105,43 +106,46 @@ WazeRouteCalculator==0.12
YesssSMS==0.4.1
# homeassistant.components.abode
-abodepy==0.16.7
+abodepy==0.17.0
# homeassistant.components.mcp23017
-adafruit-blinka==1.2.1
+adafruit-blinka==3.9.0
# homeassistant.components.mcp23017
-adafruit-circuitpython-mcp230xx==1.1.2
+adafruit-circuitpython-mcp230xx==2.2.2
# homeassistant.components.androidtv
adb-shell==0.1.1
# homeassistant.components.adguard
-adguardhome==0.4.0
+adguardhome==0.4.1
# homeassistant.components.frontier_silicon
afsapi==0.0.4
# homeassistant.components.geonetnz_quakes
-aio_geojson_geonetnz_quakes==0.11
+aio_geojson_geonetnz_quakes==0.12
# homeassistant.components.geonetnz_volcano
aio_geojson_geonetnz_volcano==0.5
# homeassistant.components.nsw_rural_fire_service_feed
-aio_geojson_nsw_rfs_incidents==0.1
+aio_geojson_nsw_rfs_incidents==0.3
+
+# homeassistant.components.gdacs
+aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
-aioambient==1.0.2
+aioambient==1.0.4
# homeassistant.components.asuswrt
-aioasuswrt==1.1.22
+aioasuswrt==1.2.2
# homeassistant.components.automatic
aioautomatic==0.6.5
# homeassistant.components.aws
-aiobotocore==0.10.4
+aiobotocore==0.11.1
# homeassistant.components.dnsip
aiodns==2.0.0
@@ -158,12 +162,15 @@ aioftp==0.12.0
# homeassistant.components.harmony
aioharmony==0.1.13
+# homeassistant.components.homekit_controller
+aiohomekit[IP]==0.2.11
+
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
# homeassistant.components.hue
-aiohue==1.10.1
+aiohue==2.0.0
# homeassistant.components.imap
aioimaplib==0.7.15
@@ -172,7 +179,7 @@ aioimaplib==0.7.15
aiokafka==0.5.1
# homeassistant.components.kef
-aiokef==0.2.6
+aiokef==0.2.7
# homeassistant.components.lifx
aiolifx==0.6.7
@@ -190,13 +197,13 @@ aionotion==1.1.0
aiopvapi==1.6.14
# homeassistant.components.webostv
-aiopylgtv==0.3.0
+aiopylgtv==0.3.3
# homeassistant.components.switcher_kis
aioswitcher==2019.4.26
# homeassistant.components.unifi
-aiounifi==11
+aiounifi==13
# homeassistant.components.wwlln
aiowwlln==2.0.2
@@ -208,19 +215,19 @@ airly==0.0.2
aladdin_connect==0.3
# homeassistant.components.alarmdecoder
-alarmdecoder==1.13.9
+alarmdecoder==1.13.2
# homeassistant.components.alpha_vantage
-alpha_vantage==2.1.2
+alpha_vantage==2.1.3
# homeassistant.components.ambiclimate
ambiclimate==0.2.1
# homeassistant.components.amcrest
-amcrest==1.5.3
+amcrest==1.5.6
# homeassistant.components.androidtv
-androidtv==0.0.38
+androidtv==0.0.39
# homeassistant.components.anel_pwrctrl
anel_pwrctrl-homeassistant==0.0.1.dev2
@@ -235,7 +242,7 @@ apcaccess==0.0.13
apns2==0.3.0
# homeassistant.components.apprise
-apprise==0.8.3
+apprise==0.8.4
# homeassistant.components.aprs
aprslib==0.6.46
@@ -299,10 +306,10 @@ beautifulsoup4==4.8.2
beewi_smartclim==0.0.7
# homeassistant.components.zha
-bellows-homeassistant==0.12.0
+bellows-homeassistant==0.13.2
# homeassistant.components.bmw_connected_drive
-bimmer_connected==0.6.2
+bimmer_connected==0.7.1
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
@@ -334,13 +341,13 @@ bomradarloop==0.1.3
boto3==1.9.252
# homeassistant.components.braviatv
-braviarc-homeassistant==0.3.7.dev0
+bravia-tv==1.0
# homeassistant.components.broadlink
broadlink==0.12.0
# homeassistant.components.brother
-brother==0.1.4
+brother==0.1.6
# homeassistant.components.brottsplatskartan
brottsplatskartan==0.0.1
@@ -423,16 +430,16 @@ defusedxml==0.6.0
deluge-client==1.7.1
# homeassistant.components.denonavr
-denonavr==0.7.11
+denonavr==0.7.12
# homeassistant.components.directv
-directpy==0.5
+directpy==0.6
# homeassistant.components.discogs
discogs_client==2.2.2
# homeassistant.components.discord
-discord.py==1.2.5
+discord.py==1.3.1
# homeassistant.components.updater
distro==1.4.0
@@ -447,11 +454,14 @@ doorbirdpy==2.0.8
dovado==0.4.1
# homeassistant.components.dsmr
-dsmr_parser==0.12
+dsmr_parser==0.18
# homeassistant.components.dweet
dweepy==0.3.0
+# homeassistant.components.dynalite
+dynalite_devices==0.1.22
+
# homeassistant.components.rainforest_eagle
eagle200_reader==0.2.1
@@ -483,7 +493,7 @@ enocean==0.50
enturclient==0.2.1
# homeassistant.components.environment_canada
-env_canada==0.0.34
+env_canada==0.0.35
# homeassistant.components.envirophat
# envirophat==0.0.6
@@ -548,12 +558,12 @@ freesms==0.1.2
# homeassistant.components.fritzbox_netmonitor
fritzconnection==1.2.0
-# homeassistant.components.fritzdect
-fritzhome==1.0.4
-
# homeassistant.components.google_translate
gTTS-token==1.1.3
+# homeassistant.components.garmin_connect
+garminconnect==0.1.8
+
# homeassistant.components.gearbest
gearbest_parser==1.0.7
@@ -582,6 +592,7 @@ georss_qld_bushfire_alert_client==0.3
# homeassistant.components.braviatv
# homeassistant.components.huawei_lte
# homeassistant.components.kef
+# homeassistant.components.minecraft_server
# homeassistant.components.nmap_tracker
getmac==0.8.1
@@ -619,7 +630,7 @@ gpiozero==1.5.1
gps3==0.33.3
# homeassistant.components.greeneye_monitor
-greeneye_monitor==1.0.1
+greeneye_monitor==2.0
# homeassistant.components.greenwave
greenwavereality==0.5.1
@@ -637,7 +648,7 @@ ha-ffmpeg==2.0
ha-philipsjs==0.0.8
# homeassistant.components.plugwise
-haanna==0.13.5
+haanna==0.14.3
# homeassistant.components.habitica
habitipy==0.2.0
@@ -652,7 +663,7 @@ hass-nabucasa==0.31
hbmqtt==0.9.5
# homeassistant.components.jewish_calendar
-hdate==0.9.3
+hdate==0.9.5
# homeassistant.components.heatmiser
heatmiserV3==1.1.18
@@ -673,19 +684,16 @@ hlk-sw16==0.0.8
hole==0.5.0
# homeassistant.components.workday
-holidays==0.9.12
+holidays==0.10.1
# homeassistant.components.frontend
-home-assistant-frontend==20200108.2
+home-assistant-frontend==20200220.3
# homeassistant.components.zwave
-homeassistant-pyozw==0.1.7
-
-# homeassistant.components.homekit_controller
-homekit[IP]==0.15.0
+homeassistant-pyozw==0.1.8
# homeassistant.components.homematicip_cloud
-homematicip==0.10.15
+homematicip==0.10.17
# homeassistant.components.horizon
horimote==0.4.1
@@ -695,7 +703,7 @@ horimote==0.4.1
httplib2==0.10.3
# homeassistant.components.huawei_lte
-huawei-lte-api==1.4.6
+huawei-lte-api==1.4.7
# homeassistant.components.hydrawise
hydrawiser==0.1.1
@@ -706,7 +714,7 @@ hydrawiser==0.1.1
# i2csense==0.0.4
# homeassistant.components.iaqualink
-iaqualink==0.3.0
+iaqualink==0.3.1
# homeassistant.components.watson_tts
ibm-watson==4.0.1
@@ -718,7 +726,7 @@ ibmiotf==0.3.4
iglo==1.2.7
# homeassistant.components.ihc
-ihcsdk==2.4.0
+ihcsdk==2.6.0
# homeassistant.components.incomfort
incomfort-client==0.4.0
@@ -727,7 +735,7 @@ incomfort-client==0.4.0
influxdb==5.2.3
# homeassistant.components.insteon
-insteonplm==0.16.5
+insteonplm==0.16.7
# homeassistant.components.iperf3
iperf3==0.1.11
@@ -735,6 +743,7 @@ iperf3==0.1.11
# homeassistant.components.route53
ipify==1.0.0
+# homeassistant.components.rest
# homeassistant.components.verisure
jsonpath==0.82
@@ -760,13 +769,13 @@ keyrings.alt==3.4.0
kiwiki-client==0.1.1
# homeassistant.components.konnected
-konnected==0.1.5
+konnected==1.1.0
# homeassistant.components.eufy
lakeside==0.12
# homeassistant.components.dyson
-libpurecool==0.6.0
+libpurecool==0.6.1
# homeassistant.components.foscam
libpyfoscam==1.0
@@ -798,9 +807,6 @@ limitlessled==1.1.3
# homeassistant.components.linode
linode-api==4.1.9b1
-# homeassistant.components.liveboxplaytv
-liveboxplaytv==2.0.3
-
# homeassistant.components.lametric
lmnotify==0.0.4
@@ -837,6 +843,9 @@ maxcube-api==0.1.0
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
+# homeassistant.components.minecraft_server
+mcstatus==2.3.0
+
# homeassistant.components.message_bird
messagebird==1.2.0
@@ -902,7 +911,7 @@ niko-home-control==0.2.1
niluclient==0.1.2
# homeassistant.components.nederlandse_spoorwegen
-nsapi==3.0.0
+nsapi==3.0.3
# homeassistant.components.nsw_fuel_station
nsw-fuel-api-client==1.0.10
@@ -914,7 +923,7 @@ nuheat==0.3.0
# homeassistant.components.opencv
# homeassistant.components.tensorflow
# homeassistant.components.trend
-numpy==1.17.4
+numpy==1.18.1
# homeassistant.components.oasa_telematics
oasatelematics==0.3
@@ -997,7 +1006,7 @@ pilight==0.1.1
# homeassistant.components.qrcode
# homeassistant.components.seven_segments
# homeassistant.components.tensorflow
-pillow==6.2.1
+pillow==7.0.0
# homeassistant.components.dominos
pizzapi==0.0.3
@@ -1022,7 +1031,7 @@ pmsensor==0.4
pocketcasts==0.1
# homeassistant.components.reddit
-praw==6.5.0
+praw==6.5.1
# homeassistant.components.islamic_prayer_times
prayer_times_calculator==0.0.3
@@ -1040,7 +1049,7 @@ prometheus_client==0.7.1
protobuf==3.6.1
# homeassistant.components.proxmoxve
-proxmoxer==1.0.3
+proxmoxer==1.0.4
# homeassistant.components.systemmonitor
psutil==5.6.7
@@ -1060,11 +1069,14 @@ pushbullet.py==0.11.0
# homeassistant.components.pushetta
pushetta==1.0.15
+# homeassistant.components.pushover
+pushover_complete==1.1.1
+
# homeassistant.components.rpi_gpio_pwm
-pwmled==1.4.1
+pwmled==1.5.0
# homeassistant.components.august
-py-august==0.7.0
+py-august==0.21.0
# homeassistant.components.canary
py-canary==0.5.0
@@ -1098,7 +1110,7 @@ pyRFXtrx==0.25
# pySwitchmate==0.4.6
# homeassistant.components.tibber
-pyTibber==0.12.0
+pyTibber==0.12.2
# homeassistant.components.dlink
pyW215==0.6.0
@@ -1134,7 +1146,7 @@ pyalmond==0.0.2
pyarlo==0.2.3
# homeassistant.components.netatmo
-pyatmo==3.2.2
+pyatmo==3.2.4
# homeassistant.components.atome
pyatome==0.1.1
@@ -1188,7 +1200,7 @@ pydaikin==1.6.2
pydanfossair==0.1.0
# homeassistant.components.deconz
-pydeconz==68
+pydeconz==70
# homeassistant.components.delijn
pydelijn==0.5.1
@@ -1202,9 +1214,6 @@ pydoods==1.0.2
# homeassistant.components.android_ip_webcam
pydroid-ipcam==0.8
-# homeassistant.components.duke_energy
-pydukeenergy==0.0.6
-
# homeassistant.components.ebox
pyebox==1.1.4
@@ -1212,10 +1221,10 @@ pyebox==1.1.4
pyeconet==0.0.11
# homeassistant.components.edimax
-pyedimax==0.1
+pyedimax==0.2.1
# homeassistant.components.eight_sleep
-pyeight==0.1.2
+pyeight==0.1.3
# homeassistant.components.emby
pyemby==1.6
@@ -1229,6 +1238,9 @@ pyephember==0.3.1
# homeassistant.components.everlights
pyeverlights==0.1.0
+# homeassistant.components.ezviz
+pyezviz==0.1.5
+
# homeassistant.components.fortigate
pyfgt==0.5.1
@@ -1270,7 +1282,7 @@ pygogogate2==0.1.1
pygtfs==0.1.5
# homeassistant.components.version
-pyhaversion==3.1.0
+pyhaversion==3.2.0
# homeassistant.components.heos
pyheos==0.6.0
@@ -1279,10 +1291,10 @@ pyheos==0.6.0
pyhik==0.2.5
# homeassistant.components.hive
-pyhiveapi==0.2.19.3
+pyhiveapi==0.2.20.1
# homeassistant.components.homematic
-pyhomematic==0.1.63
+pyhomematic==0.1.65
# homeassistant.components.homeworks
pyhomeworks==0.0.6
@@ -1291,13 +1303,13 @@ pyhomeworks==0.0.6
pyialarm==0.3
# homeassistant.components.icloud
-pyicloud==0.9.1
+pyicloud==0.9.2
# homeassistant.components.intesishome
pyintesishome==1.6
# homeassistant.components.ipma
-pyipma==2.0.2
+pyipma==2.0.3
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -1350,6 +1362,9 @@ pymailgunner==1.4
# homeassistant.components.mediaroom
pymediaroom==0.6.4
+# homeassistant.components.melcloud
+pymelcloud==2.1.0
+
# homeassistant.components.somfy
pymfy==0.7.1
@@ -1396,7 +1411,7 @@ pynuki==1.3.3
pynut2==2.1.2
# homeassistant.components.nws
-pynws==0.10.1
+pynws==0.10.4
# homeassistant.components.nx584
pynx584==0.4
@@ -1413,6 +1428,9 @@ pyombi==0.1.10
# homeassistant.components.openuv
pyopenuv==1.0.9
+# homeassistant.components.opnsense
+pyopnsense==0.2.0
+
# homeassistant.components.opple
pyoppleio==1.0.5
@@ -1427,9 +1445,6 @@ pyotgw==0.5b1
# homeassistant.components.otp
pyotp==2.3.0
-# homeassistant.components.owlet
-pyowlet==1.0.3
-
# homeassistant.components.openweathermap
pyowm==2.10.0
@@ -1449,7 +1464,7 @@ pypjlink2==1.2.0
pypoint==1.1.2
# homeassistant.components.ps4
-pyps4-2ndscreen==1.0.4
+pyps4-2ndscreen==1.0.7
# homeassistant.components.qwikswitch
pyqwikswitch==0.93
@@ -1491,10 +1506,10 @@ pysesame2==1.0.1
pysher==1.0.1
# homeassistant.components.signal_messenger
-pysignalclirestapi==0.1.4
+pysignalclirestapi==0.2.4
# homeassistant.components.sma
-pysma==0.3.4
+pysma==0.3.5
# homeassistant.components.smartthings
pysmartapp==0.3.2
@@ -1532,9 +1547,6 @@ pysyncthru==0.5.0
# homeassistant.components.tautulli
pytautulli==0.5.0
-# homeassistant.components.liveboxplaytv
-pyteleloisirs==3.6
-
# homeassistant.components.tfiac
pytfiac==0.4
@@ -1551,7 +1563,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2
# homeassistant.components.ecobee
-python-ecobee-api==0.1.4
+python-ecobee-api==0.2.1
# homeassistant.components.eq3btsmart
# python-eq3bt==0.1.11
@@ -1565,6 +1577,9 @@ python-family-hub-local==0.0.2
# homeassistant.components.darksky
python-forecastio==1.4.0
+# homeassistant.components.sms
+# python-gammu==2.12
+
# homeassistant.components.gc100
python-gc100==1.0.3a
@@ -1601,9 +1616,6 @@ python-nest==4.1.0
# homeassistant.components.nmap_tracker
python-nmap==0.6.1
-# homeassistant.components.pushover
-python-pushover==0.4
-
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.1
@@ -1617,10 +1629,10 @@ python-sochain-api==0.0.2
python-songpal==0.11.2
# homeassistant.components.synologydsm
-python-synology==0.3.0
+python-synology==0.4.0
# homeassistant.components.tado
-python-tado==0.2.9
+python-tado==0.3.0
# homeassistant.components.telegram_bot
python-telegram-bot==11.1.0
@@ -1632,7 +1644,7 @@ python-telnet-vlc==1.0.4
python-twitch-client==0.6.0
# homeassistant.components.velbus
-python-velbus==2.0.35
+python-velbus==2.0.41
# homeassistant.components.vlc
python-vlc==1.1.2
@@ -1669,7 +1681,7 @@ pytradfri[async]==6.4.0
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==0.1.5.9
+pytrafikverket==0.1.6.1
# homeassistant.components.ubee
pyubee==0.8
@@ -1690,7 +1702,7 @@ pyversasense==0.0.6
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.4
+pyvizio==0.1.26
# homeassistant.components.velux
pyvlx==0.2.12
@@ -1711,7 +1723,7 @@ pyzabbix==0.7.4
pyzbar==0.1.7
# homeassistant.components.qnap
-qnapstats==0.2.7
+qnapstats==0.3.0
# homeassistant.components.quantum_gateway
quantum-gateway==0.0.5
@@ -1744,7 +1756,7 @@ restrictedpython==5.0
rfk101py==0.0.1
# homeassistant.components.rflink
-rflink==0.0.50
+rflink==0.0.51
# homeassistant.components.ring
ring_doorbell==0.6.0
@@ -1779,6 +1791,9 @@ russound_rio==0.1.7
# homeassistant.components.yamaha
rxv==0.6.0
+# homeassistant.components.salt
+saltbox==0.1.3
+
# homeassistant.components.samsungtv
samsungctl[websocket]==0.7.1
@@ -1792,7 +1807,7 @@ schiene==0.23
scsgate==0.1.0
# homeassistant.components.sendgrid
-sendgrid==6.1.0
+sendgrid==6.1.1
# homeassistant.components.sensehat
sense-hat==2.2.0
@@ -1816,7 +1831,7 @@ simplehound==0.3
simplepush==1.1.4
# homeassistant.components.simplisafe
-simplisafe-python==6.0.0
+simplisafe-python==8.1.1
# homeassistant.components.sisyphus
sisyphus-control==2.2.1
@@ -1854,7 +1869,7 @@ smhi-pkg==1.0.10
snapcast==2.0.10
# homeassistant.components.socialblade
-socialbladeclient==0.2
+socialbladeclient==0.5
# homeassistant.components.solaredge_local
solaredge-local==0.2.0
@@ -1884,7 +1899,7 @@ spiderpy==1.3.1
spotcrime==1.0.4
# homeassistant.components.spotify
-spotipy-homeassistant==2.4.4.dev1
+spotipy==2.7.1
# homeassistant.components.recorder
# homeassistant.components.sql
@@ -1921,7 +1936,7 @@ sucks==0.9.4
sunwatcher==0.2.1
# homeassistant.components.surepetcare
-surepy==0.1.10
+surepy==0.2.3
# homeassistant.components.swiss_hydrological_data
swisshydrodata==0.0.3
@@ -1957,7 +1972,7 @@ temperusb==1.5.3
# tensorflow==1.13.2
# homeassistant.components.tesla
-teslajsonpy==0.2.3
+teslajsonpy==0.3.0
# homeassistant.components.thermoworks_smoke
thermoworks_smoke==0.1.8
@@ -1978,7 +1993,7 @@ todoist-python==8.0.0
toonapilib==3.2.4
# homeassistant.components.totalconnect
-total_connect_client==0.28
+total_connect_client==0.50
# homeassistant.components.tplink_lte
tp-connected==0.0.4
@@ -1995,6 +2010,9 @@ twentemilieu==0.2.0
# homeassistant.components.twilio
twilio==6.32.0
+# homeassistant.components.rainforest_eagle
+uEagle==0.0.1
+
# homeassistant.components.unifiled
unifiled==0.11
@@ -2011,7 +2029,7 @@ uscisstatus==0.1.1
uvcclient==0.11.0
# homeassistant.components.vallox
-vallox-websocket-api==2.2.0
+vallox-websocket-api==2.4.0
# homeassistant.components.venstar
venstarcolortouch==0.12
@@ -2019,6 +2037,9 @@ venstarcolortouch==0.12
# homeassistant.components.meteo_france
vigilancemeteo==3.0.0
+# homeassistant.components.vilfo
+vilfo-api-client==0.3.2
+
# homeassistant.components.volkszaehler
volkszaehler==0.1.2
@@ -2078,6 +2099,7 @@ xfinity-gateway==0.0.4
xknx==0.11.2
# homeassistant.components.bluesound
+# homeassistant.components.rest
# homeassistant.components.startca
# homeassistant.components.ted5000
# homeassistant.components.yr
@@ -2103,7 +2125,7 @@ yeelight==0.5.0
yeelightsunflower==0.0.10
# homeassistant.components.media_extractor
-youtube_dl==2020.01.24
+youtube_dl==2020.02.16
# homeassistant.components.zengge
zengge==0.2
@@ -2112,7 +2134,7 @@ zengge==0.2
zeroconf==0.24.4
# homeassistant.components.zha
-zha-quirks==0.0.31
+zha-quirks==0.0.33
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
@@ -2120,14 +2142,17 @@ zhong_hong_hvac==1.0.9
# homeassistant.components.ziggo_mediabox_xl
ziggo-mediabox-xl==1.1.0
+# homeassistant.components.zha
+zigpy-cc==0.1.0
+
# homeassistant.components.zha
zigpy-deconz==0.7.0
# homeassistant.components.zha
-zigpy-homeassistant==0.12.0
+zigpy-homeassistant==0.13.2
# homeassistant.components.zha
-zigpy-xbee-homeassistant==0.8.0
+zigpy-xbee-homeassistant==0.9.0
# homeassistant.components.zha
zigpy-zigate==0.5.1
diff --git a/requirements_test.txt b/requirements_test.txt
index 030e3dc60ceaef..6fc7e10a78dba3 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,13 +7,14 @@ asynctest==0.13.0
codecov==2.0.15
mock-open==1.3.1
mypy==0.761
-pre-commit==1.21.0
+pre-commit==2.1.1
pylint==2.4.4
astroid==2.3.3
+pylint-strict-informational==0.1
pytest-aiohttp==0.3.0
pytest-cov==2.8.1
pytest-sugar==0.9.2
pytest-timeout==1.3.3
-pytest==5.3.4
+pytest==5.3.5
requests_mock==1.7.0
responses==0.10.6
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 1451da94ff00a4..0522a3a8ea4dc1 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -4,7 +4,7 @@
-r requirements_test.txt
# homeassistant.components.homekit
-HAP-python==2.6.0
+HAP-python==2.7.0
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -26,56 +26,62 @@ RtmAPI==0.7.2
YesssSMS==0.4.1
# homeassistant.components.abode
-abodepy==0.16.7
+abodepy==0.17.0
# homeassistant.components.androidtv
adb-shell==0.1.1
# homeassistant.components.adguard
-adguardhome==0.4.0
+adguardhome==0.4.1
# homeassistant.components.geonetnz_quakes
-aio_geojson_geonetnz_quakes==0.11
+aio_geojson_geonetnz_quakes==0.12
# homeassistant.components.geonetnz_volcano
aio_geojson_geonetnz_volcano==0.5
# homeassistant.components.nsw_rural_fire_service_feed
-aio_geojson_nsw_rfs_incidents==0.1
+aio_geojson_nsw_rfs_incidents==0.3
+
+# homeassistant.components.gdacs
+aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
-aioambient==1.0.2
+aioambient==1.0.4
# homeassistant.components.asuswrt
-aioasuswrt==1.1.22
+aioasuswrt==1.2.2
# homeassistant.components.automatic
aioautomatic==0.6.5
# homeassistant.components.aws
-aiobotocore==0.10.4
+aiobotocore==0.11.1
# homeassistant.components.esphome
aioesphomeapi==2.6.1
+# homeassistant.components.homekit_controller
+aiohomekit[IP]==0.2.11
+
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
# homeassistant.components.hue
-aiohue==1.10.1
+aiohue==2.0.0
# homeassistant.components.notion
aionotion==1.1.0
# homeassistant.components.webostv
-aiopylgtv==0.3.0
+aiopylgtv==0.3.3
# homeassistant.components.switcher_kis
aioswitcher==2019.4.26
# homeassistant.components.unifi
-aiounifi==11
+aiounifi==13
# homeassistant.components.wwlln
aiowwlln==2.0.2
@@ -87,13 +93,13 @@ airly==0.0.2
ambiclimate==0.2.1
# homeassistant.components.androidtv
-androidtv==0.0.38
+androidtv==0.0.39
# homeassistant.components.apns
apns2==0.3.0
# homeassistant.components.apprise
-apprise==0.8.3
+apprise==0.8.4
# homeassistant.components.aprs
aprslib==0.6.46
@@ -112,7 +118,7 @@ av==6.1.2
axis==25
# homeassistant.components.zha
-bellows-homeassistant==0.12.0
+bellows-homeassistant==0.13.2
# homeassistant.components.bom
bomradarloop==0.1.3
@@ -121,7 +127,7 @@ bomradarloop==0.1.3
broadlink==0.12.0
# homeassistant.components.brother
-brother==0.1.4
+brother==0.1.6
# homeassistant.components.buienradar
buienradar==1.0.1
@@ -153,16 +159,19 @@ datadog==0.15.0
defusedxml==0.6.0
# homeassistant.components.denonavr
-denonavr==0.7.11
+denonavr==0.7.12
# homeassistant.components.directv
-directpy==0.5
+directpy==0.6
# homeassistant.components.updater
distro==1.4.0
# homeassistant.components.dsmr
-dsmr_parser==0.12
+dsmr_parser==0.18
+
+# homeassistant.components.dynalite
+dynalite_devices==0.1.22
# homeassistant.components.ee_brightbox
eebrightbox==0.0.4
@@ -185,6 +194,9 @@ foobot_async==0.3.1
# homeassistant.components.google_translate
gTTS-token==1.1.3
+# homeassistant.components.garmin_connect
+garminconnect==0.1.8
+
# homeassistant.components.geo_json_events
# homeassistant.components.usgs_earthquakes_feed
geojson_client==0.4
@@ -204,6 +216,7 @@ georss_qld_bushfire_alert_client==0.3
# homeassistant.components.braviatv
# homeassistant.components.huawei_lte
# homeassistant.components.kef
+# homeassistant.components.minecraft_server
# homeassistant.components.nmap_tracker
getmac==0.8.1
@@ -232,7 +245,7 @@ hass-nabucasa==0.31
hbmqtt==0.9.5
# homeassistant.components.jewish_calendar
-hdate==0.9.3
+hdate==0.9.5
# homeassistant.components.here_travel_time
herepy==2.0.0
@@ -241,33 +254,31 @@ herepy==2.0.0
hole==0.5.0
# homeassistant.components.workday
-holidays==0.9.12
+holidays==0.10.1
# homeassistant.components.frontend
-home-assistant-frontend==20200108.2
+home-assistant-frontend==20200220.3
# homeassistant.components.zwave
-homeassistant-pyozw==0.1.7
-
-# homeassistant.components.homekit_controller
-homekit[IP]==0.15.0
+homeassistant-pyozw==0.1.8
# homeassistant.components.homematicip_cloud
-homematicip==0.10.15
+homematicip==0.10.17
# homeassistant.components.google
# homeassistant.components.remember_the_milk
httplib2==0.10.3
# homeassistant.components.huawei_lte
-huawei-lte-api==1.4.6
+huawei-lte-api==1.4.7
# homeassistant.components.iaqualink
-iaqualink==0.3.0
+iaqualink==0.3.1
# homeassistant.components.influxdb
influxdb==5.2.3
+# homeassistant.components.rest
# homeassistant.components.verisure
jsonpath==0.82
@@ -277,8 +288,14 @@ keyring==20.0.0
# homeassistant.scripts.keyring
keyrings.alt==3.4.0
+# homeassistant.components.konnected
+konnected==1.1.0
+
# homeassistant.components.dyson
-libpurecool==0.6.0
+libpurecool==0.6.1
+
+# homeassistant.components.mikrotik
+librouteros==3.0.0
# homeassistant.components.soundtouch
libsoundtouch==0.7.2
@@ -292,6 +309,12 @@ luftdaten==0.6.3
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
+# homeassistant.components.minecraft_server
+mcstatus==2.3.0
+
+# homeassistant.components.meteo_france
+meteofrance==0.3.7
+
# homeassistant.components.mfi
mficlient==0.3.0
@@ -318,7 +341,7 @@ nuheat==0.3.0
# homeassistant.components.opencv
# homeassistant.components.tensorflow
# homeassistant.components.trend
-numpy==1.17.4
+numpy==1.18.1
# homeassistant.components.google
oauth2client==4.0.0
@@ -350,7 +373,7 @@ plexwebsocket==0.0.6
pmsensor==0.4
# homeassistant.components.reddit
-praw==6.5.0
+praw==6.5.1
# homeassistant.components.islamic_prayer_times
prayer_times_calculator==0.0.3
@@ -367,6 +390,9 @@ pure-python-adb==0.2.2.dev0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
+# homeassistant.components.august
+py-august==0.21.0
+
# homeassistant.components.canary
py-canary==0.5.0
@@ -399,7 +425,7 @@ pyalmond==0.0.2
pyarlo==0.2.3
# homeassistant.components.netatmo
-pyatmo==3.2.2
+pyatmo==3.2.4
# homeassistant.components.blackbird
pyblackbird==0.5
@@ -417,7 +443,7 @@ pycoolmasternet==0.0.4
pydaikin==1.6.2
# homeassistant.components.deconz
-pydeconz==68
+pydeconz==70
# homeassistant.components.zwave
pydispatcher==2.0.5
@@ -439,19 +465,19 @@ pyfttt==0.3
pygatt[GATTTOOL]==4.0.5
# homeassistant.components.version
-pyhaversion==3.1.0
+pyhaversion==3.2.0
# homeassistant.components.heos
pyheos==0.6.0
# homeassistant.components.homematic
-pyhomematic==0.1.63
+pyhomematic==0.1.65
# homeassistant.components.icloud
-pyicloud==0.9.1
+pyicloud==0.9.2
# homeassistant.components.ipma
-pyipma==2.0.2
+pyipma==2.0.3
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -468,6 +494,9 @@ pylitejet==0.1
# homeassistant.components.mailgun
pymailgunner==1.4
+# homeassistant.components.melcloud
+pymelcloud==2.1.0
+
# homeassistant.components.somfy
pymfy==0.7.1
@@ -481,7 +510,7 @@ pymodbus==1.5.2
pymonoprice==0.3
# homeassistant.components.nws
-pynws==0.10.1
+pynws==0.10.4
# homeassistant.components.nx584
pynx584==0.4
@@ -489,6 +518,9 @@ pynx584==0.4
# homeassistant.components.openuv
pyopenuv==1.0.9
+# homeassistant.components.opnsense
+pyopnsense==0.2.0
+
# homeassistant.components.opentherm_gw
pyotgw==0.5b1
@@ -501,13 +533,16 @@ pyotp==2.3.0
pypoint==1.1.2
# homeassistant.components.ps4
-pyps4-2ndscreen==1.0.4
+pyps4-2ndscreen==1.0.7
# homeassistant.components.qwikswitch
pyqwikswitch==0.93
+# homeassistant.components.signal_messenger
+pysignalclirestapi==0.2.4
+
# homeassistant.components.sma
-pysma==0.3.4
+pysma==0.3.5
# homeassistant.components.smartthings
pysmartapp==0.3.2
@@ -525,7 +560,7 @@ pysonos==0.0.24
pyspcwebgw==0.4.0
# homeassistant.components.ecobee
-python-ecobee-api==0.1.4
+python-ecobee-api==0.2.1
# homeassistant.components.darksky
python-forecastio==1.4.0
@@ -539,8 +574,11 @@ python-miio==0.4.8
# homeassistant.components.nest
python-nest==4.1.0
+# homeassistant.components.twitch
+python-twitch-client==0.6.0
+
# homeassistant.components.velbus
-python-velbus==2.0.35
+python-velbus==2.0.41
# homeassistant.components.awair
python_awair==0.0.4
@@ -558,7 +596,7 @@ pyvera==0.3.7
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.4
+pyvizio==0.1.26
# homeassistant.components.html5
pywebpush==1.9.2
@@ -570,7 +608,7 @@ regenmaschine==1.5.1
restrictedpython==5.0
# homeassistant.components.rflink
-rflink==0.0.50
+rflink==0.0.51
# homeassistant.components.ring
ring_doorbell==0.6.0
@@ -581,6 +619,9 @@ rxv==0.6.0
# homeassistant.components.samsungtv
samsungctl[websocket]==0.7.1
+# homeassistant.components.sense
+sense_energy==0.7.0
+
# homeassistant.components.sentry
sentry-sdk==0.13.5
@@ -588,7 +629,7 @@ sentry-sdk==0.13.5
simplehound==0.3
# homeassistant.components.simplisafe
-simplisafe-python==6.0.0
+simplisafe-python==8.1.1
# homeassistant.components.sleepiq
sleepyq==0.7
@@ -605,6 +646,9 @@ somecomfort==0.5.2
# homeassistant.components.marytts
speak2mary==1.4.0
+# homeassistant.components.spotify
+spotipy==2.7.1
+
# homeassistant.components.recorder
# homeassistant.components.sql
sqlalchemy==1.3.13
@@ -628,7 +672,7 @@ sunwatcher==0.2.1
tellduslive==0.10.10
# homeassistant.components.tesla
-teslajsonpy==0.2.3
+teslajsonpy==0.3.0
# homeassistant.components.toon
toonapilib==3.2.4
@@ -648,6 +692,12 @@ url-normalize==1.4.1
# homeassistant.components.uvc
uvcclient==0.11.0
+# homeassistant.components.meteo_france
+vigilancemeteo==3.0.0
+
+# homeassistant.components.vilfo
+vilfo-api-client==0.3.2
+
# homeassistant.components.verisure
vsure==1.5.4
@@ -668,6 +718,7 @@ withings-api==2.1.3
wled==0.2.1
# homeassistant.components.bluesound
+# homeassistant.components.rest
# homeassistant.components.startca
# homeassistant.components.ted5000
# homeassistant.components.yr
@@ -684,16 +735,19 @@ yahooweather==0.10
zeroconf==0.24.4
# homeassistant.components.zha
-zha-quirks==0.0.31
+zha-quirks==0.0.33
+
+# homeassistant.components.zha
+zigpy-cc==0.1.0
# homeassistant.components.zha
zigpy-deconz==0.7.0
# homeassistant.components.zha
-zigpy-homeassistant==0.12.0
+zigpy-homeassistant==0.13.2
# homeassistant.components.zha
-zigpy-xbee-homeassistant==0.8.0
+zigpy-xbee-homeassistant==0.9.0
# homeassistant.components.zha
zigpy-zigate==0.5.1
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index 8af2cbb6123511..b0deb01b3da1c0 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,7 +1,8 @@
-# Automatically generated from .pre-commit-config-all.yaml by gen_requirements_all.py, do not edit
+# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
bandit==1.6.2
black==19.10b0
+codespell==v1.16.0
flake8-docstrings==1.5.0
flake8==3.7.9
isort==v4.3.21
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index fc539a97f9f990..2b7fe8226b200b 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -33,6 +33,7 @@
"PySwitchbot",
"pySwitchmate",
"python-eq3bt",
+ "python-gammu",
"python-lirc",
"pyuserinput",
"raspihats",
@@ -57,6 +58,9 @@
CONSTRAINT_BASE = """
pycryptodome>=3.6.6
+# Constrain urllib3 to ensure we deal with CVE-2019-11236 & CVE-2019-11324
+urllib3>=1.24.3
+
# Not needed for our supported Python versions
enum34==1000000000.0.0
@@ -64,7 +68,7 @@
pycrypto==1000000000.0.0
"""
-IGNORE_PRE_COMMIT_HOOK_ID = ("check-json",)
+IGNORE_PRE_COMMIT_HOOK_ID = ("check-json", "no-commit-to-branch")
def has_tests(module: str):
@@ -252,7 +256,7 @@ def requirements_test_output(reqs):
def requirements_pre_commit_output():
"""Generate output for pre-commit dependencies."""
- source = ".pre-commit-config-all.yaml"
+ source = ".pre-commit-config.yaml"
pre_commit_conf = load_yaml(source)
reqs = []
for repo in (x for x in pre_commit_conf["repos"] if x.get("rev")):
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
index 99e32e57f43292..a1541ef68c9d29 100644
--- a/script/hassfest/__main__.py
+++ b/script/hassfest/__main__.py
@@ -1,10 +1,12 @@
"""Validate manifests."""
import pathlib
import sys
+from time import monotonic
from . import (
codeowners,
config_flow,
+ coverage,
dependencies,
json,
manifest,
@@ -18,6 +20,7 @@
json,
codeowners,
config_flow,
+ coverage,
dependencies,
manifest,
services,
@@ -48,7 +51,17 @@ def main():
integrations = Integration.load_dir(pathlib.Path("homeassistant/components"))
for plugin in PLUGINS:
- plugin.validate(integrations, config)
+ try:
+ start = monotonic()
+ print(f"Validating {plugin.__name__.split('.')[-1]}...", end="", flush=True)
+ plugin.validate(integrations, config)
+ print(" done in {:.2f}s".format(monotonic() - start))
+ except RuntimeError as err:
+ print()
+ print()
+ print("Error!")
+ print(err)
+ return 1
# When we generate, all errors that are fixable will be ignored,
# as generating them will be fixed.
diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py
new file mode 100644
index 00000000000000..dc94b36e6d8b1f
--- /dev/null
+++ b/script/hassfest/coverage.py
@@ -0,0 +1,50 @@
+"""Validate coverage files."""
+from pathlib import Path
+from typing import Dict
+
+from .model import Config, Integration
+
+
+def validate(integrations: Dict[str, Integration], config: Config):
+ """Validate coverage."""
+ coverage_path = config.root / ".coveragerc"
+
+ not_found = []
+ checking = False
+
+ with coverage_path.open("rt") as fp:
+ for line in fp:
+ line = line.strip()
+
+ if not line or line.startswith("#"):
+ continue
+
+ if not checking:
+ if line == "omit =":
+ checking = True
+ continue
+
+ # Finished
+ if line == "[report]":
+ break
+
+ path = Path(line)
+
+ # Discard wildcard
+ while "*" in path.name:
+ path = path.parent
+
+ if not path.exists():
+ not_found.append(line)
+
+ if not not_found:
+ return
+
+ errors = []
+
+ if not_found:
+ errors.append(
+ f".coveragerc references files that don't exist: {', '.join(not_found)}."
+ )
+
+ raise RuntimeError(" ".join(errors))
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
index 52c8bfecf95268..c909b6216a93f4 100644
--- a/script/hassfest/dependencies.py
+++ b/script/hassfest/dependencies.py
@@ -103,14 +103,12 @@ def visit_Attribute(self, node):
"homeassistant",
"system_log",
"person",
- # Discovery
- "discovery",
# Other
"mjpeg", # base class, has no reqs or component to load.
"stream", # Stream cannot install on all systems, can be imported without reqs.
}
-IGNORE_VIOLATIONS = [
+IGNORE_VIOLATIONS = {
# Has same requirement, gets defaults.
("sql", "recorder"),
# Sharing a base class
@@ -122,6 +120,7 @@ def visit_Attribute(self, node):
("demo", "openalpr_local"),
# This should become a helper method that integrations can submit data to
("websocket_api", "lovelace"),
+ ("websocket_api", "shopping_list"),
# Expose HA to external systems
"homekit",
"alexa",
@@ -134,9 +133,7 @@ def visit_Attribute(self, node):
# These should be extracted to external package
"pvoutput",
"dwd_weather_warnings",
- # Should be rewritten to use own data fetcher
- "scrape",
-]
+}
def calc_allowed_references(integration: Integration) -> Set[str]:
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index e6bd65517866aa..7852953dc929db 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -1,11 +1,17 @@
"""Manifest validation."""
from typing import Dict
+from urllib.parse import urlparse
import voluptuous as vol
from voluptuous.humanize import humanize_error
from .model import Integration
+DOCUMENTATION_URL_SCHEMA = "https"
+DOCUMENTATION_URL_HOST = "www.home-assistant.io"
+DOCUMENTATION_URL_PATH_PREFIX = "/integrations/"
+DOCUMENTATION_URL_EXCEPTIONS = ["https://www.home-assistant.io/hassio"]
+
SUPPORTED_QUALITY_SCALES = [
"gold",
"internal",
@@ -13,6 +19,25 @@
"silver",
]
+
+def documentation_url(value: str) -> str:
+ """Validate that a documentation url has the correct path and domain."""
+ if value in DOCUMENTATION_URL_EXCEPTIONS:
+ return value
+
+ parsed_url = urlparse(value)
+ if not parsed_url.scheme == DOCUMENTATION_URL_SCHEMA:
+ raise vol.Invalid("Documentation url is not prefixed with https")
+ if not parsed_url.netloc == DOCUMENTATION_URL_HOST:
+ raise vol.Invalid("Documentation url not hosted at www.home-assistant.io")
+ if not parsed_url.path.startswith(DOCUMENTATION_URL_PATH_PREFIX):
+ raise vol.Invalid(
+ "Documentation url does not begin with www.home-assistant.io/integrations"
+ )
+
+ return value
+
+
MANIFEST_SCHEMA = vol.Schema(
{
vol.Required("domain"): str,
@@ -23,12 +48,16 @@
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
),
vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}),
- vol.Required("documentation"): str,
+ vol.Required("documentation"): vol.All(
+ vol.Url(), documentation_url # pylint: disable=no-value-for-parameter
+ ),
vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES),
vol.Required("requirements"): [str],
vol.Required("dependencies"): [str],
vol.Optional("after_dependencies"): [str],
vol.Required("codeowners"): [str],
+ vol.Optional("logo"): vol.Url(), # pylint: disable=no-value-for-parameter
+ vol.Optional("icon"): vol.Url(), # pylint: disable=no-value-for-parameter
}
)
diff --git a/script/run-in-env.sh b/script/run-in-env.sh
new file mode 100755
index 00000000000000..d9fe17f4b17688
--- /dev/null
+++ b/script/run-in-env.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env sh -eu
+
+# Activate pyenv and virtualenv if present, then run the specified command
+
+# pyenv, pyenv-virtualenv
+if [ -s .python-version ]; then
+ PYENV_VERSION=$(head -n 1 .python-version)
+ export PYENV_VERSION
+fi
+
+# other common virtualenvs
+for venv in venv .venv .; do
+ if [ -f $venv/bin/activate ]; then
+ . $venv/bin/activate
+ fi
+done
+
+exec "$@"
diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py
index 8fa2814e54fa88..d3b6891410447d 100644
--- a/script/scaffold/__main__.py
+++ b/script/scaffold/__main__.py
@@ -82,6 +82,12 @@ def main():
subprocess.run(["python", "-m", "script.gen_requirements_all"], **pipe_null)
print()
+ print("Running script/translations_develop to pick up new translation strings.")
+ subprocess.run(
+ ["script/translations_develop", "--integration", info.domain], **pipe_null
+ )
+ print()
+
if args.develop:
print("Running tests")
print(f"$ pytest -vvv tests/components/{info.domain}")
diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py
index 48d0a20ea73321..fda5081e7c379d 100644
--- a/script/scaffold/gather_info.py
+++ b/script/scaffold/gather_info.py
@@ -56,7 +56,7 @@ def gather_info(arguments) -> Info:
YES_NO = {
"validators": [["Type either 'yes' or 'no'", lambda value: value in ("yes", "no")]],
- "convertor": lambda value: value == "yes",
+ "converter": lambda value: value == "yes",
}
@@ -155,8 +155,8 @@ def _gather_info(fields) -> dict:
break
if hint is None:
- if "convertor" in info:
- value = info["convertor"](value)
+ if "converter" in info:
+ value = info["converter"](value)
answers[key] = value
return answers
diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py
index ec332de13e213f..8a543a04af3074 100644
--- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py
+++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py
@@ -1,4 +1,6 @@
"""Test the NEW_NAME config flow."""
+from asynctest import patch
+
from homeassistant import config_entries, setup
from homeassistant.components.NEW_DOMAIN.const import (
DOMAIN,
@@ -48,6 +50,10 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
},
)
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ with patch(
+ "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True
+ ) as mock_setup:
+ await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert len(mock_setup.mock_calls) == 1
diff --git a/script/scaffold/templates/device_condition/integration/device_condition.py b/script/scaffold/templates/device_condition/integration/device_condition.py
index 1414636474d414..cb2489e427943e 100644
--- a/script/scaffold/templates/device_condition/integration/device_condition.py
+++ b/script/scaffold/templates/device_condition/integration/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
@@ -67,6 +67,7 @@ async def async_get_conditions(
return conditions
+@callback
def async_condition_from_config(
config: ConfigType, config_validation: bool
) -> condition.ConditionCheckerType:
@@ -78,6 +79,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/script/scaffold/templates/device_condition/tests/test_device_condition.py b/script/scaffold/templates/device_condition/tests/test_device_condition.py
index d58957030dc50f..34217a61f9e0d8 100644
--- a/script/scaffold/templates/device_condition/tests/test_device_condition.py
+++ b/script/scaffold/templates/device_condition/tests/test_device_condition.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py
index 0ea584f474d3c8..825405663180bf 100644
--- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py
+++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/script/translations_develop b/script/translations_develop
index eb9d685fa8e33a..f0976f3d676ac5 100755
--- a/script/translations_develop
+++ b/script/translations_develop
@@ -1,21 +1,81 @@
-#!/usr/bin/env bash
+#!/usr/bin/env python
# Compile the current translation strings files for testing
-# Safe bash settings
-# -e Exit on command fail
-# -u Exit on unset variable
-# -o pipefail Exit if piped command has error code
-set -eu -o pipefail
+import argparse
+import json
+import os
+from pathlib import Path
+from shutil import rmtree
+import subprocess
+import sys
-cd "$(dirname "$0")/.."
-mkdir -p build/translations-download
+def valid_integration(integration):
+ """Test if it's a valid integration."""
+ if not Path(f"homeassistant/components/{integration}").exists():
+ raise argparse.ArgumentTypeError(
+ f"The integration {integration} does not exist."
+ )
-script/translations_upload_merge.py
+ return integration
-# Use the generated translations upload file as the mock output from the
-# Lokalise download
-mv build/translations-upload.json build/translations-download/en.json
-script/translations_download_split.py
+def get_arguments() -> argparse.Namespace:
+ """Get parsed passed in arguments."""
+ parser = argparse.ArgumentParser(description="Develop Translations")
+ parser.add_argument(
+ "--integration", type=valid_integration, help="Integration to process."
+ )
+
+ arguments = parser.parse_args()
+
+ return arguments
+
+
+def main():
+ """Run the script."""
+ if not os.path.isfile("requirements_all.txt"):
+ print("Run this from HA root dir")
+ return
+
+ args = get_arguments()
+ if args.integration:
+ integration = args.integration
+ else:
+ integration = None
+ while (
+ integration is None
+ or not Path(f"homeassistant/components/{integration}").exists()
+ ):
+ if integration is not None:
+ print(f"Integration {integration} doesn't exist!")
+ print()
+ integration = input("Integration to process: ")
+
+ download_dir = Path("build/translations-download")
+
+ if download_dir.is_dir():
+ rmtree(str(download_dir))
+
+ download_dir.mkdir(parents=True)
+
+ subprocess.run("script/translations_upload_merge.py")
+
+ raw_data = json.loads(Path("build/translations-upload.json").read_text())
+
+ if integration not in raw_data["component"]:
+ print("Integration has no strings.json")
+ sys.exit(1)
+
+ Path("build/translations-download/en.json").write_text(
+ json.dumps({"component": {integration: raw_data["component"][integration]}})
+ )
+
+ subprocess.run(
+ ["script/translations_download_split.py", "--integration", "{integration}"]
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/script/version_bump.py b/script/version_bump.py
index 13dfe499f5e864..f3ed5e99c5553c 100755
--- a/script/version_bump.py
+++ b/script/version_bump.py
@@ -140,7 +140,7 @@ def main():
if not arguments.commit:
return
- subprocess.run(["git", "commit", "-am", f"Bumped version to {bumped}"])
+ subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"])
def test_bump_version():
diff --git a/setup.py b/setup.py
index 521b9f2678caae..0564b7f4773e8a 100755
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@
PROJECT_LICENSE = "Apache License 2.0"
PROJECT_AUTHOR = "The Home Assistant Authors"
PROJECT_COPYRIGHT = " 2013-{}, {}".format(dt.now().year, PROJECT_AUTHOR)
-PROJECT_URL = "https://home-assistant.io/"
+PROJECT_URL = "https://www.home-assistant.io/"
PROJECT_EMAIL = "hello@home-assistant.io"
PROJECT_GITHUB_USERNAME = "home-assistant"
@@ -38,7 +38,8 @@
"attrs==19.3.0",
"bcrypt==3.1.7",
"certifi>=2019.11.28",
- "importlib-metadata==1.4.0",
+ "ciso8601==2.1.3",
+ "importlib-metadata==1.5.0",
"jinja2>=2.10.3",
"PyJWT==1.7.1",
# PyJWT has loose dependency. We want the latest one.
@@ -47,7 +48,7 @@
"python-slugify==4.0.0",
"pytz>=2019.03",
"pyyaml==5.3",
- "requests==2.22.0",
+ "requests==2.23.0",
"ruamel.yaml==0.15.100",
"voluptuous==0.11.7",
"voluptuous-serialize==2.3.0",
diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py
index bc4ecaab712b65..c79d76baf4fffb 100644
--- a/tests/auth/mfa_modules/test_notify.py
+++ b/tests/auth/mfa_modules/test_notify.py
@@ -321,7 +321,7 @@ async def test_include_exclude_config(hass):
async def test_setup_user_no_notify_service(hass):
- """Test setup flow abort if there is no avilable notify service."""
+ """Test setup flow abort if there is no available notify service."""
async_mock_service(hass, "notify", "test1", NOTIFY_SERVICE_SCHEMA)
notify_auth_module = await auth_mfa_module_from_config(
hass, {"type": "notify", "exclude": "test1"}
diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py
index 2ff75c579e56cd..82c0c0dbdbd4c7 100644
--- a/tests/auth/test_init.py
+++ b/tests/auth/test_init.py
@@ -31,7 +31,7 @@ async def test_auth_manager_from_config_validates_config(mock_hass):
[
{"name": "Test Name", "type": "insecure_example", "users": []},
{
- "name": "Invalid config because no users",
+ "name": "Invalid configuration because no users",
"type": "insecure_example",
"id": "invalid_config",
},
@@ -81,7 +81,7 @@ async def test_auth_manager_from_config_auth_modules(mock_hass):
[
{"name": "Module 1", "type": "insecure_example", "data": []},
{
- "name": "Invalid config because no data",
+ "name": "Invalid configuration because no data",
"type": "insecure_example",
"id": "another",
},
@@ -453,7 +453,7 @@ async def test_refresh_token_type_long_lived_access_token(hass):
async def test_cannot_deactive_owner(mock_hass):
- """Test that we cannot deactive the owner."""
+ """Test that we cannot deactivate the owner."""
manager = await auth.auth_manager_from_config(mock_hass, [], [])
owner = MockUser(is_owner=True).add_to_auth_manager(manager)
diff --git a/tests/common.py b/tests/common.py
index 5a00a2bc7df4a2..4581c96b52a738 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -323,11 +323,15 @@ async def async_mock_mqtt_component(hass, config=None):
if config is None:
config = {mqtt.CONF_BROKER: "mock-broker"}
+ async def _async_fire_mqtt_message(topic, payload, qos, retain):
+ async_fire_mqtt_message(hass, topic, payload, qos, retain)
+
with patch("paho.mqtt.client.Client") as mock_client:
mock_client().connect.return_value = 0
mock_client().subscribe.return_value = (0, 0)
mock_client().unsubscribe.return_value = (0, 0)
mock_client().publish.return_value = (0, 0)
+ mock_client().publish.side_effect = _async_fire_mqtt_message
result = await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config})
assert result
diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py
index 903314ab1b77cc..a0d575deac0468 100644
--- a/tests/components/adguard/test_config_flow.py
+++ b/tests/components/adguard/test_config_flow.py
@@ -40,11 +40,9 @@ async def test_show_authenticate_form(hass):
async def test_connection_error(hass, aioclient_mock):
"""Test we show user form on AdGuard Home connection error."""
aioclient_mock.get(
- "{}://{}:{}/control/status".format(
- "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http",
- FIXTURE_USER_INPUT[CONF_HOST],
- FIXTURE_USER_INPUT[CONF_PORT],
- ),
+ f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}"
+ f"://{FIXTURE_USER_INPUT[CONF_HOST]}"
+ f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status",
exc=aiohttp.ClientError,
)
@@ -60,11 +58,9 @@ async def test_connection_error(hass, aioclient_mock):
async def test_full_flow_implementation(hass, aioclient_mock):
"""Test registering an integration and finishing flow works."""
aioclient_mock.get(
- "{}://{}:{}/control/status".format(
- "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http",
- FIXTURE_USER_INPUT[CONF_HOST],
- FIXTURE_USER_INPUT[CONF_PORT],
- ),
+ f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}"
+ f"://{FIXTURE_USER_INPUT[CONF_HOST]}"
+ f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status",
json={"version": "v0.99.0"},
headers={"Content-Type": "application/json"},
)
@@ -244,11 +240,9 @@ async def test_hassio_connection_error(hass, aioclient_mock):
async def test_outdated_adguard_version(hass, aioclient_mock):
"""Test we show abort when connecting with unsupported AdGuard version."""
aioclient_mock.get(
- "{}://{}:{}/control/status".format(
- "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http",
- FIXTURE_USER_INPUT[CONF_HOST],
- FIXTURE_USER_INPUT[CONF_PORT],
- ),
+ f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}"
+ f"://{FIXTURE_USER_INPUT[CONF_HOST]}"
+ f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status",
json={"version": "v0.98.0"},
headers={"Content-Type": "application/json"},
)
diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py
index ec14cefc2915a3..9b890aa4d25140 100644
--- a/tests/components/alarm_control_panel/test_device_trigger.py
+++ b/tests/components/alarm_control_panel/test_device_trigger.py
@@ -207,20 +207,18 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_TRIGGERED)
await hass.async_block_till_done()
assert len(calls) == 1
- assert calls[0].data[
- "some"
- ] == "triggered - device - {} - pending - triggered - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[0].data["some"]
+ == "triggered - device - alarm_control_panel.entity - pending - triggered - None"
)
# Fake that the entity is disarmed.
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_DISARMED)
await hass.async_block_till_done()
assert len(calls) == 2
- assert calls[1].data[
- "some"
- ] == "disarmed - device - {} - triggered - disarmed - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[1].data["some"]
+ == "disarmed - device - alarm_control_panel.entity - triggered - disarmed - None"
)
# Fake that the entity is armed home.
@@ -228,10 +226,9 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME)
await hass.async_block_till_done()
assert len(calls) == 3
- assert calls[2].data[
- "some"
- ] == "armed_home - device - {} - pending - armed_home - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[2].data["some"]
+ == "armed_home - device - alarm_control_panel.entity - pending - armed_home - None"
)
# Fake that the entity is armed away.
@@ -239,10 +236,9 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY)
await hass.async_block_till_done()
assert len(calls) == 4
- assert calls[3].data[
- "some"
- ] == "armed_away - device - {} - pending - armed_away - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[3].data["some"]
+ == "armed_away - device - alarm_control_panel.entity - pending - armed_away - None"
)
# Fake that the entity is armed night.
@@ -250,8 +246,7 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT)
await hass.async_block_till_done()
assert len(calls) == 5
- assert calls[4].data[
- "some"
- ] == "armed_night - device - {} - pending - armed_night - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[4].data["some"]
+ == "armed_night - device - alarm_control_panel.entity - pending - armed_night - None"
)
diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py
index 55a3112c32f7d5..d4de97f3b465b8 100644
--- a/tests/components/alert/test_init.py
+++ b/tests/components/alert/test_init.py
@@ -60,7 +60,7 @@
None,
None,
]
-ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME)
+ENTITY_ID = f"{alert.DOMAIN}.{NAME}"
def turn_on(hass, entity_id):
diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py
index d3fe28d227d871..d459ee2cc3227c 100644
--- a/tests/components/alexa/test_flash_briefings.py
+++ b/tests/components/alexa/test_flash_briefings.py
@@ -63,7 +63,7 @@ def mock_service(call):
def _flash_briefing_req(client, briefing_id):
- return client.get("/api/alexa/flash_briefings/{}".format(briefing_id))
+ return client.get(f"/api/alexa/flash_briefings/{briefing_id}")
async def test_flash_briefing_invalid_id(alexa_client):
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index 161f69287d4945..a714b69461cdeb 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -16,6 +16,7 @@
SUPPORT_TURN_ON,
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP,
)
import homeassistant.components.vacuum as vacuum
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
@@ -131,7 +132,7 @@ async def discovery_test(device, hass, expected_endpoints=1):
def get_capability(capabilities, capability_name, instance=None):
"""Search a set of capabilities for a specific one."""
for capability in capabilities:
- if instance and capability["instance"] == instance:
+ if instance and capability.get("instance") == instance:
return capability
if not instance and capability["interface"] == capability_name:
return capability
@@ -669,7 +670,7 @@ async def test_fan_range(hass):
{
"friendly_name": "Test fan 5",
"supported_features": 1,
- "speed_list": ["off", "low", "medium", "high", "turbo", "warp_speed"],
+ "speed_list": ["off", "low", "medium", "high", "turbo", 5, "warp_speed"],
"speed": "medium",
},
)
@@ -705,7 +706,7 @@ async def test_fan_range(hass):
supported_range = configuration["supportedRange"]
assert supported_range["minimumValue"] == 0
- assert supported_range["maximumValue"] == 5
+ assert supported_range["maximumValue"] == 6
assert supported_range["precision"] == 1
presets = configuration["presets"]
@@ -737,8 +738,10 @@ async def test_fan_range(hass):
},
} in presets
+ assert {"rangeValue": 5} not in presets
+
assert {
- "rangeValue": 5,
+ "rangeValue": 6,
"presetResources": {
"friendlyNames": [
{"@type": "text", "value": {"text": "warp speed", "locale": "en-US"}},
@@ -753,7 +756,7 @@ async def test_fan_range(hass):
"fan#test_5",
"fan.set_speed",
hass,
- payload={"rangeValue": "1"},
+ payload={"rangeValue": 1},
instance="fan.speed",
)
assert call.data["speed"] == "low"
@@ -764,18 +767,33 @@ async def test_fan_range(hass):
"fan#test_5",
"fan.set_speed",
hass,
- payload={"rangeValue": "5"},
+ payload={"rangeValue": 5},
+ instance="fan.speed",
+ )
+ assert call.data["speed"] == 5
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.RangeController",
+ "SetRangeValue",
+ "fan#test_5",
+ "fan.set_speed",
+ hass,
+ payload={"rangeValue": 6},
instance="fan.speed",
)
assert call.data["speed"] == "warp_speed"
await assert_range_changes(
hass,
- [("low", "-1"), ("high", "1"), ("medium", "0"), ("warp_speed", "99")],
+ [
+ ("low", -1, False),
+ ("high", 1, False),
+ ("medium", 0, False),
+ ("warp_speed", 99, False),
+ ],
"Alexa.RangeController",
"AdjustRangeValue",
"fan#test_5",
- False,
"fan.set_speed",
"speed",
instance="fan.speed",
@@ -802,18 +820,17 @@ async def test_fan_range_off(hass):
"fan#test_6",
"fan.turn_off",
hass,
- payload={"rangeValue": "0"},
+ payload={"rangeValue": 0},
instance="fan.speed",
)
assert call.data["speed"] == "off"
await assert_range_changes(
hass,
- [("off", "-3"), ("off", "-99")],
+ [("off", -3, False), ("off", -99, False)],
"Alexa.RangeController",
"AdjustRangeValue",
"fan#test_6",
- False,
"fan.turn_off",
"speed",
instance="fan.speed",
@@ -870,6 +887,7 @@ async def test_media_player(hass):
| SUPPORT_VOLUME_MUTE
| SUPPORT_VOLUME_SET,
"volume_level": 0.75,
+ "source_list": ["hdmi", "tv"],
},
)
appliance = await discovery_test(device, hass)
@@ -888,7 +906,6 @@ async def test_media_player(hass):
"Alexa.PlaybackStateReporter",
"Alexa.PowerController",
"Alexa.Speaker",
- "Alexa.StepSpeaker",
)
playback_capability = get_capability(capabilities, "Alexa.PlaybackController")
@@ -942,93 +959,6 @@ async def test_media_player(hass):
hass,
)
- call, _ = await assert_request_calls_service(
- "Alexa.Speaker",
- "SetVolume",
- "media_player#test",
- "media_player.volume_set",
- hass,
- payload={"volume": 50},
- )
- assert call.data["volume_level"] == 0.5
-
- call, _ = await assert_request_calls_service(
- "Alexa.Speaker",
- "SetMute",
- "media_player#test",
- "media_player.volume_mute",
- hass,
- payload={"mute": True},
- )
- assert call.data["is_volume_muted"]
-
- call, _, = await assert_request_calls_service(
- "Alexa.Speaker",
- "SetMute",
- "media_player#test",
- "media_player.volume_mute",
- hass,
- payload={"mute": False},
- )
- assert not call.data["is_volume_muted"]
-
- await assert_percentage_changes(
- hass,
- [(0.7, "-5"), (0.8, "5"), (0, "-80")],
- "Alexa.Speaker",
- "AdjustVolume",
- "media_player#test",
- "volume",
- "media_player.volume_set",
- "volume_level",
- )
-
- call, _ = await assert_request_calls_service(
- "Alexa.StepSpeaker",
- "SetMute",
- "media_player#test",
- "media_player.volume_mute",
- hass,
- payload={"mute": True},
- )
- assert call.data["is_volume_muted"]
-
- call, _, = await assert_request_calls_service(
- "Alexa.StepSpeaker",
- "SetMute",
- "media_player#test",
- "media_player.volume_mute",
- hass,
- payload={"mute": False},
- )
- assert not call.data["is_volume_muted"]
-
- call, _ = await assert_request_calls_service(
- "Alexa.StepSpeaker",
- "AdjustVolume",
- "media_player#test",
- "media_player.volume_up",
- hass,
- payload={"volumeSteps": 1, "volumeStepsDefault": False},
- )
-
- call, _ = await assert_request_calls_service(
- "Alexa.StepSpeaker",
- "AdjustVolume",
- "media_player#test",
- "media_player.volume_down",
- hass,
- payload={"volumeSteps": -1, "volumeStepsDefault": False},
- )
-
- call, _ = await assert_request_calls_service(
- "Alexa.StepSpeaker",
- "AdjustVolume",
- "media_player#test",
- "media_player.volume_up",
- hass,
- payload={"volumeSteps": 10, "volumeStepsDefault": True},
- )
call, _ = await assert_request_calls_service(
"Alexa.ChannelController",
"ChangeChannel",
@@ -1118,13 +1048,11 @@ async def test_media_player_power(hass):
"Alexa",
"Alexa.ChannelController",
"Alexa.EndpointHealth",
- "Alexa.InputController",
"Alexa.PlaybackController",
"Alexa.PlaybackStateReporter",
"Alexa.PowerController",
"Alexa.SeekController",
"Alexa.Speaker",
- "Alexa.StepSpeaker",
)
await assert_request_calls_service(
@@ -1248,23 +1176,177 @@ async def test_media_player_inputs(hass):
assert call.data["source"] == "tv"
-async def test_media_player_speaker(hass):
- """Test media player discovery with device class speaker."""
+async def test_media_player_no_supported_inputs(hass):
+ """Test media player discovery with no supported inputs."""
device = (
- "media_player.test",
+ "media_player.test_no_inputs",
"off",
{
"friendly_name": "Test media player",
- "supported_features": 51765,
+ "supported_features": SUPPORT_SELECT_SOURCE,
+ "volume_level": 0.75,
+ "source_list": [
+ "foo",
+ "foo_2",
+ "vcr",
+ "betamax",
+ "record_player",
+ "f.m.",
+ "a.m.",
+ "tape_deck",
+ "laser_disc",
+ "hd_dvd",
+ ],
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance["endpointId"] == "media_player#test_no_inputs"
+ assert appliance["displayCategories"][0] == "TV"
+ assert appliance["friendlyName"] == "Test media player"
+
+ # Assert Alexa.InputController is not in capabilities list.
+ assert_endpoint_capabilities(
+ appliance, "Alexa", "Alexa.EndpointHealth", "Alexa.PowerController"
+ )
+
+
+async def test_media_player_speaker(hass):
+ """Test media player with speaker interface."""
+ device = (
+ "media_player.test_speaker",
+ "off",
+ {
+ "friendly_name": "Test media player speaker",
+ "supported_features": SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET,
"volume_level": 0.75,
"device_class": "speaker",
},
)
appliance = await discovery_test(device, hass)
- assert appliance["endpointId"] == "media_player#test"
+ assert appliance["endpointId"] == "media_player#test_speaker"
assert appliance["displayCategories"][0] == "SPEAKER"
- assert appliance["friendlyName"] == "Test media player"
+ assert appliance["friendlyName"] == "Test media player speaker"
+
+ capabilities = assert_endpoint_capabilities(
+ appliance,
+ "Alexa",
+ "Alexa.EndpointHealth",
+ "Alexa.PowerController",
+ "Alexa.Speaker",
+ )
+
+ speaker_capability = get_capability(capabilities, "Alexa.Speaker")
+ properties = speaker_capability["properties"]
+ assert {"name": "volume"} in properties["supported"]
+ assert {"name": "muted"} in properties["supported"]
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.Speaker",
+ "SetVolume",
+ "media_player#test_speaker",
+ "media_player.volume_set",
+ hass,
+ payload={"volume": 50},
+ )
+ assert call.data["volume_level"] == 0.5
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.Speaker",
+ "SetMute",
+ "media_player#test_speaker",
+ "media_player.volume_mute",
+ hass,
+ payload={"mute": True},
+ )
+ assert call.data["is_volume_muted"]
+
+ call, _, = await assert_request_calls_service(
+ "Alexa.Speaker",
+ "SetMute",
+ "media_player#test_speaker",
+ "media_player.volume_mute",
+ hass,
+ payload={"mute": False},
+ )
+ assert not call.data["is_volume_muted"]
+
+ await assert_percentage_changes(
+ hass,
+ [(0.7, "-5"), (0.8, "5"), (0, "-80")],
+ "Alexa.Speaker",
+ "AdjustVolume",
+ "media_player#test_speaker",
+ "volume",
+ "media_player.volume_set",
+ "volume_level",
+ )
+
+
+async def test_media_player_step_speaker(hass):
+ """Test media player with step speaker interface."""
+ device = (
+ "media_player.test_step_speaker",
+ "off",
+ {
+ "friendly_name": "Test media player step speaker",
+ "supported_features": SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP,
+ "device_class": "speaker",
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance["endpointId"] == "media_player#test_step_speaker"
+ assert appliance["displayCategories"][0] == "SPEAKER"
+ assert appliance["friendlyName"] == "Test media player step speaker"
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.StepSpeaker",
+ "SetMute",
+ "media_player#test_step_speaker",
+ "media_player.volume_mute",
+ hass,
+ payload={"mute": True},
+ )
+ assert call.data["is_volume_muted"]
+
+ call, _, = await assert_request_calls_service(
+ "Alexa.StepSpeaker",
+ "SetMute",
+ "media_player#test_step_speaker",
+ "media_player.volume_mute",
+ hass,
+ payload={"mute": False},
+ )
+ assert not call.data["is_volume_muted"]
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.StepSpeaker",
+ "AdjustVolume",
+ "media_player#test_step_speaker",
+ "media_player.volume_up",
+ hass,
+ payload={"volumeSteps": 1, "volumeStepsDefault": False},
+ )
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.StepSpeaker",
+ "AdjustVolume",
+ "media_player#test_step_speaker",
+ "media_player.volume_down",
+ hass,
+ payload={"volumeSteps": -1, "volumeStepsDefault": False},
+ )
+
+ call, _ = await assert_request_calls_service(
+ "Alexa.StepSpeaker",
+ "AdjustVolume",
+ "media_player#test_step_speaker",
+ "media_player.volume_up",
+ hass,
+ payload={"volumeSteps": 10, "volumeStepsDefault": True},
+ )
async def test_media_player_seek(hass):
@@ -1452,7 +1534,11 @@ async def test_cover_position_range(hass):
assert appliance["friendlyName"] == "Test cover range"
capabilities = assert_endpoint_capabilities(
- appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa"
+ appliance,
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.EndpointHealth",
+ "Alexa",
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
@@ -1520,7 +1606,7 @@ async def test_cover_position_range(hass):
"cover#test_range",
"cover.set_cover_position",
hass,
- payload={"rangeValue": "50"},
+ payload={"rangeValue": 50},
instance="cover.position",
)
assert call.data["position"] == 50
@@ -1531,7 +1617,7 @@ async def test_cover_position_range(hass):
"cover#test_range",
"cover.close_cover",
hass,
- payload={"rangeValue": "0"},
+ payload={"rangeValue": 0},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
@@ -1545,7 +1631,7 @@ async def test_cover_position_range(hass):
"cover#test_range",
"cover.open_cover",
hass,
- payload={"rangeValue": "100"},
+ payload={"rangeValue": 100},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
@@ -1559,7 +1645,7 @@ async def test_cover_position_range(hass):
"cover#test_range",
"cover.open_cover",
hass,
- payload={"rangeValueDelta": "99"},
+ payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
@@ -1573,7 +1659,7 @@ async def test_cover_position_range(hass):
"cover#test_range",
"cover.close_cover",
hass,
- payload={"rangeValueDelta": "-99"},
+ payload={"rangeValueDelta": -99, "rangeValueDeltaDefault": False},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
@@ -1583,11 +1669,10 @@ async def test_cover_position_range(hass):
await assert_range_changes(
hass,
- [(25, "-5"), (35, "5")],
+ [(25, -5, False), (35, 5, False), (50, 1, True), (10, -1, True)],
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_range",
- False,
"cover.set_cover_position",
"position",
instance="cover.position",
@@ -1614,21 +1699,13 @@ async def assert_percentage_changes(
async def assert_range_changes(
- hass,
- adjustments,
- namespace,
- name,
- endpoint,
- delta_default,
- service,
- changed_parameter,
- instance,
+ hass, adjustments, namespace, name, endpoint, service, changed_parameter, instance
):
"""Assert an API request making range changes works.
AdjustRangeValue are examples of such requests.
"""
- for result_range, adjustment in adjustments:
+ for result_range, adjustment, delta_default in adjustments:
payload = {
"rangeValueDelta": adjustment,
"rangeValueDeltaDefault": delta_default,
@@ -2353,6 +2430,7 @@ async def test_alarm_control_panel_disarmed(hass):
"code_arm_required": False,
"code_format": "number",
"code": "1234",
+ "supported_features": 31,
},
)
appliance = await discovery_test(device, hass)
@@ -2369,6 +2447,10 @@ async def test_alarm_control_panel_disarmed(hass):
assert security_panel_capability is not None
configuration = security_panel_capability["configuration"]
assert {"type": "FOUR_DIGIT_PIN"} in configuration["supportedAuthorizationTypes"]
+ assert {"value": "DISARMED"} in configuration["supportedArmStates"]
+ assert {"value": "ARMED_STAY"} in configuration["supportedArmStates"]
+ assert {"value": "ARMED_AWAY"} in configuration["supportedArmStates"]
+ assert {"value": "ARMED_NIGHT"} in configuration["supportedArmStates"]
properties = await reported_properties(hass, "alarm_control_panel#test_1")
properties.assert_equal("Alexa.SecurityPanelController", "armState", "DISARMED")
@@ -2420,6 +2502,7 @@ async def test_alarm_control_panel_armed(hass):
"code_arm_required": False,
"code_format": "FORMAT_NUMBER",
"code": "1234",
+ "supported_features": 3,
},
)
appliance = await discovery_test(device, hass)
@@ -2458,11 +2541,15 @@ async def test_alarm_control_panel_armed(hass):
async def test_alarm_control_panel_code_arm_required(hass):
- """Test alarm_control_panel with code_arm_required discovery."""
+ """Test alarm_control_panel with code_arm_required not in discovery."""
device = (
"alarm_control_panel.test_3",
"disarmed",
- {"friendly_name": "Test Alarm Control Panel 3", "code_arm_required": True},
+ {
+ "friendly_name": "Test Alarm Control Panel 3",
+ "code_arm_required": True,
+ "supported_features": 3,
+ },
)
await discovery_test(device, hass, expected_endpoints=0)
@@ -2474,7 +2561,7 @@ async def test_range_unsupported_domain(hass):
context = Context()
request = get_new_request("Alexa.RangeController", "SetRangeValue", "switch#test")
- request["directive"]["payload"] = {"rangeValue": "1"}
+ request["directive"]["payload"] = {"rangeValue": 1}
request["directive"]["header"]["instance"] = "switch.speed"
msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context)
@@ -2505,6 +2592,36 @@ async def test_mode_unsupported_domain(hass):
assert msg["payload"]["type"] == "INVALID_DIRECTIVE"
+async def test_cover(hass):
+ """Test garage cover discovery and powerController."""
+ device = (
+ "cover.test",
+ "off",
+ {
+ "friendly_name": "Test cover",
+ "supported_features": 3,
+ "device_class": "garage",
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance["endpointId"] == "cover#test"
+ assert appliance["displayCategories"][0] == "GARAGE_DOOR"
+ assert appliance["friendlyName"] == "Test cover"
+
+ assert_endpoint_capabilities(
+ appliance,
+ "Alexa.ModeController",
+ "Alexa.PowerController",
+ "Alexa.EndpointHealth",
+ "Alexa",
+ )
+
+ await assert_power_controller_works(
+ "cover#test", "cover.open_cover", "cover.close_cover", hass
+ )
+
+
async def test_cover_position_mode(hass):
"""Test cover discovery and position using modeController."""
device = (
@@ -2523,7 +2640,11 @@ async def test_cover_position_mode(hass):
assert appliance["friendlyName"] == "Test cover mode"
capabilities = assert_endpoint_capabilities(
- appliance, "Alexa", "Alexa.ModeController", "Alexa.EndpointHealth"
+ appliance,
+ "Alexa.PowerController",
+ "Alexa.ModeController",
+ "Alexa.EndpointHealth",
+ "Alexa",
)
mode_capability = get_capability(capabilities, "Alexa.ModeController")
@@ -2742,7 +2863,11 @@ async def test_cover_tilt_position_range(hass):
assert appliance["friendlyName"] == "Test cover tilt range"
capabilities = assert_endpoint_capabilities(
- appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa"
+ appliance,
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.EndpointHealth",
+ "Alexa",
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
@@ -2764,10 +2889,10 @@ async def test_cover_tilt_position_range(hass):
"cover#test_tilt_range",
"cover.set_cover_tilt_position",
hass,
- payload={"rangeValue": "50"},
+ payload={"rangeValue": 50},
instance="cover.tilt",
)
- assert call.data["position"] == 50
+ assert call.data["tilt_position"] == 50
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
@@ -2775,7 +2900,7 @@ async def test_cover_tilt_position_range(hass):
"cover#test_tilt_range",
"cover.close_cover_tilt",
hass,
- payload={"rangeValue": "0"},
+ payload={"rangeValue": 0},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
@@ -2789,7 +2914,7 @@ async def test_cover_tilt_position_range(hass):
"cover#test_tilt_range",
"cover.open_cover_tilt",
hass,
- payload={"rangeValue": "100"},
+ payload={"rangeValue": 100},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
@@ -2803,7 +2928,7 @@ async def test_cover_tilt_position_range(hass):
"cover#test_tilt_range",
"cover.open_cover_tilt",
hass,
- payload={"rangeValueDelta": "99"},
+ payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
@@ -2817,7 +2942,7 @@ async def test_cover_tilt_position_range(hass):
"cover#test_tilt_range",
"cover.close_cover_tilt",
hass,
- payload={"rangeValueDelta": "-99"},
+ payload={"rangeValueDelta": -99, "rangeValueDeltaDefault": False},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
@@ -2827,11 +2952,10 @@ async def test_cover_tilt_position_range(hass):
await assert_range_changes(
hass,
- [(25, "-5"), (35, "5")],
+ [(25, -5, False), (35, 5, False), (50, 1, True), (10, -1, True)],
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_tilt_range",
- False,
"cover.set_cover_tilt_position",
"tilt_position",
instance="cover.tilt",
@@ -2858,7 +2982,11 @@ async def test_cover_semantics_position_and_tilt(hass):
assert appliance["friendlyName"] == "Test cover semantics"
capabilities = assert_endpoint_capabilities(
- appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa"
+ appliance,
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.EndpointHealth",
+ "Alexa",
)
# Assert for Position Semantics
@@ -2982,18 +3110,17 @@ async def test_input_number(hass):
"input_number#test_slider",
"input_number.set_value",
hass,
- payload={"rangeValue": "10"},
+ payload={"rangeValue": 10},
instance="input_number.value",
)
assert call.data["value"] == 10
await assert_range_changes(
hass,
- [(25, "-5"), (35, "5"), (-20, "-100"), (35, "100")],
+ [(25, -5, False), (35, 5, False), (-20, -100, False), (35, 100, False)],
"Alexa.RangeController",
"AdjustRangeValue",
"input_number#test_slider",
- False,
"input_number.set_value",
"value",
instance="input_number.value",
@@ -3068,18 +3195,23 @@ async def test_input_number_float(hass):
"input_number#test_slider_float",
"input_number.set_value",
hass,
- payload={"rangeValue": "0.333"},
+ payload={"rangeValue": 0.333},
instance="input_number.value",
)
assert call.data["value"] == 0.333
await assert_range_changes(
hass,
- [(0.4, "-0.1"), (0.6, "0.1"), (0, "-100"), (1, "100"), (0.51, "0.01")],
+ [
+ (0.4, -0.1, False),
+ (0.6, 0.1, False),
+ (0, -100, False),
+ (1, 100, False),
+ (0.51, 0.01, False),
+ ],
"Alexa.RangeController",
"AdjustRangeValue",
"input_number#test_slider_float",
- False,
"input_number.set_value",
"value",
instance="input_number.value",
@@ -3376,7 +3508,7 @@ async def test_vacuum_fan_speed(hass):
"vacuum#test_2",
"vacuum.set_fan_speed",
hass,
- payload={"rangeValue": "1"},
+ payload={"rangeValue": 1},
instance="vacuum.fan_speed",
)
assert call.data["fan_speed"] == "low"
@@ -3387,18 +3519,22 @@ async def test_vacuum_fan_speed(hass):
"vacuum#test_2",
"vacuum.set_fan_speed",
hass,
- payload={"rangeValue": "5"},
+ payload={"rangeValue": 5},
instance="vacuum.fan_speed",
)
assert call.data["fan_speed"] == "super_sucker"
await assert_range_changes(
hass,
- [("low", "-1"), ("high", "1"), ("medium", "0"), ("super_sucker", "99")],
+ [
+ ("low", -1, False),
+ ("high", 1, False),
+ ("medium", 0, False),
+ ("super_sucker", 99, False),
+ ],
"Alexa.RangeController",
"AdjustRangeValue",
"vacuum#test_2",
- False,
"vacuum.set_fan_speed",
"fan_speed",
instance="vacuum.fan_speed",
diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py
index 0b402ed407ddc0..0b4869ee2a6f4c 100644
--- a/tests/components/almond/test_config_flow.py
+++ b/tests/components/almond/test_config_flow.py
@@ -1,6 +1,7 @@
"""Test the Almond config flow."""
import asyncio
-from unittest.mock import patch
+
+from asynctest import patch
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.almond import config_flow
@@ -55,7 +56,12 @@ async def test_hassio(hass):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "hassio_confirm"
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ with patch(
+ "homeassistant.components.almond.async_setup_entry", return_value=True
+ ) as mock_setup:
+ result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+
+ assert len(mock_setup.mock_calls) == 1
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -128,7 +134,12 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
},
)
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ with patch(
+ "homeassistant.components.almond.async_setup_entry", return_value=True
+ ) as mock_setup:
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert len(mock_setup.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py
index 25e4609000951d..a64b776133805b 100644
--- a/tests/components/ambient_station/test_config_flow.py
+++ b/tests/components/ambient_station/test_config_flow.py
@@ -7,6 +7,7 @@
from homeassistant import data_entry_flow
from homeassistant.components.ambient_station import CONF_APP_KEY, DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY
from tests.common import MockConfigEntry, load_fixture, mock_coro
@@ -30,12 +31,16 @@ async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.AmbientStationFlowHandler()
- flow.hass = hass
+ MockConfigEntry(
+ domain=DOMAIN, unique_id="67890fghij67890fghij", data=conf
+ ).add_to_hass(hass)
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_APP_KEY: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
@@ -47,6 +52,7 @@ async def test_invalid_api_key(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "invalid_key"}
@@ -59,6 +65,7 @@ async def test_no_devices(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "no_devices"}
@@ -68,6 +75,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -85,6 +93,7 @@ async def test_step_import(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_import(import_config=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -105,6 +114,7 @@ async def test_step_user(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py
index 0aaa870c57b4d8..82287877eaf0ba 100644
--- a/tests/components/androidtv/test_media_player.py
+++ b/tests/components/androidtv/test_media_player.py
@@ -12,6 +12,7 @@
CONF_ADB_SERVER_IP,
CONF_ADBKEY,
CONF_APPS,
+ CONF_EXCLUDE_UNNAMED_APPS,
KEYS,
SERVICE_ADB_COMMAND,
SERVICE_DOWNLOAD,
@@ -28,6 +29,7 @@
CONF_HOST,
CONF_NAME,
CONF_PLATFORM,
+ SERVICE_VOLUME_SET,
STATE_IDLE,
STATE_OFF,
STATE_PLAYING,
@@ -299,7 +301,11 @@ async def test_setup_with_adbkey(hass):
async def _test_sources(hass, config0):
"""Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices."""
config = config0.copy()
- config[DOMAIN][CONF_APPS] = {"com.app.test1": "TEST 1"}
+ config[DOMAIN][CONF_APPS] = {
+ "com.app.test1": "TEST 1",
+ "com.app.test3": None,
+ "com.app.test4": "",
+ }
patch_key, entity_id = _setup(config)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
@@ -315,14 +321,16 @@ async def _test_sources(hass, config0):
patch_update = patchers.patch_androidtv_update(
"playing",
"com.app.test1",
- ["com.app.test1", "com.app.test2"],
+ ["com.app.test1", "com.app.test2", "com.app.test3", "com.app.test4"],
"hdmi",
False,
1,
)
else:
patch_update = patchers.patch_firetv_update(
- "playing", "com.app.test1", ["com.app.test1", "com.app.test2"]
+ "playing",
+ "com.app.test1",
+ ["com.app.test1", "com.app.test2", "com.app.test3", "com.app.test4"],
)
with patch_update:
@@ -331,20 +339,22 @@ async def _test_sources(hass, config0):
assert state is not None
assert state.state == STATE_PLAYING
assert state.attributes["source"] == "TEST 1"
- assert state.attributes["source_list"] == ["TEST 1", "com.app.test2"]
+ assert sorted(state.attributes["source_list"]) == ["TEST 1", "com.app.test2"]
if config[DOMAIN].get(CONF_DEVICE_CLASS) != "firetv":
patch_update = patchers.patch_androidtv_update(
"playing",
"com.app.test2",
- ["com.app.test2", "com.app.test1"],
+ ["com.app.test2", "com.app.test1", "com.app.test3", "com.app.test4"],
"hdmi",
True,
0,
)
else:
patch_update = patchers.patch_firetv_update(
- "playing", "com.app.test2", ["com.app.test2", "com.app.test1"]
+ "playing",
+ "com.app.test2",
+ ["com.app.test2", "com.app.test1", "com.app.test3", "com.app.test4"],
)
with patch_update:
@@ -353,7 +363,7 @@ async def _test_sources(hass, config0):
assert state is not None
assert state.state == STATE_PLAYING
assert state.attributes["source"] == "com.app.test2"
- assert state.attributes["source_list"] == ["com.app.test2", "TEST 1"]
+ assert sorted(state.attributes["source_list"]) == ["TEST 1", "com.app.test2"]
return True
@@ -368,10 +378,82 @@ async def test_firetv_sources(hass):
assert await _test_sources(hass, CONFIG_FIRETV_ADB_SERVER)
+async def _test_exclude_sources(hass, config0, expected_sources):
+ """Test that sources (i.e., apps) are handled correctly when the `exclude_unnamed_apps` config parameter is provided."""
+ config = config0.copy()
+ config[DOMAIN][CONF_APPS] = {
+ "com.app.test1": "TEST 1",
+ "com.app.test3": None,
+ "com.app.test4": "",
+ }
+ patch_key, entity_id = _setup(config)
+
+ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
+ patch_key
+ ], patchers.patch_shell("")[patch_key]:
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.helpers.entity_component.async_update_entity(entity_id)
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_OFF
+
+ if config[DOMAIN].get(CONF_DEVICE_CLASS) != "firetv":
+ patch_update = patchers.patch_androidtv_update(
+ "playing",
+ "com.app.test1",
+ [
+ "com.app.test1",
+ "com.app.test2",
+ "com.app.test3",
+ "com.app.test4",
+ "com.app.test5",
+ ],
+ "hdmi",
+ False,
+ 1,
+ )
+ else:
+ patch_update = patchers.patch_firetv_update(
+ "playing",
+ "com.app.test1",
+ [
+ "com.app.test1",
+ "com.app.test2",
+ "com.app.test3",
+ "com.app.test4",
+ "com.app.test5",
+ ],
+ )
+
+ with patch_update:
+ await hass.helpers.entity_component.async_update_entity(entity_id)
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_PLAYING
+ assert state.attributes["source"] == "TEST 1"
+ assert sorted(state.attributes["source_list"]) == expected_sources
+
+ return True
+
+
+async def test_androidtv_exclude_sources(hass):
+ """Test that sources (i.e., apps) are handled correctly for Android TV devices when the `exclude_unnamed_apps` config parameter is provided as true."""
+ config = CONFIG_ANDROIDTV_ADB_SERVER.copy()
+ config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True
+ assert await _test_exclude_sources(hass, config, ["TEST 1"])
+
+
+async def test_firetv_exclude_sources(hass):
+ """Test that sources (i.e., apps) are handled correctly for Fire TV devices when the `exclude_unnamed_apps` config parameter is provided as true."""
+ config = CONFIG_FIRETV_ADB_SERVER.copy()
+ config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True
+ assert await _test_exclude_sources(hass, config, ["TEST 1"])
+
+
async def _test_select_source(hass, config0, source, expected_arg, method_patch):
"""Test that the methods for launching and stopping apps are called correctly when selecting a source."""
config = config0.copy()
- config[DOMAIN][CONF_APPS] = {"com.app.test1": "TEST 1"}
+ config[DOMAIN][CONF_APPS] = {"com.app.test1": "TEST 1", "com.app.test3": None}
patch_key, entity_id = _setup(config)
with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
@@ -428,6 +510,17 @@ async def test_androidtv_select_source_launch_app_id_no_name(hass):
)
+async def test_androidtv_select_source_launch_app_hidden(hass):
+ """Test that an app can be launched using its app ID when it is hidden from the sources list."""
+ assert await _test_select_source(
+ hass,
+ CONFIG_ANDROIDTV_ADB_SERVER,
+ "com.app.test3",
+ "com.app.test3",
+ patchers.PATCH_LAUNCH_APP,
+ )
+
+
async def test_androidtv_select_source_stop_app_id(hass):
"""Test that an app can be stopped using its app ID."""
assert await _test_select_source(
@@ -461,6 +554,17 @@ async def test_androidtv_select_source_stop_app_id_no_name(hass):
)
+async def test_androidtv_select_source_stop_app_hidden(hass):
+ """Test that an app can be stopped using its app ID when it is hidden from the sources list."""
+ assert await _test_select_source(
+ hass,
+ CONFIG_ANDROIDTV_ADB_SERVER,
+ "!com.app.test3",
+ "com.app.test3",
+ patchers.PATCH_STOP_APP,
+ )
+
+
async def test_firetv_select_source_launch_app_id(hass):
"""Test that an app can be launched using its app ID."""
assert await _test_select_source(
@@ -494,6 +598,17 @@ async def test_firetv_select_source_launch_app_id_no_name(hass):
)
+async def test_firetv_select_source_launch_app_hidden(hass):
+ """Test that an app can be launched using its app ID when it is hidden from the sources list."""
+ assert await _test_select_source(
+ hass,
+ CONFIG_FIRETV_ADB_SERVER,
+ "com.app.test3",
+ "com.app.test3",
+ patchers.PATCH_LAUNCH_APP,
+ )
+
+
async def test_firetv_select_source_stop_app_id(hass):
"""Test that an app can be stopped using its app ID."""
assert await _test_select_source(
@@ -527,6 +642,17 @@ async def test_firetv_select_source_stop_app_id_no_name(hass):
)
+async def test_firetv_select_source_stop_hidden(hass):
+ """Test that an app can be stopped using its app ID when it is hidden from the sources list."""
+ assert await _test_select_source(
+ hass,
+ CONFIG_FIRETV_ADB_SERVER,
+ "!com.app.test3",
+ "com.app.test3",
+ patchers.PATCH_STOP_APP,
+ )
+
+
async def _test_setup_fail(hass, config):
"""Test that the entity is not created when the ADB connection is not established."""
patch_key, entity_id = _setup(config)
@@ -820,3 +946,25 @@ async def test_upload(hass):
blocking=True,
)
patch_push.assert_called_with(local_path, device_path)
+
+
+async def test_androidtv_volume_set(hass):
+ """Test setting the volume for an Android TV device."""
+ patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER)
+
+ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
+ patch_key
+ ], patchers.patch_shell("")[patch_key]:
+ assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER)
+
+ with patch(
+ "androidtv.basetv.BaseTV.set_volume_level", return_value=0.5
+ ) as patch_set_volume_level:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_VOLUME_SET,
+ {ATTR_ENTITY_ID: entity_id, "volume_level": 0.5},
+ blocking=True,
+ )
+
+ patch_set_volume_level.assert_called_with(0.5)
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index dbc08a43bfa112..4417f3d4f91fe1 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -36,7 +36,7 @@ async def test_api_list_state_entities(hass, mock_api_client):
async def test_api_get_state(hass, mock_api_client):
"""Test if the debug interface allows us to get a state."""
hass.states.async_set("hello.world", "nice", {"attr": 1})
- resp = await mock_api_client.get(const.URL_API_STATES_ENTITY.format("hello.world"))
+ resp = await mock_api_client.get("/api/states/hello.world")
assert resp.status == 200
json = await resp.json()
@@ -51,9 +51,7 @@ async def test_api_get_state(hass, mock_api_client):
async def test_api_get_non_existing_state(hass, mock_api_client):
"""Test if the debug interface allows us to get a state."""
- resp = await mock_api_client.get(
- const.URL_API_STATES_ENTITY.format("does_not_exist")
- )
+ resp = await mock_api_client.get("/api/states/does_not_exist")
assert resp.status == 404
@@ -62,8 +60,7 @@ async def test_api_state_change(hass, mock_api_client):
hass.states.async_set("test.test", "not_to_be_set")
await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test.test"),
- json={"state": "debug_state_change2"},
+ "/api/states/test.test", json={"state": "debug_state_change2"}
)
assert hass.states.get("test.test").state == "debug_state_change2"
@@ -75,8 +72,7 @@ async def test_api_state_change_of_non_existing_entity(hass, mock_api_client):
new_state = "debug_state_change"
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"),
- json={"state": new_state},
+ "/api/states/test_entity.that_does_not_exist", json={"state": new_state}
)
assert resp.status == 201
@@ -88,7 +84,7 @@ async def test_api_state_change_of_non_existing_entity(hass, mock_api_client):
async def test_api_state_change_with_bad_data(hass, mock_api_client):
"""Test if API sends appropriate error if we omit state."""
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"), json={}
+ "/api/states/test_entity.that_does_not_exist", json={}
)
assert resp.status == 400
@@ -98,15 +94,13 @@ async def test_api_state_change_with_bad_data(hass, mock_api_client):
async def test_api_state_change_to_zero_value(hass, mock_api_client):
"""Test if changing a state to a zero value is possible."""
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"),
- json={"state": 0},
+ "/api/states/test_entity.with_zero_state", json={"state": 0}
)
assert resp.status == 201
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"),
- json={"state": 0.0},
+ "/api/states/test_entity.with_zero_state", json={"state": 0.0}
)
assert resp.status == 200
@@ -126,15 +120,12 @@ def event_listener(event):
hass.bus.async_listen(const.EVENT_STATE_CHANGED, event_listener)
- await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test.test"), json={"state": "not_to_be_set"}
- )
+ await mock_api_client.post("/api/states/test.test", json={"state": "not_to_be_set"})
await hass.async_block_till_done()
assert len(events) == 0
await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test.test"),
- json={"state": "not_to_be_set", "force_update": True},
+ "/api/states/test.test", json={"state": "not_to_be_set", "force_update": True}
)
await hass.async_block_till_done()
assert len(events) == 1
@@ -152,7 +143,7 @@ def listener(event):
hass.bus.async_listen_once("test.event_no_data", listener)
- await mock_api_client.post(const.URL_API_EVENTS_EVENT.format("test.event_no_data"))
+ await mock_api_client.post("/api/events/test.event_no_data")
await hass.async_block_till_done()
assert len(test_value) == 1
@@ -174,9 +165,7 @@ def listener(event):
hass.bus.async_listen_once("test_event_with_data", listener)
- await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test_event_with_data"), json={"test": 1}
- )
+ await mock_api_client.post("/api/events/test_event_with_data", json={"test": 1})
await hass.async_block_till_done()
@@ -196,8 +185,7 @@ def listener(event):
hass.bus.async_listen_once("test_event_bad_data", listener)
resp = await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test_event_bad_data"),
- data=json.dumps("not an object"),
+ "/api/events/test_event_bad_data", data=json.dumps("not an object")
)
await hass.async_block_till_done()
@@ -207,8 +195,7 @@ def listener(event):
# Try now with valid but unusable JSON
resp = await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test_event_bad_data"),
- data=json.dumps([1, 2, 3]),
+ "/api/events/test_event_bad_data", data=json.dumps([1, 2, 3])
)
await hass.async_block_till_done()
@@ -272,9 +259,7 @@ def listener(service_call):
hass.services.async_register("test_domain", "test_service", listener)
- await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service")
- )
+ await mock_api_client.post("/api/services/test_domain/test_service")
await hass.async_block_till_done()
assert len(test_value) == 1
@@ -295,8 +280,7 @@ def listener(service_call):
hass.services.async_register("test_domain", "test_service", listener)
await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"),
- json={"test": 1},
+ "/api/services/test_domain/test_service", json={"test": 1}
)
await hass.async_block_till_done()
@@ -348,7 +332,7 @@ async def test_stream_with_restricted(hass, mock_api_client):
listen_count = _listen_count(hass)
resp = await mock_api_client.get(
- "{}?restrict=test_event1,test_event3".format(const.URL_API_STREAM)
+ f"{const.URL_API_STREAM}?restrict=test_event1,test_event3"
)
assert resp.status == 200
assert listen_count + 1 == _listen_count(hass)
@@ -403,7 +387,7 @@ async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin
) as mock_file:
resp = await client.get(
const.URL_API_ERROR_LOG,
- headers={"Authorization": "Bearer {}".format(hass_access_token)},
+ headers={"Authorization": f"Bearer {hass_access_token}"},
)
assert len(mock_file.mock_calls) == 1
@@ -415,7 +399,7 @@ async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin
hass_admin_user.groups = []
resp = await client.get(
const.URL_API_ERROR_LOG,
- headers={"Authorization": "Bearer {}".format(hass_access_token)},
+ headers={"Authorization": f"Bearer {hass_access_token}"},
)
assert resp.status == 401
@@ -432,8 +416,8 @@ def listener(event):
hass.bus.async_listen("test.event", listener)
await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test.event"),
- headers={"authorization": "Bearer {}".format(hass_access_token)},
+ "/api/events/test.event",
+ headers={"authorization": f"Bearer {hass_access_token}"},
)
await hass.async_block_till_done()
@@ -449,7 +433,7 @@ async def test_api_call_service_context(hass, mock_api_client, hass_access_token
await mock_api_client.post(
"/api/services/test_domain/test_service",
- headers={"authorization": "Bearer {}".format(hass_access_token)},
+ headers={"authorization": f"Bearer {hass_access_token}"},
)
await hass.async_block_till_done()
@@ -464,7 +448,7 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token):
await mock_api_client.post(
"/api/states/light.kitchen",
json={"state": "on"},
- headers={"authorization": "Bearer {}".format(hass_access_token)},
+ headers={"authorization": f"Bearer {hass_access_token}"},
)
refresh_token = await hass.auth.async_validate_access_token(hass_access_token)
@@ -541,15 +525,13 @@ async def test_rendering_template_legacy_user(
async def test_api_call_service_not_found(hass, mock_api_client):
- """Test if the API failes 400 if unknown service."""
- resp = await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service")
- )
+ """Test if the API fails 400 if unknown service."""
+ resp = await mock_api_client.post("/api/services/test_domain/test_service")
assert resp.status == 400
async def test_api_call_service_bad_data(hass, mock_api_client):
- """Test if the API failes 400 if unknown service."""
+ """Test if the API fails 400 if unknown service."""
test_value = []
@ha.callback
@@ -562,7 +544,6 @@ def listener(service_call):
)
resp = await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"),
- json={"hello": 5},
+ "/api/services/test_domain/test_service", json={"hello": 5}
)
assert resp.status == 400
diff --git a/tests/components/apprise/test_notify.py b/tests/components/apprise/test_notify.py
index a275e57653dc2a..8135f4e8e2c2e5 100644
--- a/tests/components/apprise/test_notify.py
+++ b/tests/components/apprise/test_notify.py
@@ -95,7 +95,7 @@ async def test_apprise_notification(hass):
assert await async_setup_component(hass, BASE_COMPONENT, config)
await hass.async_block_till_done()
- # Test the existance of our service
+ # Test the existence of our service
assert hass.services.has_service(BASE_COMPONENT, "test")
# Test the call to our underlining notify() call
@@ -134,7 +134,7 @@ async def test_apprise_notification_with_target(hass, tmp_path):
assert await async_setup_component(hass, BASE_COMPONENT, config)
await hass.async_block_till_done()
- # Test the existance of our service
+ # Test the existence of our service
assert hass.services.has_service(BASE_COMPONENT, "test")
# Test the call to our underlining notify() call
diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py
index 8448a25a7fd323..2ff31a8fd4f1c1 100644
--- a/tests/components/arcam_fmj/test_media_player.py
+++ b/tests/components/arcam_fmj/test_media_player.py
@@ -123,7 +123,7 @@ async def test_turn_off(player, state):
@pytest.mark.parametrize("mute", [True, False])
async def test_mute_volume(player, state, mute):
- """Test mute functionallity."""
+ """Test mute functionality."""
await player.async_mute_volume(mute)
state.set_mute.assert_called_with(mute)
player.async_schedule_update_ha_state.assert_called_with()
@@ -200,14 +200,14 @@ async def test_select_sound_mode(player, state, mode, mode_sel, mode_2ch, mode_m
async def test_volume_up(player, state):
- """Test mute functionallity."""
+ """Test mute functionality."""
await player.async_volume_up()
state.inc_volume.assert_called_with()
player.async_schedule_update_ha_state.assert_called_with()
async def test_volume_down(player, state):
- """Test mute functionallity."""
+ """Test mute functionality."""
await player.async_volume_down()
state.dec_volume.assert_called_with()
player.async_schedule_update_ha_state.assert_called_with()
diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py
index 2ecab9c1d37366..095b7b76d6037e 100644
--- a/tests/components/asuswrt/test_device_tracker.py
+++ b/tests/components/asuswrt/test_device_tracker.py
@@ -2,30 +2,16 @@
from unittest.mock import patch
from homeassistant.components.asuswrt import (
- CONF_MODE,
- CONF_PORT,
- CONF_PROTOCOL,
+ CONF_DNSMASQ,
+ CONF_INTERFACE,
DATA_ASUSWRT,
DOMAIN,
)
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.setup import async_setup_component
from tests.common import mock_coro_func
-FAKEFILE = None
-
-VALID_CONFIG_ROUTER_SSH = {
- DOMAIN: {
- CONF_PLATFORM: "asuswrt",
- CONF_HOST: "fake_host",
- CONF_USERNAME: "fake_user",
- CONF_PROTOCOL: "ssh",
- CONF_MODE: "router",
- CONF_PORT: "22",
- }
-}
-
async def test_password_or_pub_key_required(hass):
"""Test creating an AsusWRT scanner without a pass or pubkey."""
@@ -33,7 +19,9 @@ async def test_password_or_pub_key_required(hass):
AsusWrt().connection.async_connect = mock_coro_func()
AsusWrt().is_connected = False
result = await async_setup_component(
- hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}}
+ hass,
+ DOMAIN,
+ {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}},
)
assert not result
@@ -53,8 +41,74 @@ async def test_get_scanner_with_password_no_pubkey(hass):
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_user",
CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "/",
+ }
+ },
+ )
+ assert result
+ assert hass.data[DATA_ASUSWRT] is not None
+
+
+async def test_specify_non_directory_path_for_dnsmasq(hass):
+ """Test creating an AsusWRT scanner with a dnsmasq location which is not a valid directory."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+ AsusWrt().is_connected = False
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_HOST: "fake_host",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "?non_directory?",
+ }
+ },
+ )
+ assert not result
+
+
+async def test_interface(hass):
+ """Test creating an AsusWRT scanner using interface eth1."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+ AsusWrt().connection.async_get_connected_devices = mock_coro_func(
+ return_value={}
+ )
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_HOST: "fake_host",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "/",
+ CONF_INTERFACE: "eth1",
}
},
)
assert result
assert hass.data[DATA_ASUSWRT] is not None
+
+
+async def test_no_interface(hass):
+ """Test creating an AsusWRT scanner using no interface."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+ AsusWrt().is_connected = False
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_HOST: "fake_host",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "/",
+ CONF_INTERFACE: None,
+ }
+ },
+ )
+ assert not result
diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py
new file mode 100644
index 00000000000000..39443c3fef850e
--- /dev/null
+++ b/tests/components/asuswrt/test_sensor.py
@@ -0,0 +1,42 @@
+"""The tests for the ASUSWRT sensor platform."""
+from unittest.mock import patch
+
+# import homeassistant.components.sensor as sensor
+from homeassistant.components.asuswrt import (
+ CONF_DNSMASQ,
+ CONF_INTERFACE,
+ CONF_MODE,
+ CONF_PORT,
+ CONF_PROTOCOL,
+ CONF_SENSORS,
+ DATA_ASUSWRT,
+ DOMAIN,
+)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro_func
+
+VALID_CONFIG_ROUTER_SSH = {
+ DOMAIN: {
+ CONF_DNSMASQ: "/",
+ CONF_HOST: "fake_host",
+ CONF_INTERFACE: "eth0",
+ CONF_MODE: "router",
+ CONF_PORT: "22",
+ CONF_PROTOCOL: "ssh",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "fake_pass",
+ CONF_SENSORS: "upload",
+ }
+}
+
+
+async def test_default_sensor_setup(hass):
+ """Test creating an AsusWRT sensor."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+
+ result = await async_setup_component(hass, DOMAIN, VALID_CONFIG_ROUTER_SSH)
+ assert result
+ assert hass.data[DATA_ASUSWRT] is not None
diff --git a/tests/components/august/__init__.py b/tests/components/august/__init__.py
new file mode 100644
index 00000000000000..156b61705116c7
--- /dev/null
+++ b/tests/components/august/__init__.py
@@ -0,0 +1 @@
+"""Tests for the august component."""
diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py
new file mode 100644
index 00000000000000..cb78049d14976d
--- /dev/null
+++ b/tests/components/august/mocks.py
@@ -0,0 +1,326 @@
+"""Mocks for the august component."""
+import json
+import os
+import time
+from unittest.mock import MagicMock, PropertyMock
+
+from asynctest import mock
+from august.activity import (
+ ACTIVITY_ACTIONS_DOOR_OPERATION,
+ ACTIVITY_ACTIONS_DOORBELL_DING,
+ ACTIVITY_ACTIONS_DOORBELL_MOTION,
+ ACTIVITY_ACTIONS_DOORBELL_VIEW,
+ ACTIVITY_ACTIONS_LOCK_OPERATION,
+ DoorbellDingActivity,
+ DoorbellMotionActivity,
+ DoorbellViewActivity,
+ DoorOperationActivity,
+ LockOperationActivity,
+)
+from august.authenticator import AuthenticationState
+from august.doorbell import Doorbell, DoorbellDetail
+from august.lock import Lock, LockDetail
+
+from homeassistant.components.august import (
+ CONF_LOGIN_METHOD,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ DOMAIN,
+)
+from homeassistant.setup import async_setup_component
+
+from tests.common import load_fixture
+
+
+def _mock_get_config():
+ """Return a default august config."""
+ return {
+ DOMAIN: {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "mocked_username",
+ CONF_PASSWORD: "mocked_password",
+ }
+ }
+
+
+@mock.patch("homeassistant.components.august.gateway.Api")
+@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate")
+async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock):
+ """Set up august integration."""
+ authenticate_mock.side_effect = MagicMock(
+ return_value=_mock_august_authentication("original_token", 1234)
+ )
+ api_mock.return_value = api_instance
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
+ return True
+
+
+async def _create_august_with_devices(
+ hass, devices, api_call_side_effects=None, activities=None
+):
+ if api_call_side_effects is None:
+ api_call_side_effects = {}
+
+ device_data = {
+ "doorbells": [],
+ "locks": [],
+ }
+ for device in devices:
+ if isinstance(device, LockDetail):
+ device_data["locks"].append(
+ {"base": _mock_august_lock(device.device_id), "detail": device}
+ )
+ elif isinstance(device, DoorbellDetail):
+ device_data["doorbells"].append(
+ {"base": _mock_august_doorbell(device.device_id), "detail": device}
+ )
+ else:
+ raise ValueError
+
+ def _get_device_detail(device_type, device_id):
+ for device in device_data[device_type]:
+ if device["detail"].device_id == device_id:
+ return device["detail"]
+ raise ValueError
+
+ def _get_base_devices(device_type):
+ base_devices = []
+ for device in device_data[device_type]:
+ base_devices.append(device["base"])
+ return base_devices
+
+ def get_lock_detail_side_effect(access_token, device_id):
+ return _get_device_detail("locks", device_id)
+
+ def get_doorbell_detail_side_effect(access_token, device_id):
+ return _get_device_detail("doorbells", device_id)
+
+ def get_operable_locks_side_effect(access_token):
+ return _get_base_devices("locks")
+
+ def get_doorbells_side_effect(access_token):
+ return _get_base_devices("doorbells")
+
+ def get_house_activities_side_effect(access_token, house_id, limit=10):
+ if activities is not None:
+ return activities
+ return []
+
+ def lock_return_activities_side_effect(access_token, device_id):
+ lock = _get_device_detail("locks", device_id)
+ return [
+ _mock_lock_operation_activity(lock, "lock"),
+ _mock_door_operation_activity(lock, "doorclosed"),
+ ]
+
+ def unlock_return_activities_side_effect(access_token, device_id):
+ lock = _get_device_detail("locks", device_id)
+ return [
+ _mock_lock_operation_activity(lock, "unlock"),
+ _mock_door_operation_activity(lock, "dooropen"),
+ ]
+
+ if "get_lock_detail" not in api_call_side_effects:
+ api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect
+ if "get_doorbell_detail" not in api_call_side_effects:
+ api_call_side_effects["get_doorbell_detail"] = get_doorbell_detail_side_effect
+ if "get_operable_locks" not in api_call_side_effects:
+ api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect
+ if "get_doorbells" not in api_call_side_effects:
+ api_call_side_effects["get_doorbells"] = get_doorbells_side_effect
+ if "get_house_activities" not in api_call_side_effects:
+ api_call_side_effects["get_house_activities"] = get_house_activities_side_effect
+ if "lock_return_activities" not in api_call_side_effects:
+ api_call_side_effects[
+ "lock_return_activities"
+ ] = lock_return_activities_side_effect
+ if "unlock_return_activities" not in api_call_side_effects:
+ api_call_side_effects[
+ "unlock_return_activities"
+ ] = unlock_return_activities_side_effect
+
+ return await _mock_setup_august_with_api_side_effects(hass, api_call_side_effects)
+
+
+async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects):
+ api_instance = MagicMock(name="Api")
+
+ if api_call_side_effects["get_lock_detail"]:
+ api_instance.get_lock_detail.side_effect = api_call_side_effects[
+ "get_lock_detail"
+ ]
+
+ if api_call_side_effects["get_operable_locks"]:
+ api_instance.get_operable_locks.side_effect = api_call_side_effects[
+ "get_operable_locks"
+ ]
+
+ if api_call_side_effects["get_doorbells"]:
+ api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"]
+
+ if api_call_side_effects["get_doorbell_detail"]:
+ api_instance.get_doorbell_detail.side_effect = api_call_side_effects[
+ "get_doorbell_detail"
+ ]
+
+ if api_call_side_effects["get_house_activities"]:
+ api_instance.get_house_activities.side_effect = api_call_side_effects[
+ "get_house_activities"
+ ]
+
+ if api_call_side_effects["lock_return_activities"]:
+ api_instance.lock_return_activities.side_effect = api_call_side_effects[
+ "lock_return_activities"
+ ]
+
+ if api_call_side_effects["unlock_return_activities"]:
+ api_instance.unlock_return_activities.side_effect = api_call_side_effects[
+ "unlock_return_activities"
+ ]
+ return await _mock_setup_august(hass, api_instance)
+
+
+def _mock_august_authentication(token_text, token_timestamp):
+ authentication = MagicMock(name="august.authentication")
+ type(authentication).state = PropertyMock(
+ return_value=AuthenticationState.AUTHENTICATED
+ )
+ type(authentication).access_token = PropertyMock(return_value=token_text)
+ type(authentication).access_token_expires = PropertyMock(
+ return_value=token_timestamp
+ )
+ return authentication
+
+
+def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"):
+ return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid))
+
+
+def _mock_august_doorbell(deviceid="mockdeviceid1", houseid="mockhouseid1"):
+ return Doorbell(
+ deviceid, _mock_august_doorbell_data(deviceid=deviceid, houseid=houseid)
+ )
+
+
+def _mock_august_doorbell_data(deviceid="mockdeviceid1", houseid="mockhouseid1"):
+ return {
+ "_id": deviceid,
+ "DeviceID": deviceid,
+ "name": deviceid + " Name",
+ "HouseID": houseid,
+ "UserType": "owner",
+ "serialNumber": "mockserial",
+ "battery": 90,
+ "status": "standby",
+ "currentFirmwareVersion": "mockfirmware",
+ "Bridge": {
+ "_id": "bridgeid1",
+ "firmwareVersion": "mockfirm",
+ "operative": True,
+ },
+ "LockStatus": {"doorState": "open"},
+ }
+
+
+def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"):
+ return {
+ "_id": lockid,
+ "LockID": lockid,
+ "LockName": lockid + " Name",
+ "HouseID": houseid,
+ "UserType": "owner",
+ "SerialNumber": "mockserial",
+ "battery": 90,
+ "currentFirmwareVersion": "mockfirmware",
+ "Bridge": {
+ "_id": "bridgeid1",
+ "firmwareVersion": "mockfirm",
+ "operative": True,
+ },
+ "LockStatus": {"doorState": "open"},
+ }
+
+
+async def _mock_operative_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.online.json")
+
+
+async def _mock_inoperative_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.offline.json")
+
+
+async def _mock_activities_from_fixture(hass, path):
+ json_dict = await _load_json_fixture(hass, path)
+ activities = []
+ for activity_json in json_dict:
+ activity = _activity_from_dict(activity_json)
+ if activity:
+ activities.append(activity)
+
+ return activities
+
+
+async def _mock_lock_from_fixture(hass, path):
+ json_dict = await _load_json_fixture(hass, path)
+ return LockDetail(json_dict)
+
+
+async def _mock_doorbell_from_fixture(hass, path):
+ json_dict = await _load_json_fixture(hass, path)
+ return DoorbellDetail(json_dict)
+
+
+async def _load_json_fixture(hass, path):
+ fixture = await hass.async_add_executor_job(
+ load_fixture, os.path.join("august", path)
+ )
+ return json.loads(fixture)
+
+
+async def _mock_doorsense_enabled_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.online_with_doorsense.json")
+
+
+async def _mock_doorsense_missing_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json")
+
+
+def _mock_lock_operation_activity(lock, action):
+ return LockOperationActivity(
+ {
+ "dateTime": time.time() * 1000,
+ "deviceID": lock.device_id,
+ "deviceType": "lock",
+ "action": action,
+ }
+ )
+
+
+def _mock_door_operation_activity(lock, action):
+ return DoorOperationActivity(
+ {
+ "dateTime": time.time() * 1000,
+ "deviceID": lock.device_id,
+ "deviceType": "lock",
+ "action": action,
+ }
+ )
+
+
+def _activity_from_dict(activity_dict):
+ action = activity_dict.get("action")
+
+ activity_dict["dateTime"] = time.time() * 1000
+
+ if action in ACTIVITY_ACTIONS_DOORBELL_DING:
+ return DoorbellDingActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_DOORBELL_MOTION:
+ return DoorbellMotionActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_DOORBELL_VIEW:
+ return DoorbellViewActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_LOCK_OPERATION:
+ return LockOperationActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_DOOR_OPERATION:
+ return DoorOperationActivity(activity_dict)
+ return None
diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py
new file mode 100644
index 00000000000000..1ecca29985d6c9
--- /dev/null
+++ b/tests/components/august/test_binary_sensor.py
@@ -0,0 +1,133 @@
+"""The binary_sensor tests for the august platform."""
+
+import pytest
+
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_OFF,
+ STATE_ON,
+ STATE_UNAVAILABLE,
+)
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_activities_from_fixture,
+ _mock_doorbell_from_fixture,
+ _mock_lock_from_fixture,
+)
+
+
+@pytest.mark.skip(
+ reason="The lock and doorsense can get out of sync due to update intervals, "
+ + "this is an existing bug which will be fixed with dispatcher events to tell "
+ + "all linked devices to update."
+)
+async def test_doorsense(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_lock_from_fixture(
+ hass, "get_lock.online_with_doorsense.json"
+ )
+ lock_details = [lock_one]
+ await _create_august_with_devices(hass, lock_details)
+
+ binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open")
+ assert binary_sensor_abc_name.state == STATE_ON
+
+ data = {}
+ data[ATTR_ENTITY_ID] = "lock.abc_name"
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True
+ )
+
+ binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open")
+ assert binary_sensor_abc_name.state == STATE_ON
+
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True
+ )
+
+ binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open")
+ assert binary_sensor_abc_name.state == STATE_OFF
+
+
+async def test_create_doorbell(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ doorbell_details = [doorbell_one]
+ await _create_august_with_devices(hass, doorbell_details)
+
+ binary_sensor_k98gidt45gul_name_motion = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_motion"
+ )
+ assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
+ binary_sensor_k98gidt45gul_name_online = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_online"
+ )
+ assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON
+ binary_sensor_k98gidt45gul_name_ding = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_ding"
+ )
+ assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
+ binary_sensor_k98gidt45gul_name_motion = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_motion"
+ )
+ assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
+
+
+async def test_create_doorbell_offline(hass):
+ """Test creation of a doorbell that is offline."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
+ doorbell_details = [doorbell_one]
+ await _create_august_with_devices(hass, doorbell_details)
+
+ binary_sensor_tmt100_name_motion = hass.states.get(
+ "binary_sensor.tmt100_name_motion"
+ )
+ assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE
+ binary_sensor_tmt100_name_online = hass.states.get(
+ "binary_sensor.tmt100_name_online"
+ )
+ assert binary_sensor_tmt100_name_online.state == STATE_OFF
+ binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding")
+ assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE
+
+
+async def test_create_doorbell_with_motion(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ doorbell_details = [doorbell_one]
+ activities = await _mock_activities_from_fixture(
+ hass, "get_activity.doorbell_motion.json"
+ )
+ await _create_august_with_devices(hass, doorbell_details, activities=activities)
+
+ binary_sensor_k98gidt45gul_name_motion = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_motion"
+ )
+ assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON
+ binary_sensor_k98gidt45gul_name_online = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_online"
+ )
+ assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON
+ binary_sensor_k98gidt45gul_name_ding = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_ding"
+ )
+ assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
+
+
+async def test_doorbell_device_registry(hass):
+ """Test creation of a lock with doorsense and bridge ands up in the registry."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
+ doorbell_details = [doorbell_one]
+ await _create_august_with_devices(hass, doorbell_details)
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ reg_device = device_registry.async_get_device(
+ identifiers={("august", "tmt100")}, connections=set()
+ )
+ assert "hydra1" == reg_device.model
+ assert "3.1.0-HYDRC75+201909251139" == reg_device.sw_version
diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py
new file mode 100644
index 00000000000000..9ed97ecbc29c41
--- /dev/null
+++ b/tests/components/august/test_camera.py
@@ -0,0 +1,18 @@
+"""The camera tests for the august platform."""
+
+from homeassistant.const import STATE_IDLE
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_doorbell_from_fixture,
+)
+
+
+async def test_create_doorbell(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ doorbell_details = [doorbell_one]
+ await _create_august_with_devices(hass, doorbell_details)
+
+ camera_k98gidt45gul_name = hass.states.get("camera.k98gidt45gul_name")
+ assert camera_k98gidt45gul_name.state == STATE_IDLE
diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py
new file mode 100644
index 00000000000000..3e81986d9f451a
--- /dev/null
+++ b/tests/components/august/test_config_flow.py
@@ -0,0 +1,195 @@
+"""Test the August config flow."""
+from asynctest import patch
+from august.authenticator import ValidationResult
+
+from homeassistant import config_entries, setup
+from homeassistant.components.august.const import (
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ CONF_INSTALL_ID,
+ CONF_LOGIN_METHOD,
+ DOMAIN,
+ VERIFICATION_CODE_KEY,
+)
+from homeassistant.components.august.exceptions import (
+ CannotConnect,
+ InvalidAuth,
+ RequireValidation,
+)
+from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.august.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.august.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "my@email.tld"
+ assert result2["data"] == {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ CONF_INSTALL_ID: None,
+ CONF_TIMEOUT: 10,
+ CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ side_effect=InvalidAuth,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ side_effect=CannotConnect,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_needs_validate(hass):
+ """Test we present validation when we need to validate."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ side_effect=RequireValidation,
+ ), patch(
+ "homeassistant.components.august.gateway.Authenticator.send_verification_code",
+ return_value=True,
+ ) as mock_send_verification_code:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert len(mock_send_verification_code.mock_calls) == 1
+ assert result2["type"] == "form"
+ assert result2["errors"] is None
+ assert result2["step_id"] == "validation"
+
+ # Try with the WRONG verification code give us the form back again
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ side_effect=RequireValidation,
+ ), patch(
+ "homeassistant.components.august.gateway.Authenticator.validate_verification_code",
+ return_value=ValidationResult.INVALID_VERIFICATION_CODE,
+ ) as mock_validate_verification_code, patch(
+ "homeassistant.components.august.gateway.Authenticator.send_verification_code",
+ return_value=True,
+ ) as mock_send_verification_code, patch(
+ "homeassistant.components.august.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.august.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result3 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {VERIFICATION_CODE_KEY: "incorrect"},
+ )
+
+ # Make sure we do not resend the code again
+ # so they have a chance to retry
+ assert len(mock_send_verification_code.mock_calls) == 0
+ assert len(mock_validate_verification_code.mock_calls) == 1
+ assert result3["type"] == "form"
+ assert result3["errors"] is None
+ assert result3["step_id"] == "validation"
+
+ # Try with the CORRECT verification code and we setup
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.august.gateway.Authenticator.validate_verification_code",
+ return_value=ValidationResult.VALIDATED,
+ ) as mock_validate_verification_code, patch(
+ "homeassistant.components.august.gateway.Authenticator.send_verification_code",
+ return_value=True,
+ ) as mock_send_verification_code, patch(
+ "homeassistant.components.august.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.august.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result4 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {VERIFICATION_CODE_KEY: "correct"},
+ )
+
+ assert len(mock_send_verification_code.mock_calls) == 0
+ assert len(mock_validate_verification_code.mock_calls) == 1
+ assert result4["type"] == "create_entry"
+ assert result4["title"] == "my@email.tld"
+ assert result4["data"] == {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ CONF_INSTALL_ID: None,
+ CONF_TIMEOUT: 10,
+ CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py
new file mode 100644
index 00000000000000..38696d316cafdc
--- /dev/null
+++ b/tests/components/august/test_gateway.py
@@ -0,0 +1,49 @@
+"""The gateway tests for the august platform."""
+from unittest.mock import MagicMock
+
+from asynctest import mock
+
+from homeassistant.components.august.const import DOMAIN
+from homeassistant.components.august.gateway import AugustGateway
+
+from tests.components.august.mocks import _mock_august_authentication, _mock_get_config
+
+
+async def test_refresh_access_token(hass):
+ """Test token refreshes."""
+ await _patched_refresh_access_token(hass, "new_token", 5678)
+
+
+@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate")
+@mock.patch("homeassistant.components.august.gateway.Authenticator.should_refresh")
+@mock.patch(
+ "homeassistant.components.august.gateway.Authenticator.refresh_access_token"
+)
+async def _patched_refresh_access_token(
+ hass,
+ new_token,
+ new_token_expire_time,
+ refresh_access_token_mock,
+ should_refresh_mock,
+ authenticate_mock,
+):
+ authenticate_mock.side_effect = MagicMock(
+ return_value=_mock_august_authentication("original_token", 1234)
+ )
+ august_gateway = AugustGateway(hass)
+ mocked_config = _mock_get_config()
+ august_gateway.async_setup(mocked_config[DOMAIN])
+ august_gateway.authenticate()
+
+ should_refresh_mock.return_value = False
+ await august_gateway.async_refresh_access_token_if_needed()
+ refresh_access_token_mock.assert_not_called()
+
+ should_refresh_mock.return_value = True
+ refresh_access_token_mock.return_value = _mock_august_authentication(
+ new_token, new_token_expire_time
+ )
+ await august_gateway.async_refresh_access_token_if_needed()
+ refresh_access_token_mock.assert_called()
+ assert august_gateway.access_token == new_token
+ assert august_gateway.authentication.access_token_expires == new_token_expire_time
diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py
new file mode 100644
index 00000000000000..4767f24e113bff
--- /dev/null
+++ b/tests/components/august/test_init.py
@@ -0,0 +1,146 @@
+"""The tests for the august platform."""
+from asynctest import patch
+from august.exceptions import AugustApiHTTPError
+
+from homeassistant import setup
+from homeassistant.components.august.const import (
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ CONF_INSTALL_ID,
+ CONF_LOGIN_METHOD,
+ DEFAULT_AUGUST_CONFIG_FILE,
+)
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ CONF_PASSWORD,
+ CONF_TIMEOUT,
+ CONF_USERNAME,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_LOCKED,
+ STATE_ON,
+)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.setup import async_setup_component
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_doorsense_enabled_august_lock_detail,
+ _mock_doorsense_missing_august_lock_detail,
+ _mock_get_config,
+ _mock_inoperative_august_lock_detail,
+ _mock_operative_august_lock_detail,
+)
+
+
+async def test_unlock_throws_august_api_http_error(hass):
+ """Test unlock throws correct error on http error."""
+ mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
+
+ def _unlock_return_activities_side_effect(access_token, device_id):
+ raise AugustApiHTTPError("This should bubble up as its user consumable")
+
+ await _create_august_with_devices(
+ hass,
+ [mocked_lock_detail],
+ api_call_side_effects={
+ "unlock_return_activities": _unlock_return_activities_side_effect
+ },
+ )
+ last_err = None
+ data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
+ try:
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True)
+ except HomeAssistantError as err:
+ last_err = err
+ assert (
+ str(last_err)
+ == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable"
+ )
+
+
+async def test_lock_throws_august_api_http_error(hass):
+ """Test lock throws correct error on http error."""
+ mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
+
+ def _lock_return_activities_side_effect(access_token, device_id):
+ raise AugustApiHTTPError("This should bubble up as its user consumable")
+
+ await _create_august_with_devices(
+ hass,
+ [mocked_lock_detail],
+ api_call_side_effects={
+ "lock_return_activities": _lock_return_activities_side_effect
+ },
+ )
+ last_err = None
+ data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
+ try:
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True)
+ except HomeAssistantError as err:
+ last_err = err
+ assert (
+ str(last_err)
+ == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable"
+ )
+
+
+async def test_inoperative_locks_are_filtered_out(hass):
+ """Ensure inoperative locks do not get setup."""
+ august_operative_lock = await _mock_operative_august_lock_detail(hass)
+ august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass)
+ await _create_august_with_devices(
+ hass, [august_operative_lock, august_inoperative_lock]
+ )
+
+ lock_abc_name = hass.states.get("lock.abc_name")
+ assert lock_abc_name is None
+ lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get(
+ "lock.a6697750d607098bae8d6baa11ef8063_name"
+ )
+ assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED
+
+
+async def test_lock_has_doorsense(hass):
+ """Check to see if a lock has doorsense."""
+ doorsenselock = await _mock_doorsense_enabled_august_lock_detail(hass)
+ nodoorsenselock = await _mock_doorsense_missing_august_lock_detail(hass)
+ await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock])
+
+ binary_sensor_online_with_doorsense_name_open = hass.states.get(
+ "binary_sensor.online_with_doorsense_name_open"
+ )
+ assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON
+ binary_sensor_missing_doorsense_id_name_open = hass.states.get(
+ "binary_sensor.missing_doorsense_id_name_open"
+ )
+ assert binary_sensor_missing_doorsense_id_name_open is None
+
+
+async def test_set_up_from_yaml(hass):
+ """Test to make sure config is imported from yaml."""
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ with patch(
+ "homeassistant.components.august.async_setup_august", return_value=True,
+ ) as mock_setup_august, patch(
+ "homeassistant.components.august.config_flow.AugustGateway.authenticate",
+ return_value=True,
+ ):
+ mocked_config = _mock_get_config()
+ assert await async_setup_component(hass, "august", mocked_config)
+ await hass.async_block_till_done()
+ assert len(mock_setup_august.mock_calls) == 1
+ call = mock_setup_august.call_args
+ args, kwargs = call
+ imported_config_entry = args[1]
+ # The import must use DEFAULT_AUGUST_CONFIG_FILE so they
+ # do not loose their token when config is migrated
+ assert imported_config_entry.data == {
+ CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE,
+ CONF_INSTALL_ID: None,
+ CONF_LOGIN_METHOD: "email",
+ CONF_PASSWORD: "mocked_password",
+ CONF_TIMEOUT: None,
+ CONF_USERNAME: "mocked_username",
+ }
diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py
new file mode 100644
index 00000000000000..24e0cdafd46372
--- /dev/null
+++ b/tests/components/august/test_lock.py
@@ -0,0 +1,84 @@
+"""The lock tests for the august platform."""
+
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_LOCKED,
+ STATE_UNKNOWN,
+ STATE_UNLOCKED,
+)
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_doorsense_enabled_august_lock_detail,
+ _mock_lock_from_fixture,
+)
+
+
+async def test_lock_device_registry(hass):
+ """Test creation of a lock with doorsense and bridge ands up in the registry."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+ lock_details = [lock_one]
+ await _create_august_with_devices(hass, lock_details)
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ reg_device = device_registry.async_get_device(
+ identifiers={("august", "online_with_doorsense")}, connections=set()
+ )
+ assert "AUG-MD01" == reg_device.model
+ assert "undefined-4.3.0-1.8.14" == reg_device.sw_version
+
+
+async def test_one_lock_operation(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+ lock_details = [lock_one]
+ await _create_august_with_devices(hass, lock_details)
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+
+ assert lock_online_with_doorsense_name.state == STATE_LOCKED
+
+ assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92
+ assert (
+ lock_online_with_doorsense_name.attributes.get("friendly_name")
+ == "online_with_doorsense Name"
+ )
+
+ data = {}
+ data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name"
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True
+ )
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+ assert lock_online_with_doorsense_name.state == STATE_UNLOCKED
+
+ assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92
+ assert (
+ lock_online_with_doorsense_name.attributes.get("friendly_name")
+ == "online_with_doorsense Name"
+ )
+
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True
+ )
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+ assert lock_online_with_doorsense_name.state == STATE_LOCKED
+
+
+async def test_one_lock_unknown_state(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_lock_from_fixture(
+ hass, "get_lock.online.unknown_state.json",
+ )
+ lock_details = [lock_one]
+ await _create_august_with_devices(hass, lock_details)
+
+ lock_brokenid_name = hass.states.get("lock.brokenid_name")
+
+ assert lock_brokenid_name.state == STATE_UNKNOWN
diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py
new file mode 100644
index 00000000000000..a0c1a2ea7bb41f
--- /dev/null
+++ b/tests/components/august/test_sensor.py
@@ -0,0 +1,84 @@
+"""The sensor tests for the august platform."""
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_doorbell_from_fixture,
+ _mock_lock_from_fixture,
+)
+
+
+async def test_create_doorbell(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ sensor_k98gidt45gul_name_battery = hass.states.get(
+ "sensor.k98gidt45gul_name_battery"
+ )
+ assert sensor_k98gidt45gul_name_battery.state == "96"
+ assert sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == "%"
+
+
+async def test_create_doorbell_offline(hass):
+ """Test creation of a doorbell that is offline."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
+ await _create_august_with_devices(hass, [doorbell_one])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
+ assert sensor_tmt100_name_battery.state == "81"
+ assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == "%"
+
+ entry = entity_registry.async_get("sensor.tmt100_name_battery")
+ assert entry
+ assert entry.unique_id == "tmt100_device_battery"
+
+
+async def test_create_doorbell_hardwired(hass):
+ """Test creation of a doorbell that is hardwired without a battery."""
+ doorbell_one = await _mock_doorbell_from_fixture(
+ hass, "get_doorbell.nobattery.json"
+ )
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
+ assert sensor_tmt100_name_battery is None
+
+
+async def test_create_lock_with_linked_keypad(hass):
+ """Test creation of a lock with a linked keypad that both have a battery."""
+ lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json")
+ await _create_august_with_devices(hass, [lock_one])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
+ )
+ assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88"
+ assert (
+ sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[
+ "unit_of_measurement"
+ ]
+ == "%"
+ )
+ entry = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
+ )
+ assert entry
+ assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery"
+
+ sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
+ )
+ assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "60"
+ assert (
+ sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[
+ "unit_of_measurement"
+ ]
+ == "%"
+ )
+ entry = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
+ )
+ assert entry
+ assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery"
diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py
index ce8edae1466517..b359144ab97766 100644
--- a/tests/components/auth/test_indieauth.py
+++ b/tests/components/auth/test_indieauth.py
@@ -169,7 +169,8 @@ async def test_find_link_tag_max_size(hass, mock_session):
@pytest.mark.parametrize(
- "client_id", ["https://home-assistant.io/android", "https://home-assistant.io/iOS"]
+ "client_id",
+ ["https://www.home-assistant.io/android", "https://www.home-assistant.io/iOS"],
)
async def test_verify_redirect_uri_android_ios(client_id):
"""Test that we verify redirect uri correctly for Android/iOS."""
diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py
index 96d497c3daeb22..2c9a39c6fb62f0 100644
--- a/tests/components/auth/test_init.py
+++ b/tests/components/auth/test_init.py
@@ -28,7 +28,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client):
step = await resp.json()
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"},
)
@@ -71,7 +71,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client):
assert resp.status == 401
resp = await client.get(
- "/api/", headers={"authorization": "Bearer {}".format(tokens["access_token"])}
+ "/api/", headers={"authorization": f"Bearer {tokens['access_token']}"}
)
assert resp.status == 200
diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py
index 2aa6b0d9f8dd92..3f0e9bce06380d 100644
--- a/tests/components/auth/test_init_link_user.py
+++ b/tests/components/auth/test_init_link_user.py
@@ -42,7 +42,7 @@ async def async_get_code(hass, aiohttp_client):
step = await resp.json()
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={"client_id": CLIENT_ID, "username": "2nd-user", "password": "2nd-pass"},
)
@@ -67,7 +67,7 @@ async def test_link_user(hass, aiohttp_client):
resp = await client.post(
"/auth/link_user",
json={"client_id": CLIENT_ID, "code": code},
- headers={"authorization": "Bearer {}".format(info["access_token"])},
+ headers={"authorization": f"Bearer {info['access_token']}"},
)
assert resp.status == 200
@@ -84,7 +84,7 @@ async def test_link_user_invalid_client_id(hass, aiohttp_client):
resp = await client.post(
"/auth/link_user",
json={"client_id": "invalid", "code": code},
- headers={"authorization": "Bearer {}".format(info["access_token"])},
+ headers={"authorization": f"Bearer {info['access_token']}"},
)
assert resp.status == 400
@@ -100,7 +100,7 @@ async def test_link_user_invalid_code(hass, aiohttp_client):
resp = await client.post(
"/auth/link_user",
json={"client_id": CLIENT_ID, "code": "invalid"},
- headers={"authorization": "Bearer {}".format(info["access_token"])},
+ headers={"authorization": f"Bearer {info['access_token']}"},
)
assert resp.status == 400
diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py
index d7bb544893867a..e6e5281d601ffa 100644
--- a/tests/components/auth/test_login_flow.py
+++ b/tests/components/auth/test_login_flow.py
@@ -54,7 +54,7 @@ async def test_invalid_username_password(hass, aiohttp_client):
# Incorrect username
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={
"client_id": CLIENT_ID,
"username": "wrong-user",
@@ -70,7 +70,7 @@ async def test_invalid_username_password(hass, aiohttp_client):
# Incorrect password
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={
"client_id": CLIENT_ID,
"username": "test-user",
@@ -105,7 +105,7 @@ async def test_login_exist_user(hass, aiohttp_client):
step = await resp.json()
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"},
)
diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py
index 26d19d6fa47f57..340bb6c1e95dd2 100644
--- a/tests/components/automation/test_event.py
+++ b/tests/components/automation/test_event.py
@@ -11,7 +11,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py
index 05e30458ef36ff..5daca51d0a1fdb 100644
--- a/tests/components/automation/test_geo_location.py
+++ b/tests/components/automation/test_geo_location.py
@@ -11,7 +11,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 49707ac66b0df6..c27a0262a4e7b6 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -5,6 +5,7 @@
import pytest
import homeassistant.components.automation as automation
+from homeassistant.components.automation import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_NAME,
@@ -29,7 +30,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
@@ -922,3 +923,102 @@ async def test_automation_restore_last_triggered_with_initial_state(hass):
assert state
assert state.state == STATE_ON
assert state.attributes["last_triggered"] == time
+
+
+async def test_extraction_functions(hass):
+ """Test extraction functions."""
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: [
+ {
+ "alias": "test1",
+ "trigger": {"platform": "state", "entity_id": "sensor.trigger_1"},
+ "condition": {
+ "condition": "state",
+ "entity_id": "light.condition_state",
+ "state": "on",
+ },
+ "action": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": "light.in_both"},
+ },
+ {
+ "service": "test.script",
+ "data": {"entity_id": "light.in_first"},
+ },
+ {
+ "domain": "light",
+ "device_id": "device-in-both",
+ "entity_id": "light.bla",
+ "type": "turn_on",
+ },
+ ],
+ },
+ {
+ "alias": "test2",
+ "trigger": {
+ "platform": "device",
+ "domain": "light",
+ "type": "turned_on",
+ "entity_id": "light.trigger_2",
+ "device_id": "trigger-device-2",
+ },
+ "condition": {
+ "condition": "device",
+ "device_id": "condition-device",
+ "domain": "light",
+ "type": "is_on",
+ "entity_id": "light.bla",
+ },
+ "action": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": "light.in_both"},
+ },
+ {
+ "condition": "state",
+ "entity_id": "sensor.condition",
+ "state": "100",
+ },
+ {"scene": "scene.hello"},
+ {
+ "domain": "light",
+ "device_id": "device-in-both",
+ "entity_id": "light.bla",
+ "type": "turn_on",
+ },
+ {
+ "domain": "light",
+ "device_id": "device-in-last",
+ "entity_id": "light.bla",
+ "type": "turn_on",
+ },
+ ],
+ },
+ ]
+ },
+ )
+
+ assert set(automation.automations_with_entity(hass, "light.in_both")) == {
+ "automation.test1",
+ "automation.test2",
+ }
+ assert set(automation.entities_in_automation(hass, "automation.test1")) == {
+ "sensor.trigger_1",
+ "light.condition_state",
+ "light.in_both",
+ "light.in_first",
+ }
+ assert set(automation.automations_with_device(hass, "device-in-both")) == {
+ "automation.test1",
+ "automation.test2",
+ }
+ assert set(automation.devices_in_automation(hass, "automation.test2")) == {
+ "trigger-device-2",
+ "condition-device",
+ "device-in-both",
+ "device-in-last",
+ }
diff --git a/tests/components/automation/test_litejet.py b/tests/components/automation/test_litejet.py
index 294b15baf91e23..710b16d1b48ab6 100644
--- a/tests/components/automation/test_litejet.py
+++ b/tests/components/automation/test_litejet.py
@@ -22,7 +22,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py
index 9dbe93a7998d40..b8c369f5e63c73 100644
--- a/tests/components/automation/test_mqtt.py
+++ b/tests/components/automation/test_mqtt.py
@@ -17,7 +17,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py
index c6c1fd83184bdb..17cb8e38136b02 100644
--- a/tests/components/automation/test_numeric_state.py
+++ b/tests/components/automation/test_numeric_state.py
@@ -20,7 +20,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py
index b6f9a50cf9d2e1..9d4fa9a1100172 100644
--- a/tests/components/automation/test_state.py
+++ b/tests/components/automation/test_state.py
@@ -20,7 +20,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py
index d9566b8f464364..27e0d4f69653bf 100644
--- a/tests/components/automation/test_template.py
+++ b/tests/components/automation/test_template.py
@@ -20,7 +20,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py
index d84fd18fb6b5d7..511f8a305e69ab 100644
--- a/tests/components/automation/test_time.py
+++ b/tests/components/automation/test_time.py
@@ -18,7 +18,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_time_pattern.py b/tests/components/automation/test_time_pattern.py
index 70d647a124182f..2c0574c3238e7d 100644
--- a/tests/components/automation/test_time_pattern.py
+++ b/tests/components/automation/test_time_pattern.py
@@ -11,7 +11,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py
index 44ad20e16f0133..cb031486b6fd72 100644
--- a/tests/components/automation/test_zone.py
+++ b/tests/components/automation/test_zone.py
@@ -11,7 +11,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py
index ded1520718f685..03d3f71d5f9ec5 100644
--- a/tests/components/awair/test_sensor.py
+++ b/tests/components/awair/test_sensor.py
@@ -16,6 +16,9 @@
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
STATE_UNAVAILABLE,
@@ -183,7 +186,7 @@ async def test_awair_co2(hass):
sensor = hass.states.get("sensor.awair_co2")
assert sensor.state == "612"
assert sensor.attributes["device_class"] == DEVICE_CLASS_CARBON_DIOXIDE
- assert sensor.attributes["unit_of_measurement"] == "ppm"
+ assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_MILLION
async def test_awair_voc(hass):
@@ -193,7 +196,7 @@ async def test_awair_voc(hass):
sensor = hass.states.get("sensor.awair_voc")
assert sensor.state == "1012"
assert sensor.attributes["device_class"] == DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS
- assert sensor.attributes["unit_of_measurement"] == "ppb"
+ assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_BILLION
async def test_awair_dust(hass):
@@ -205,7 +208,10 @@ async def test_awair_dust(hass):
sensor = hass.states.get("sensor.awair_pm2_5")
assert sensor.state == "6.2"
assert sensor.attributes["device_class"] == DEVICE_CLASS_PM2_5
- assert sensor.attributes["unit_of_measurement"] == "µg/m3"
+ assert (
+ sensor.attributes["unit_of_measurement"]
+ == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
+ )
async def test_awair_unsupported_sensors(hass):
diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py
index 809c71c5cb1789..2e4c3e9f8bee7d 100644
--- a/tests/components/axis/test_config_flow.py
+++ b/tests/components/axis/test_config_flow.py
@@ -4,7 +4,7 @@
from homeassistant.components import axis
from homeassistant.components.axis import config_flow
-from .test_device import MAC, setup_axis_integration
+from .test_device import MAC, MODEL, NAME, setup_axis_integration
from tests.common import MockConfigEntry, mock_coro
@@ -54,12 +54,10 @@ async def test_flow_manual_configuration(hass):
assert result["type"] == "create_entry"
assert result["title"] == f"prodnbr - {MAC}"
assert result["data"] == {
- axis.CONF_DEVICE: {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_USERNAME: "user",
- config_flow.CONF_PASSWORD: "pass",
- config_flow.CONF_PORT: 80,
- },
+ config_flow.CONF_HOST: "1.2.3.4",
+ config_flow.CONF_USERNAME: "user",
+ config_flow.CONF_PASSWORD: "pass",
+ config_flow.CONF_PORT: 80,
config_flow.CONF_MAC: MAC,
config_flow.CONF_MODEL: "prodnbr",
config_flow.CONF_NAME: "prodnbr 0",
@@ -95,11 +93,8 @@ async def test_manual_configuration_update_configuration(hass):
)
assert result["type"] == "abort"
- assert result["reason"] == "updated_configuration"
- assert (
- device.config_entry.data[config_flow.CONF_DEVICE][config_flow.CONF_HOST]
- == "2.3.4.5"
- )
+ assert result["reason"] == "already_configured"
+ assert device.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5"
async def test_flow_fails_already_configured(hass):
@@ -223,12 +218,10 @@ async def test_flow_create_entry_multiple_existing_entries_of_same_model(hass):
assert result["type"] == "create_entry"
assert result["title"] == f"prodnbr - {MAC}"
assert result["data"] == {
- axis.CONF_DEVICE: {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_USERNAME: "user",
- config_flow.CONF_PASSWORD: "pass",
- config_flow.CONF_PORT: 80,
- },
+ config_flow.CONF_HOST: "1.2.3.4",
+ config_flow.CONF_USERNAME: "user",
+ config_flow.CONF_PASSWORD: "pass",
+ config_flow.CONF_PORT: 80,
config_flow.CONF_MAC: MAC,
config_flow.CONF_MODEL: "prodnbr",
config_flow.CONF_NAME: "prodnbr 2",
@@ -271,12 +264,10 @@ async def test_zeroconf_flow(hass):
assert result["type"] == "create_entry"
assert result["title"] == f"prodnbr - {MAC}"
assert result["data"] == {
- axis.CONF_DEVICE: {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_USERNAME: "user",
- config_flow.CONF_PASSWORD: "pass",
- config_flow.CONF_PORT: 80,
- },
+ config_flow.CONF_HOST: "1.2.3.4",
+ config_flow.CONF_USERNAME: "user",
+ config_flow.CONF_PASSWORD: "pass",
+ config_flow.CONF_PORT: 80,
config_flow.CONF_MAC: MAC,
config_flow.CONF_MODEL: "prodnbr",
config_flow.CONF_NAME: "prodnbr 0",
@@ -310,6 +301,15 @@ async def test_zeroconf_flow_updated_configuration(hass):
"""Test that zeroconf update configuration with new parameters."""
device = await setup_axis_integration(hass)
assert device.host == "1.2.3.4"
+ assert device.config_entry.data == {
+ config_flow.CONF_HOST: "1.2.3.4",
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_USERNAME: "username",
+ config_flow.CONF_PASSWORD: "password",
+ config_flow.CONF_MAC: MAC,
+ config_flow.CONF_MODEL: MODEL,
+ config_flow.CONF_NAME: NAME,
+ }
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
@@ -323,11 +323,16 @@ async def test_zeroconf_flow_updated_configuration(hass):
)
assert result["type"] == "abort"
- assert result["reason"] == "updated_configuration"
- assert device.host == "2.3.4.5"
- assert (
- device.config_entry.data[config_flow.CONF_DEVICE][config_flow.CONF_PORT] == 8080
- )
+ assert result["reason"] == "already_configured"
+ assert device.config_entry.data == {
+ config_flow.CONF_HOST: "2.3.4.5",
+ config_flow.CONF_PORT: 8080,
+ config_flow.CONF_USERNAME: "username",
+ config_flow.CONF_PASSWORD: "password",
+ config_flow.CONF_MAC: MAC,
+ config_flow.CONF_MODEL: MODEL,
+ config_flow.CONF_NAME: NAME,
+ }
async def test_zeroconf_flow_ignore_non_axis_device(hass):
diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py
index b175d22cfb43a7..3d2ed432c1ca1f 100644
--- a/tests/components/axis/test_device.py
+++ b/tests/components/axis/test_device.py
@@ -14,18 +14,14 @@
MODEL = "model"
NAME = "name"
-DEVICE_DATA = {
- axis.device.CONF_HOST: "1.2.3.4",
- axis.device.CONF_USERNAME: "username",
- axis.device.CONF_PASSWORD: "password",
- axis.device.CONF_PORT: 80,
-}
-
-ENTRY_OPTIONS = {axis.device.CONF_CAMERA: True, axis.device.CONF_EVENTS: True}
+ENTRY_OPTIONS = {axis.CONF_CAMERA: True, axis.CONF_EVENTS: True}
ENTRY_CONFIG = {
- axis.device.CONF_DEVICE: DEVICE_DATA,
- axis.device.CONF_MAC: MAC,
+ axis.CONF_HOST: "1.2.3.4",
+ axis.CONF_USERNAME: "username",
+ axis.CONF_PASSWORD: "password",
+ axis.CONF_PORT: 80,
+ axis.CONF_MAC: MAC,
axis.device.CONF_MODEL: MODEL,
axis.device.CONF_NAME: NAME,
}
@@ -76,6 +72,7 @@ async def setup_axis_integration(
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
options=deepcopy(options),
entry_id="1",
+ version=2,
)
config_entry.add_to_hass(hass)
@@ -116,10 +113,10 @@ async def test_device_setup(hass):
assert forward_entry_setup.mock_calls[1][1] == (entry, "binary_sensor")
assert forward_entry_setup.mock_calls[2][1] == (entry, "switch")
- assert device.host == DEVICE_DATA[axis.device.CONF_HOST]
+ assert device.host == ENTRY_CONFIG[axis.CONF_HOST]
assert device.model == ENTRY_CONFIG[axis.device.CONF_MODEL]
assert device.name == ENTRY_CONFIG[axis.device.CONF_NAME]
- assert device.serial == ENTRY_CONFIG[axis.device.CONF_MAC]
+ assert device.serial == ENTRY_CONFIG[axis.CONF_MAC]
async def test_update_address(hass):
@@ -204,7 +201,7 @@ async def test_get_device_fails(hass):
with patch(
"axis.param_cgi.Params.update_brand", side_effect=axislib.Unauthorized
), pytest.raises(axis.errors.AuthenticationRequired):
- await axis.device.get_device(hass, DEVICE_DATA)
+ await axis.device.get_device(hass, host="", port="", username="", password="")
async def test_get_device_device_unavailable(hass):
@@ -212,7 +209,7 @@ async def test_get_device_device_unavailable(hass):
with patch(
"axis.param_cgi.Params.update_brand", side_effect=axislib.RequestError
), pytest.raises(axis.errors.CannotConnect):
- await axis.device.get_device(hass, DEVICE_DATA)
+ await axis.device.get_device(hass, host="", port="", username="", password="")
async def test_get_device_unknown_error(hass):
@@ -220,4 +217,4 @@ async def test_get_device_unknown_error(hass):
with patch(
"axis.param_cgi.Params.update_brand", side_effect=axislib.AxisException
), pytest.raises(axis.errors.AuthenticationRequired):
- await axis.device.get_device(hass, DEVICE_DATA)
+ await axis.device.get_device(hass, host="", port="", username="", password="")
diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py
index 748bb539369ee1..cf5a3b2785a522 100644
--- a/tests/components/axis/test_init.py
+++ b/tests/components/axis/test_init.py
@@ -16,7 +16,7 @@ async def test_setup_device_already_configured(hass):
assert await async_setup_component(
hass,
axis.DOMAIN,
- {axis.DOMAIN: {"device_name": {axis.config_flow.CONF_HOST: "1.2.3.4"}}},
+ {axis.DOMAIN: {"device_name": {axis.CONF_HOST: "1.2.3.4"}}},
)
assert not mock_config_entries.flow.mock_calls
@@ -37,9 +37,10 @@ async def test_setup_entry(hass):
async def test_setup_entry_fails(hass):
"""Test successful setup of entry."""
- entry = MockConfigEntry(
- domain=axis.DOMAIN, data={axis.device.CONF_MAC: "0123"}, options=True
+ config_entry = MockConfigEntry(
+ domain=axis.DOMAIN, data={axis.CONF_MAC: "0123"}, options=True, version=2
)
+ config_entry.add_to_hass(hass)
mock_device = Mock()
mock_device.async_setup.return_value = mock_coro(False)
@@ -47,7 +48,7 @@ async def test_setup_entry_fails(hass):
with patch.object(axis, "AxisNetworkDevice") as mock_device_class:
mock_device_class.return_value = mock_device
- assert not await axis.async_setup_entry(hass, entry)
+ assert not await hass.config_entries.async_setup(config_entry.entry_id)
assert not hass.data[axis.DOMAIN]
@@ -57,21 +58,54 @@ async def test_unload_entry(hass):
device = await setup_axis_integration(hass)
assert hass.data[axis.DOMAIN]
- assert await axis.async_unload_entry(hass, device.config_entry)
+ assert await hass.config_entries.async_unload(device.config_entry.entry_id)
assert not hass.data[axis.DOMAIN]
async def test_populate_options(hass):
"""Test successful populate options."""
- entry = MockConfigEntry(domain=axis.DOMAIN, data={"device": {}})
- entry.add_to_hass(hass)
+ device = await setup_axis_integration(hass, options=None)
- with patch.object(axis, "get_device", return_value=mock_coro(Mock())):
-
- await axis.async_populate_options(hass, entry)
-
- assert entry.options == {
+ assert device.config_entry.options == {
axis.CONF_CAMERA: True,
axis.CONF_EVENTS: True,
axis.CONF_TRIGGER_TIME: axis.DEFAULT_TRIGGER_TIME,
}
+
+
+async def test_migrate_entry(hass):
+ """Test successful migration of entry data."""
+ legacy_config = {
+ axis.CONF_DEVICE: {
+ axis.CONF_HOST: "1.2.3.4",
+ axis.CONF_USERNAME: "username",
+ axis.CONF_PASSWORD: "password",
+ axis.CONF_PORT: 80,
+ },
+ axis.CONF_MAC: "mac",
+ axis.device.CONF_MODEL: "model",
+ axis.device.CONF_NAME: "name",
+ }
+ entry = MockConfigEntry(domain=axis.DOMAIN, data=legacy_config)
+
+ assert entry.data == legacy_config
+ assert entry.version == 1
+
+ await entry.async_migrate(hass)
+
+ assert entry.data == {
+ axis.CONF_DEVICE: {
+ axis.CONF_HOST: "1.2.3.4",
+ axis.CONF_USERNAME: "username",
+ axis.CONF_PASSWORD: "password",
+ axis.CONF_PORT: 80,
+ },
+ axis.CONF_HOST: "1.2.3.4",
+ axis.CONF_USERNAME: "username",
+ axis.CONF_PASSWORD: "password",
+ axis.CONF_PORT: 80,
+ axis.CONF_MAC: "mac",
+ axis.device.CONF_MODEL: "model",
+ axis.device.CONF_NAME: "name",
+ }
+ assert entry.version == 2
diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py
index d9341bb327167b..fb9bc7d5e5c0cb 100644
--- a/tests/components/bayesian/test_binary_sensor.py
+++ b/tests/components/bayesian/test_binary_sensor.py
@@ -149,7 +149,7 @@ def test_sensor_state(self):
assert state.state == "off"
def test_threshold(self):
- """Test sensor on probabilty threshold limits."""
+ """Test sensor on probability threshold limits."""
config = {
"binary_sensor": {
"name": "Test_Binary",
@@ -259,3 +259,54 @@ def test_probability_updates(self):
prior = bayesian.update_probability(prior, pt, pf)
assert round(abs(0.9130434782608695 - prior), 7) == 0
+
+ def test_observed_entities(self):
+ """Test sensor on observed entities."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ },
+ {
+ "platform": "template",
+ "value_template": "{{is_state('sensor.test_monitored1','on') and is_state('sensor.test_monitored','off')}}",
+ "prob_given_true": 0.9,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ assert setup_component(self.hass, "binary_sensor", config)
+
+ self.hass.states.set("sensor.test_monitored", "on")
+ self.hass.block_till_done()
+ self.hass.states.set("sensor.test_monitored1", "off")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert [] == state.attributes.get("occurred_observation_entities")
+
+ self.hass.states.set("sensor.test_monitored", "off")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert ["sensor.test_monitored"] == state.attributes.get(
+ "occurred_observation_entities"
+ )
+
+ self.hass.states.set("sensor.test_monitored1", "on")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert ["sensor.test_monitored", "sensor.test_monitored1"] == sorted(
+ state.attributes.get("occurred_observation_entities")
+ )
diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py
index ecf5e86bdad79f..1ac24e037024c2 100644
--- a/tests/components/binary_sensor/test_device_condition.py
+++ b/tests/components/binary_sensor/test_device_condition.py
@@ -36,7 +36,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py
index 404def664919bb..6234d464f524a8 100644
--- a/tests/components/binary_sensor/test_device_trigger.py
+++ b/tests/components/binary_sensor/test_device_trigger.py
@@ -36,7 +36,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py
index c0be635988a16a..fa6f331363f366 100644
--- a/tests/components/caldav/test_calendar.py
+++ b/tests/components/caldav/test_calendar.py
@@ -124,6 +124,96 @@
DESCRIPTION:What a day
END:VEVENT
END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//E-Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:9
+DTSTAMP:20171125T000000Z
+DTSTART:20171027T220000Z
+DTEND:20171027T223000Z
+SUMMARY:This is a recurring event
+LOCATION:Hamburg
+DESCRIPTION:Every day for a while
+RRULE:FREQ=DAILY;UNTIL=20171227T215959
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//E-Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:10
+DTSTAMP:20171125T000000Z
+DTSTART:20171027T230000Z
+DURATION:PT30M
+SUMMARY:This is a recurring event with a duration
+LOCATION:Hamburg
+DESCRIPTION:Every day for a while as well
+RRULE:FREQ=DAILY;UNTIL=20171227T215959
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//E-Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:11
+DTSTAMP:20171125T000000Z
+DTSTART:20171027T233000Z
+DTEND:20171027T235959Z
+SUMMARY:This is a recurring event that has ended
+LOCATION:Hamburg
+DESCRIPTION:Every day for a while
+RRULE:FREQ=DAILY;UNTIL=20171127T225959
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//E-Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:12
+DTSTAMP:20171125T000000Z
+DTSTART:20171027T234500Z
+DTEND:20171027T235959Z
+SUMMARY:This is a recurring event that never ends
+LOCATION:Hamburg
+DESCRIPTION:Every day forever
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:13
+DTSTAMP:20161125T000000Z
+DTSTART:20161127
+DTEND:20161128
+SUMMARY:This is a recurring all day event
+LOCATION:Hamburg
+DESCRIPTION:Groundhog Day
+RRULE:FREQ=DAILY;COUNT=100
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:14
+DTSTAMP:20151125T000000Z
+DTSTART:20151127T000000Z
+DTEND:20151127T003000Z
+SUMMARY:This is an hourly recurring event
+LOCATION:Hamburg
+DESCRIPTION:The bell tolls for thee
+RRULE:FREQ=HOURLY;INTERVAL=1;COUNT=12
+END:VEVENT
+END:VCALENDAR
""",
]
@@ -461,3 +551,227 @@ async def test_all_day_event_returned(mock_now, hass, calendar):
"location": "Hamburg",
"description": "What a beautiful day",
}
+
+
+@patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45))
+async def test_event_rrule(mock_now, hass, calendar):
+ """Test that the future recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a recurring event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 22:00:00",
+ "end_time": "2017-11-27 22:30:00",
+ "location": "Hamburg",
+ "description": "Every day for a while",
+ }
+
+
+@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15))
+async def test_event_rrule_ongoing(mock_now, hass, calendar):
+ """Test that the current recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a recurring event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 22:00:00",
+ "end_time": "2017-11-27 22:30:00",
+ "location": "Hamburg",
+ "description": "Every day for a while",
+ }
+
+
+@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45))
+async def test_event_rrule_duration(mock_now, hass, calendar):
+ """Test that the future recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a recurring event with a duration",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 23:00:00",
+ "end_time": "2017-11-27 23:30:00",
+ "location": "Hamburg",
+ "description": "Every day for a while as well",
+ }
+
+
+@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15))
+async def test_event_rrule_duration_ongoing(mock_now, hass, calendar):
+ """Test that the ongoing recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a recurring event with a duration",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 23:00:00",
+ "end_time": "2017-11-27 23:30:00",
+ "location": "Hamburg",
+ "description": "Every day for a while as well",
+ }
+
+
+@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37))
+async def test_event_rrule_endless(mock_now, hass, calendar):
+ """Test that the endless recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a recurring event that never ends",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 23:45:00",
+ "end_time": "2017-11-27 23:59:59",
+ "location": "Hamburg",
+ "description": "Every day forever",
+ }
+
+
+@patch(
+ "homeassistant.util.dt.now",
+ return_value=dt.as_local(datetime.datetime(2016, 12, 1, 17, 30)),
+)
+async def test_event_rrule_all_day(mock_now, hass, calendar):
+ """Test that the recurring all day event is returned."""
+ config = dict(CALDAV_CONFIG)
+ config["custom_calendars"] = [
+ {"name": "Private", "calendar": "Private", "search": ".*"}
+ ]
+
+ assert await async_setup_component(hass, "calendar", {"calendar": config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private_private")
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a recurring all day event",
+ "all_day": True,
+ "offset_reached": False,
+ "start_time": "2016-12-01 00:00:00",
+ "end_time": "2016-12-02 00:00:00",
+ "location": "Hamburg",
+ "description": "Groundhog Day",
+ }
+
+
+@patch(
+ "homeassistant.util.dt.now",
+ return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 15)),
+)
+async def test_event_rrule_hourly_on_first(mock_now, hass, calendar):
+ """Test that the endless recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is an hourly recurring event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2015-11-27 00:00:00",
+ "end_time": "2015-11-27 00:30:00",
+ "location": "Hamburg",
+ "description": "The bell tolls for thee",
+ }
+
+
+@patch(
+ "homeassistant.util.dt.now",
+ return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 15)),
+)
+async def test_event_rrule_hourly_on_last(mock_now, hass, calendar):
+ """Test that the endless recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is an hourly recurring event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2015-11-27 11:00:00",
+ "end_time": "2015-11-27 11:30:00",
+ "location": "Hamburg",
+ "description": "The bell tolls for thee",
+ }
+
+
+@patch(
+ "homeassistant.util.dt.now",
+ return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 45)),
+)
+async def test_event_rrule_hourly_off_first(mock_now, hass, calendar):
+ """Test that the endless recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+
+
+@patch(
+ "homeassistant.util.dt.now",
+ return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 45)),
+)
+async def test_event_rrule_hourly_off_last(mock_now, hass, calendar):
+ """Test that the endless recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+
+
+@patch(
+ "homeassistant.util.dt.now",
+ return_value=dt.as_local(datetime.datetime(2015, 11, 27, 12, 15)),
+)
+async def test_event_rrule_hourly_ended(mock_now, hass, calendar):
+ """Test that the endless recurring event is returned."""
+ assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("calendar.private")
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py
index bcd1482195d907..71005672fdb66a 100644
--- a/tests/components/cert_expiry/test_config_flow.py
+++ b/tests/components/cert_expiry/test_config_flow.py
@@ -19,7 +19,7 @@
@pytest.fixture(name="test_connect")
def mock_controller():
- """Mock a successfull _prt_in_configuration_exists."""
+ """Mock a successful _prt_in_configuration_exists."""
with patch(
"homeassistant.components.cert_expiry.config_flow.CertexpiryConfigFlow._test_connection",
side_effect=lambda *_: mock_coro(True),
diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py
index c8aaf0e19677f1..431849ae761efa 100644
--- a/tests/components/climate/test_device_condition.py
+++ b/tests/components/climate/test_device_condition.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py
index d9bfd6d5ba4fe1..eda215ebd0fed9 100644
--- a/tests/components/climate/test_device_trigger.py
+++ b/tests/components/climate/test_device_trigger.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py
index 2338f0eaa1e6e2..b9e6524b62ef2e 100644
--- a/tests/components/cloud/test_client.py
+++ b/tests/components/cloud/test_client.py
@@ -121,7 +121,7 @@ async def test_handler_google_actions(hass):
device = devices[0]
assert device["id"] == "switch.test"
assert device["name"]["name"] == "Config name"
- assert device["name"]["nicknames"] == ["Config alias"]
+ assert device["name"]["nicknames"] == ["Config name", "Config alias"]
assert device["type"] == "action.devices.types.SWITCH"
assert device["roomHint"] == "living room"
@@ -217,7 +217,7 @@ async def test_google_config_should_2fa(hass, mock_cloud_setup, mock_cloud_login
async def test_set_username(hass):
- """Test we set username during loggin."""
+ """Test we set username during login."""
prefs = MagicMock(
alexa_enabled=False,
google_enabled=False,
diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py
index b345a219d3fd22..45ffa1d08eca97 100644
--- a/tests/components/config/test_automation.py
+++ b/tests/components/config/test_automation.py
@@ -1,6 +1,7 @@
"""Test Automation config panel."""
import json
-from unittest.mock import patch
+
+from asynctest import patch
from homeassistant.bootstrap import async_setup_component
from homeassistant.components import config
@@ -47,7 +48,7 @@ def mock_write(path, data):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
- ):
+ ), patch("homeassistant.config.async_hass_config_yaml", return_value={}):
resp = await client.post(
"/api/config/automation/config/moon",
data=json.dumps({"trigger": [], "action": [], "condition": []}),
@@ -89,11 +90,12 @@ def mock_write(path, data):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
- ):
+ ), patch("homeassistant.config.async_hass_config_yaml", return_value={}):
resp = await client.post(
"/api/config/automation/config/moon",
data=json.dumps({"trigger": [], "action": [], "condition": []}),
)
+ await hass.async_block_till_done()
assert resp.status == 200
result = await resp.json()
@@ -107,8 +109,31 @@ def mock_write(path, data):
async def test_delete_automation(hass, hass_client):
"""Test deleting an automation."""
+ ent_reg = await hass.helpers.entity_registry.async_get_registry()
+
+ assert await async_setup_component(
+ hass,
+ "automation",
+ {
+ "automation": [
+ {
+ "id": "sun",
+ "trigger": {"platform": "event", "event_type": "test_event"},
+ "action": {"service": "test.automation"},
+ },
+ {
+ "id": "moon",
+ "trigger": {"platform": "event", "event_type": "test_event"},
+ "action": {"service": "test.automation"},
+ },
+ ]
+ },
+ )
+
+ assert len(ent_reg.entities) == 2
+
with patch.object(config, "SECTIONS", ["automation"]):
- await async_setup_component(hass, "config", {})
+ assert await async_setup_component(hass, "config", {})
client = await hass_client()
@@ -126,8 +151,9 @@ def mock_write(path, data):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
- ):
+ ), patch("homeassistant.config.async_hass_config_yaml", return_value={}):
resp = await client.delete("/api/config/automation/config/sun")
+ await hass.async_block_till_done()
assert resp.status == 200
result = await resp.json()
@@ -135,3 +161,5 @@ def mock_write(path, data):
assert len(written) == 1
assert written[0][0]["id"] == "moon"
+
+ assert len(ent_reg.entities) == 1
diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py
index 45c1f40d4ad62f..d8c9ea19b70ac2 100644
--- a/tests/components/config/test_customize.py
+++ b/tests/components/config/test_customize.py
@@ -1,6 +1,7 @@
"""Test Customize config panel."""
import json
-from unittest.mock import patch
+
+from asynctest import patch
from homeassistant.bootstrap import async_setup_component
from homeassistant.components import config
@@ -53,6 +54,8 @@ def mock_write(path, data):
hass.states.async_set("hello.world", "state", {"a": "b"})
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
+ ), patch(
+ "homeassistant.config.async_hass_config_yaml", return_value={},
):
resp = await client.post(
"/api/config/customize/config/hello.world",
@@ -60,6 +63,7 @@ def mock_write(path, data):
{"name": "Beer", "entities": ["light.top", "light.bottom"]}
),
)
+ await hass.async_block_till_done()
assert resp.status == 200
result = await resp.json()
diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py
index 133c88d9ceb96b..2a696624e0c8e8 100644
--- a/tests/components/config/test_entity_registry.py
+++ b/tests/components/config/test_entity_registry.py
@@ -41,6 +41,7 @@ async def test_list_entities(hass, client):
"disabled_by": None,
"entity_id": "test_domain.name",
"name": "Hello World",
+ "icon": None,
"platform": "test_platform",
},
{
@@ -49,6 +50,7 @@ async def test_list_entities(hass, client):
"disabled_by": None,
"entity_id": "test_domain.no_name",
"name": None,
+ "icon": None,
"platform": "test_platform",
},
]
@@ -85,6 +87,11 @@ async def test_get_entity(hass, client):
"platform": "test_platform",
"entity_id": "test_domain.name",
"name": "Hello World",
+ "icon": None,
+ "original_name": None,
+ "original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
await client.send_json(
@@ -103,6 +110,11 @@ async def test_get_entity(hass, client):
"platform": "test_platform",
"entity_id": "test_domain.no_name",
"name": None,
+ "icon": None,
+ "original_name": None,
+ "original_icon": None,
+ "capabilities": None,
+ "unique_id": "6789",
}
@@ -117,6 +129,7 @@ async def test_update_entity(hass, client):
# Using component.async_add_entities is equal to platform "domain"
platform="test_platform",
name="before update",
+ icon="icon:before update",
)
},
)
@@ -127,14 +140,16 @@ async def test_update_entity(hass, client):
state = hass.states.get("test_domain.world")
assert state is not None
assert state.name == "before update"
+ assert state.attributes["icon"] == "icon:before update"
- # UPDATE NAME
+ # UPDATE NAME & ICON
await client.send_json(
{
"id": 6,
"type": "config/entity_registry/update",
"entity_id": "test_domain.world",
"name": "after update",
+ "icon": "icon:after update",
}
)
@@ -147,10 +162,16 @@ async def test_update_entity(hass, client):
"platform": "test_platform",
"entity_id": "test_domain.world",
"name": "after update",
+ "icon": "icon:after update",
+ "original_name": None,
+ "original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
state = hass.states.get("test_domain.world")
assert state.name == "after update"
+ assert state.attributes["icon"] == "icon:after update"
# UPDATE DISABLED_BY TO USER
await client.send_json(
@@ -186,6 +207,11 @@ async def test_update_entity(hass, client):
"platform": "test_platform",
"entity_id": "test_domain.world",
"name": "after update",
+ "icon": "icon:after update",
+ "original_name": None,
+ "original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
@@ -229,6 +255,11 @@ async def test_update_entity_no_changes(hass, client):
"platform": "test_platform",
"entity_id": "test_domain.world",
"name": "name of entity",
+ "icon": None,
+ "original_name": None,
+ "original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
state = hass.states.get("test_domain.world")
@@ -301,6 +332,11 @@ async def test_update_entity_id(hass, client):
"platform": "test_platform",
"entity_id": "test_domain.planet",
"name": None,
+ "icon": None,
+ "original_name": None,
+ "original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
assert hass.states.get("test_domain.world") is None
diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py
index 1b79f30a5b6feb..49d168e279668d 100644
--- a/tests/components/config/test_group.py
+++ b/tests/components/config/test_group.py
@@ -61,6 +61,7 @@ def mock_write(path, data):
{"name": "Beer", "entities": ["light.top", "light.bottom"]}
),
)
+ await hass.async_block_till_done()
assert resp.status == 200
result = await resp.json()
diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py
index b40c895b620116..b51628f87aec43 100644
--- a/tests/components/config/test_scene.py
+++ b/tests/components/config/test_scene.py
@@ -1,6 +1,7 @@
"""Test Automation config panel."""
import json
-from unittest.mock import patch
+
+from asynctest import patch
from homeassistant.bootstrap import async_setup_component
from homeassistant.components import config
@@ -29,7 +30,7 @@ def mock_write(path, data):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
- ):
+ ), patch("homeassistant.config.async_hass_config_yaml", return_value={}):
resp = await client.post(
"/api/config/scene/config/light_off",
data=json.dumps(
@@ -86,7 +87,7 @@ def mock_write(path, data):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
- ):
+ ), patch("homeassistant.config.async_hass_config_yaml", return_value={}):
resp = await client.post(
"/api/config/scene/config/light_off",
data=json.dumps(
@@ -114,8 +115,23 @@ def mock_write(path, data):
async def test_delete_scene(hass, hass_client):
"""Test deleting a scene."""
+ ent_reg = await hass.helpers.entity_registry.async_get_registry()
+
+ assert await async_setup_component(
+ hass,
+ "scene",
+ {
+ "scene": [
+ {"id": "light_on", "name": "Light on", "entities": {}},
+ {"id": "light_off", "name": "Light off", "entities": {}},
+ ]
+ },
+ )
+
+ assert len(ent_reg.entities) == 2
+
with patch.object(config, "SECTIONS", ["scene"]):
- await async_setup_component(hass, "config", {})
+ assert await async_setup_component(hass, "config", {})
client = await hass_client()
@@ -133,8 +149,9 @@ def mock_write(path, data):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
- ):
+ ), patch("homeassistant.config.async_hass_config_yaml", return_value={}):
resp = await client.delete("/api/config/scene/config/light_on")
+ await hass.async_block_till_done()
assert resp.status == 200
result = await resp.json()
@@ -142,3 +159,5 @@ def mock_write(path, data):
assert len(written) == 1
assert written[0][0]["id"] == "light_off"
+
+ assert len(ent_reg.entities) == 1
diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py
index 267c57717f90e0..059cdb1f1e8c62 100644
--- a/tests/components/config/test_zwave.py
+++ b/tests/components/config/test_zwave.py
@@ -480,7 +480,7 @@ async def test_set_protection_value_failed(hass, client):
resp = await client.post(
"/api/zwave/protection/18",
- data=json.dumps({"value_id": "123456", "selection": "Protecton by Seuence"}),
+ data=json.dumps({"value_id": "123456", "selection": "Protecton by Sequence"}),
)
assert resp.status == 202
@@ -512,7 +512,7 @@ async def test_set_protection_value_nonexisting_node(hass, client):
resp = await client.post(
"/api/zwave/protection/18",
- data=json.dumps({"value_id": "123456", "selection": "Protecton by Seuence"}),
+ data=json.dumps({"value_id": "123456", "selection": "Protecton by Sequence"}),
)
assert resp.status == 404
@@ -532,7 +532,7 @@ async def test_set_protection_value_missing_class(hass, client):
resp = await client.post(
"/api/zwave/protection/17",
- data=json.dumps({"value_id": "123456", "selection": "Protecton by Seuence"}),
+ data=json.dumps({"value_id": "123456", "selection": "Protecton by Sequence"}),
)
assert resp.status == 404
diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py
index f84d210909534e..737d99cbdddc98 100644
--- a/tests/components/conversation/test_init.py
+++ b/tests/components/conversation/test_init.py
@@ -201,11 +201,9 @@ async def test_toggle_intent(hass, sentence):
async def test_http_api(hass, hass_client):
"""Test the HTTP conversation API."""
- result = await async_setup_component(hass, "homeassistant", {})
- assert result
-
- result = await async_setup_component(hass, "conversation", {})
- assert result
+ assert await async_setup_component(hass, "homeassistant", {})
+ assert await async_setup_component(hass, "conversation", {})
+ assert await async_setup_component(hass, "intent", {})
client = await hass_client()
hass.states.async_set("light.kitchen", "off")
diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py
index 13c6fd8701fa11..b355053ad362b9 100644
--- a/tests/components/cover/test_device_condition.py
+++ b/tests/components/cover/test_device_condition.py
@@ -38,7 +38,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py
index 3f82babc2ed103..50738e2c549553 100644
--- a/tests/components/cover/test_device_trigger.py
+++ b/tests/components/cover/test_device_trigger.py
@@ -38,7 +38,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/darksky/test_sensor.py b/tests/components/darksky/test_sensor.py
index bb716ed17ec68e..eff06e3bf7d6f7 100644
--- a/tests/components/darksky/test_sensor.py
+++ b/tests/components/darksky/test_sensor.py
@@ -30,7 +30,7 @@
"api_key": "foo",
"forecast": [1, 2],
"hourly_forecast": [1, 2],
- "monitored_conditions": ["sumary", "iocn", "temperature_high"],
+ "monitored_conditions": ["summary", "iocn", "temperature_high"],
"scan_interval": timedelta(seconds=120),
}
}
diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py
index 1dc8e61183b48d..864ba91fbc104d 100644
--- a/tests/components/deconz/test_binary_sensor.py
+++ b/tests/components/deconz/test_binary_sensor.py
@@ -96,7 +96,7 @@ async def test_binary_sensors(hass):
"id": "1",
"state": {"presence": True},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
presence_sensor = hass.states.get("binary_sensor.presence_sensor")
@@ -134,6 +134,28 @@ async def test_allow_clip_sensor(hass):
vibration_sensor = hass.states.get("binary_sensor.vibration_sensor")
assert vibration_sensor.state == "on"
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: False}
+ )
+ await hass.async_block_till_done()
+
+ assert "binary_sensor.presence_sensor" in gateway.deconz_ids
+ assert "binary_sensor.temperature_sensor" not in gateway.deconz_ids
+ assert "binary_sensor.clip_presence_sensor" not in gateway.deconz_ids
+ assert "binary_sensor.vibration_sensor" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 3
+
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True}
+ )
+ await hass.async_block_till_done()
+
+ assert "binary_sensor.presence_sensor" in gateway.deconz_ids
+ assert "binary_sensor.temperature_sensor" not in gateway.deconz_ids
+ assert "binary_sensor.clip_presence_sensor" in gateway.deconz_ids
+ assert "binary_sensor.vibration_sensor" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 4
+
async def test_add_new_binary_sensor(hass):
"""Test that adding a new binary sensor works."""
@@ -147,7 +169,7 @@ async def test_add_new_binary_sensor(hass):
"id": "1",
"sensor": deepcopy(SENSORS["1"]),
}
- gateway.api.async_event_handler(state_added_event)
+ gateway.api.event_handler(state_added_event)
await hass.async_block_till_done()
assert "binary_sensor.presence_sensor" in gateway.deconz_ids
diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py
index 00c03caaac788a..c03dc72019e924 100644
--- a/tests/components/deconz/test_climate.py
+++ b/tests/components/deconz/test_climate.py
@@ -95,7 +95,7 @@ async def test_climate_devices(hass):
"id": "1",
"config": {"mode": "off"},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
thermostat = hass.states.get("climate.thermostat")
@@ -109,7 +109,7 @@ async def test_climate_devices(hass):
"config": {"mode": "other"},
"state": {"on": True},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
thermostat = hass.states.get("climate.thermostat")
@@ -122,7 +122,7 @@ async def test_climate_devices(hass):
"id": "1",
"state": {"on": False},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
thermostat = hass.states.get("climate.thermostat")
@@ -214,6 +214,30 @@ async def test_clip_climate_device(hass):
clip_thermostat = hass.states.get("climate.clip_thermostat")
assert clip_thermostat.state == "heat"
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: False}
+ )
+ await hass.async_block_till_done()
+
+ assert "climate.thermostat" in gateway.deconz_ids
+ assert "sensor.thermostat" not in gateway.deconz_ids
+ assert "sensor.thermostat_battery_level" in gateway.deconz_ids
+ assert "climate.presence_sensor" not in gateway.deconz_ids
+ assert "climate.clip_thermostat" not in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 3
+
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True}
+ )
+ await hass.async_block_till_done()
+
+ assert "climate.thermostat" in gateway.deconz_ids
+ assert "sensor.thermostat" not in gateway.deconz_ids
+ assert "sensor.thermostat_battery_level" in gateway.deconz_ids
+ assert "climate.presence_sensor" not in gateway.deconz_ids
+ assert "climate.clip_thermostat" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 4
+
async def test_verify_state_update(hass):
"""Test that state update properly."""
@@ -232,7 +256,7 @@ async def test_verify_state_update(hass):
"id": "1",
"state": {"on": False},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
thermostat = hass.states.get("climate.thermostat")
@@ -252,7 +276,7 @@ async def test_add_new_climate_device(hass):
"id": "1",
"sensor": deepcopy(SENSORS["1"]),
}
- gateway.api.async_event_handler(state_added_event)
+ gateway.api.event_handler(state_added_event)
await hass.async_block_till_done()
assert "climate.thermostat" in gateway.deconz_ids
diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py
index 92dd95fc0c6abc..4873528d9824a4 100644
--- a/tests/components/deconz/test_config_flow.py
+++ b/tests/components/deconz/test_config_flow.py
@@ -3,13 +3,23 @@
import pydeconz
+from homeassistant import data_entry_flow
from homeassistant.components import ssdp
from homeassistant.components.deconz import config_flow
+from homeassistant.components.deconz.config_flow import (
+ CONF_SERIAL,
+ DECONZ_MANUFACTURERURL,
+)
+from homeassistant.components.deconz.const import (
+ CONF_ALLOW_CLIP_SENSOR,
+ CONF_ALLOW_DECONZ_GROUPS,
+ CONF_MASTER_GATEWAY,
+ DOMAIN,
+)
+from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
from .test_gateway import API_KEY, BRIDGEID, setup_deconz_integration
-from tests.common import MockConfigEntry
-
async def test_flow_1_discovered_bridge(hass, aioclient_mock):
"""Test that config flow for one discovered bridge works."""
@@ -20,10 +30,10 @@ async def test_flow_1_discovered_bridge(hass, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -36,12 +46,12 @@ async def test_flow_1_discovered_bridge(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "create_entry"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == BRIDGEID
assert result["data"] == {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_API_KEY: API_KEY,
+ CONF_HOST: "1.2.3.4",
+ CONF_PORT: 80,
+ CONF_API_KEY: API_KEY,
}
@@ -57,17 +67,17 @@ async def test_flow_2_discovered_bridges(hass, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={config_flow.CONF_HOST: "1.2.3.4"}
+ result["flow_id"], user_input={CONF_HOST: "1.2.3.4"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -80,12 +90,12 @@ async def test_flow_2_discovered_bridges(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "create_entry"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == BRIDGEID
assert result["data"] == {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_API_KEY: API_KEY,
+ CONF_HOST: "1.2.3.4",
+ CONF_PORT: 80,
+ CONF_API_KEY: API_KEY,
}
@@ -98,18 +108,17 @@ async def test_flow_manual_configuration(hass, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 80},
+ result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -128,12 +137,12 @@ async def test_flow_manual_configuration(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "create_entry"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == BRIDGEID
assert result["data"] == {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_API_KEY: API_KEY,
+ CONF_HOST: "1.2.3.4",
+ CONF_PORT: 80,
+ CONF_API_KEY: API_KEY,
}
@@ -142,10 +151,10 @@ async def test_manual_configuration_after_discovery_timeout(hass, aioclient_mock
aioclient_mock.get(pydeconz.utils.URL_DISCOVER, exc=asyncio.TimeoutError)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
assert not hass.config_entries.flow._progress[result["flow_id"]].bridges
@@ -155,10 +164,10 @@ async def test_manual_configuration_after_discovery_ResponseError(hass, aioclien
aioclient_mock.get(pydeconz.utils.URL_DISCOVER, exc=config_flow.ResponseError)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
assert not hass.config_entries.flow._progress[result["flow_id"]].bridges
@@ -174,18 +183,17 @@ async def test_manual_configuration_update_configuration(hass, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={config_flow.CONF_HOST: "2.3.4.5", config_flow.CONF_PORT: 80},
+ result["flow_id"], user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 80},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -204,9 +212,9 @@ async def test_manual_configuration_update_configuration(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "abort"
- assert result["reason"] == "updated_instance"
- assert gateway.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+ assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5"
async def test_manual_configuration_dont_update_configuration(hass, aioclient_mock):
@@ -220,18 +228,17 @@ async def test_manual_configuration_dont_update_configuration(hass, aioclient_mo
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 80},
+ result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -250,7 +257,7 @@ async def test_manual_configuration_dont_update_configuration(hass, aioclient_mo
result["flow_id"], user_input={}
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
@@ -263,18 +270,17 @@ async def test_manual_configuration_timeout_get_bridge(hass, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 80},
+ result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -291,7 +297,7 @@ async def test_manual_configuration_timeout_get_bridge(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "no_bridges"
@@ -304,10 +310,10 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post("http://1.2.3.4:80/api", exc=pydeconz.errors.ResponseError)
@@ -316,7 +322,7 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
assert result["errors"] == {"base": "no_key"}
@@ -324,16 +330,16 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock):
async def test_flow_ssdp_discovery(hass, aioclient_mock):
"""Test that config flow for one discovered bridge works."""
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
ssdp.ATTR_SSDP_LOCATION: "http://1.2.3.4:80/",
- ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL,
ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
},
context={"source": "ssdp"},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "link"
aioclient_mock.post(
@@ -346,24 +352,24 @@ async def test_flow_ssdp_discovery(hass, aioclient_mock):
result["flow_id"], user_input={}
)
- assert result["type"] == "create_entry"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == BRIDGEID
assert result["data"] == {
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_API_KEY: API_KEY,
+ CONF_HOST: "1.2.3.4",
+ CONF_PORT: 80,
+ CONF_API_KEY: API_KEY,
}
async def test_ssdp_discovery_not_deconz_bridge(hass):
"""Test a non deconz bridge being discovered over ssdp."""
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={ssdp.ATTR_UPNP_MANUFACTURER_URL: "not deconz bridge"},
context={"source": "ssdp"},
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "not_deconz_bridge"
@@ -372,18 +378,18 @@ async def test_ssdp_discovery_update_configuration(hass):
gateway = await setup_deconz_integration(hass)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
ssdp.ATTR_SSDP_LOCATION: "http://2.3.4.5:80/",
- ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL,
ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
},
context={"source": "ssdp"},
)
- assert result["type"] == "abort"
- assert result["reason"] == "updated_instance"
- assert gateway.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+ assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5"
async def test_ssdp_discovery_dont_update_configuration(hass):
@@ -391,18 +397,18 @@ async def test_ssdp_discovery_dont_update_configuration(hass):
gateway = await setup_deconz_integration(hass)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
ssdp.ATTR_SSDP_LOCATION: "http://1.2.3.4:80/",
- ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL,
ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
},
context={"source": "ssdp"},
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
- assert gateway.config_entry.data[config_flow.CONF_HOST] == "1.2.3.4"
+ assert gateway.config_entry.data[CONF_HOST] == "1.2.3.4"
async def test_ssdp_discovery_dont_update_existing_hassio_configuration(hass):
@@ -410,34 +416,34 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration(hass):
gateway = await setup_deconz_integration(hass, source="hassio")
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
ssdp.ATTR_SSDP_LOCATION: "http://1.2.3.4:80/",
- ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL,
ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
},
context={"source": "ssdp"},
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
- assert gateway.config_entry.data[config_flow.CONF_HOST] == "1.2.3.4"
+ assert gateway.config_entry.data[CONF_HOST] == "1.2.3.4"
async def test_flow_hassio_discovery(hass):
"""Test hassio discovery flow works."""
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
"addon": "Mock Addon",
- config_flow.CONF_HOST: "mock-deconz",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_SERIAL: BRIDGEID,
- config_flow.CONF_API_KEY: API_KEY,
+ CONF_HOST: "mock-deconz",
+ CONF_PORT: 80,
+ CONF_SERIAL: BRIDGEID,
+ CONF_API_KEY: API_KEY,
},
context={"source": "hassio"},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "hassio_confirm"
assert result["description_placeholders"] == {"addon": "Mock Addon"}
@@ -445,11 +451,11 @@ async def test_flow_hassio_discovery(hass):
result["flow_id"], user_input={}
)
- assert result["type"] == "create_entry"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].data == {
- config_flow.CONF_HOST: "mock-deconz",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_API_KEY: API_KEY,
+ CONF_HOST: "mock-deconz",
+ CONF_PORT: 80,
+ CONF_API_KEY: API_KEY,
}
@@ -458,21 +464,21 @@ async def test_hassio_discovery_update_configuration(hass):
gateway = await setup_deconz_integration(hass)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
- config_flow.CONF_HOST: "2.3.4.5",
- config_flow.CONF_PORT: 8080,
- config_flow.CONF_API_KEY: "updated",
- config_flow.CONF_SERIAL: BRIDGEID,
+ CONF_HOST: "2.3.4.5",
+ CONF_PORT: 8080,
+ CONF_API_KEY: "updated",
+ CONF_SERIAL: BRIDGEID,
},
context={"source": "hassio"},
)
- assert result["type"] == "abort"
- assert result["reason"] == "updated_instance"
- assert gateway.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5"
- assert gateway.config_entry.data[config_flow.CONF_PORT] == 8080
- assert gateway.config_entry.data[config_flow.CONF_API_KEY] == "updated"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+ assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5"
+ assert gateway.config_entry.data[CONF_PORT] == 8080
+ assert gateway.config_entry.data[CONF_API_KEY] == "updated"
async def test_hassio_discovery_dont_update_configuration(hass):
@@ -480,41 +486,37 @@ async def test_hassio_discovery_dont_update_configuration(hass):
await setup_deconz_integration(hass)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
data={
- config_flow.CONF_HOST: "1.2.3.4",
- config_flow.CONF_PORT: 80,
- config_flow.CONF_API_KEY: API_KEY,
- config_flow.CONF_SERIAL: BRIDGEID,
+ CONF_HOST: "1.2.3.4",
+ CONF_PORT: 80,
+ CONF_API_KEY: API_KEY,
+ CONF_SERIAL: BRIDGEID,
},
context={"source": "hassio"},
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_option_flow(hass):
"""Test config flow options."""
- entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=None)
- hass.config_entries._entries.append(entry)
+ gateway = await setup_deconz_integration(hass)
- flow = await hass.config_entries.options.async_create_flow(
- entry.entry_id, context={"source": "test"}, data=None
- )
+ result = await hass.config_entries.options.async_init(gateway.config_entry.entry_id)
- result = await flow.async_step_init()
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "deconz_devices"
- result = await flow.async_step_deconz_devices(
- user_input={
- config_flow.CONF_ALLOW_CLIP_SENSOR: False,
- config_flow.CONF_ALLOW_DECONZ_GROUPS: False,
- }
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={CONF_ALLOW_CLIP_SENSOR: False, CONF_ALLOW_DECONZ_GROUPS: False},
)
- assert result["type"] == "create_entry"
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
- config_flow.CONF_ALLOW_CLIP_SENSOR: False,
- config_flow.CONF_ALLOW_DECONZ_GROUPS: False,
+ CONF_ALLOW_CLIP_SENSOR: False,
+ CONF_ALLOW_DECONZ_GROUPS: False,
+ CONF_MASTER_GATEWAY: True,
}
diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py
index 5242fc6326ae0a..4bf0ec86f4a1b2 100644
--- a/tests/components/deconz/test_cover.py
+++ b/tests/components/deconz/test_cover.py
@@ -74,7 +74,7 @@ async def test_cover(hass):
"id": "1",
"state": {"on": True},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
level_controllable_cover = hass.states.get("cover.level_controllable_cover")
diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py
index 69584f630d66ac..dd3289dea2301d 100644
--- a/tests/components/deconz/test_deconz_event.py
+++ b/tests/components/deconz/test_deconz_event.py
@@ -70,7 +70,7 @@ async def test_deconz_events(hass):
mock_listener = Mock()
unsub = hass.bus.async_listen(CONF_DECONZ_EVENT, mock_listener)
- gateway.api.sensors["1"].async_update({"state": {"buttonevent": 2000}})
+ gateway.api.sensors["1"].update({"state": {"buttonevent": 2000}})
await hass.async_block_till_done()
assert len(mock_listener.mock_calls) == 1
@@ -85,7 +85,7 @@ async def test_deconz_events(hass):
mock_listener = Mock()
unsub = hass.bus.async_listen(CONF_DECONZ_EVENT, mock_listener)
- gateway.api.sensors["3"].async_update({"state": {"buttonevent": 2000}})
+ gateway.api.sensors["3"].update({"state": {"buttonevent": 2000}})
await hass.async_block_till_done()
assert len(mock_listener.mock_calls) == 1
@@ -101,7 +101,7 @@ async def test_deconz_events(hass):
mock_listener = Mock()
unsub = hass.bus.async_listen(CONF_DECONZ_EVENT, mock_listener)
- gateway.api.sensors["4"].async_update({"state": {"gesture": 2}})
+ gateway.api.sensors["4"].update({"state": {"gesture": 0}})
await hass.async_block_till_done()
assert len(mock_listener.mock_calls) == 1
@@ -109,7 +109,7 @@ async def test_deconz_events(hass):
"id": "switch_4",
"unique_id": "00:00:00:00:00:00:00:04",
"event": 1000,
- "gesture": 2,
+ "gesture": 0,
}
unsub()
diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py
index 8658eed3eb5807..e39722fdacbe31 100644
--- a/tests/components/deconz/test_light.py
+++ b/tests/components/deconz/test_light.py
@@ -59,6 +59,12 @@
"state": {"reachable": True},
"uniqueid": "00:00:00:00:00:00:00:02-00",
},
+ "4": {
+ "name": "On off light",
+ "state": {"on": True, "reachable": True},
+ "type": "On and Off light",
+ "uniqueid": "00:00:00:00:00:00:00:03-00",
+ },
}
@@ -91,18 +97,25 @@ async def test_lights_and_groups(hass):
assert "light.light_group" in gateway.deconz_ids
assert "light.empty_group" not in gateway.deconz_ids
assert "light.on_off_switch" not in gateway.deconz_ids
- # 4 entities
- assert len(hass.states.async_all()) == 4
+ assert "light.on_off_light" in gateway.deconz_ids
+
+ assert len(hass.states.async_all()) == 5
rgb_light = hass.states.get("light.rgb_light")
assert rgb_light.state == "on"
assert rgb_light.attributes["brightness"] == 255
assert rgb_light.attributes["hs_color"] == (224.235, 100.0)
assert rgb_light.attributes["is_deconz_group"] is False
+ assert rgb_light.attributes["supported_features"] == 61
tunable_white_light = hass.states.get("light.tunable_white_light")
assert tunable_white_light.state == "on"
assert tunable_white_light.attributes["color_temp"] == 2500
+ assert tunable_white_light.attributes["supported_features"] == 2
+
+ on_off_light = hass.states.get("light.on_off_light")
+ assert on_off_light.state == "on"
+ assert on_off_light.attributes["supported_features"] == 0
light_group = hass.states.get("light.light_group")
assert light_group.state == "on"
@@ -118,7 +131,7 @@ async def test_lights_and_groups(hass):
"id": "1",
"state": {"on": False},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
rgb_light = hass.states.get("light.rgb_light")
@@ -219,7 +232,7 @@ async def test_disable_light_groups(hass):
assert "light.empty_group" not in gateway.deconz_ids
assert "light.on_off_switch" not in gateway.deconz_ids
# 3 entities
- assert len(hass.states.async_all()) == 3
+ assert len(hass.states.async_all()) == 4
rgb_light = hass.states.get("light.rgb_light")
assert rgb_light is not None
@@ -232,3 +245,29 @@ async def test_disable_light_groups(hass):
empty_group = hass.states.get("light.empty_group")
assert empty_group is None
+
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_DECONZ_GROUPS: True}
+ )
+ await hass.async_block_till_done()
+
+ assert "light.rgb_light" in gateway.deconz_ids
+ assert "light.tunable_white_light" in gateway.deconz_ids
+ assert "light.light_group" in gateway.deconz_ids
+ assert "light.empty_group" not in gateway.deconz_ids
+ assert "light.on_off_switch" not in gateway.deconz_ids
+ # 3 entities
+ assert len(hass.states.async_all()) == 5
+
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_DECONZ_GROUPS: False}
+ )
+ await hass.async_block_till_done()
+
+ assert "light.rgb_light" in gateway.deconz_ids
+ assert "light.tunable_white_light" in gateway.deconz_ids
+ assert "light.light_group" not in gateway.deconz_ids
+ assert "light.empty_group" not in gateway.deconz_ids
+ assert "light.on_off_switch" not in gateway.deconz_ids
+ # 3 entities
+ assert len(hass.states.async_all()) == 4
diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py
index 2229031fa907b2..cda3138557d655 100644
--- a/tests/components/deconz/test_sensor.py
+++ b/tests/components/deconz/test_sensor.py
@@ -144,7 +144,7 @@ async def test_sensors(hass):
"id": "1",
"state": {"lightlevel": 2000},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
state_changed_event = {
"t": "event",
@@ -153,7 +153,7 @@ async def test_sensors(hass):
"id": "4",
"config": {"battery": 75},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
light_level_sensor = hass.states.get("sensor.light_level_sensor")
@@ -218,6 +218,40 @@ async def test_allow_clip_sensors(hass):
clip_light_level_sensor = hass.states.get("sensor.clip_light_level_sensor")
assert clip_light_level_sensor.state == "999.8"
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: False}
+ )
+ await hass.async_block_till_done()
+
+ assert "sensor.light_level_sensor" in gateway.deconz_ids
+ assert "sensor.presence_sensor" not in gateway.deconz_ids
+ assert "sensor.switch_1" not in gateway.deconz_ids
+ assert "sensor.switch_1_battery_level" not in gateway.deconz_ids
+ assert "sensor.switch_2" not in gateway.deconz_ids
+ assert "sensor.switch_2_battery_level" in gateway.deconz_ids
+ assert "sensor.daylight_sensor" not in gateway.deconz_ids
+ assert "sensor.power_sensor" in gateway.deconz_ids
+ assert "sensor.consumption_sensor" in gateway.deconz_ids
+ assert "sensor.clip_light_level_sensor" not in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 5
+
+ hass.config_entries.async_update_entry(
+ gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True}
+ )
+ await hass.async_block_till_done()
+
+ assert "sensor.light_level_sensor" in gateway.deconz_ids
+ assert "sensor.presence_sensor" not in gateway.deconz_ids
+ assert "sensor.switch_1" not in gateway.deconz_ids
+ assert "sensor.switch_1_battery_level" not in gateway.deconz_ids
+ assert "sensor.switch_2" not in gateway.deconz_ids
+ assert "sensor.switch_2_battery_level" in gateway.deconz_ids
+ assert "sensor.daylight_sensor" not in gateway.deconz_ids
+ assert "sensor.power_sensor" in gateway.deconz_ids
+ assert "sensor.consumption_sensor" in gateway.deconz_ids
+ assert "sensor.clip_light_level_sensor" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 6
+
async def test_add_new_sensor(hass):
"""Test that adding a new sensor works."""
@@ -231,7 +265,7 @@ async def test_add_new_sensor(hass):
"id": "1",
"sensor": deepcopy(SENSORS["1"]),
}
- gateway.api.async_event_handler(state_added_event)
+ gateway.api.event_handler(state_added_event)
await hass.async_block_till_done()
assert "sensor.light_level_sensor" in gateway.deconz_ids
@@ -248,14 +282,14 @@ async def test_add_battery_later(hass):
remote = gateway.api.sensors["1"]
assert len(gateway.deconz_ids) == 0
assert len(gateway.events) == 1
- assert len(remote._async_callbacks) == 2
+ assert len(remote._callbacks) == 2
- remote.async_update({"config": {"battery": 50}})
+ remote.update({"config": {"battery": 50}})
await hass.async_block_till_done()
assert len(gateway.deconz_ids) == 1
assert len(gateway.events) == 1
- assert len(remote._async_callbacks) == 2
+ assert len(remote._callbacks) == 2
battery_sensor = hass.states.get("sensor.switch_1_battery_level")
assert battery_sensor is not None
diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py
index 553e4f1f167463..6e151ebd47a173 100644
--- a/tests/components/deconz/test_switch.py
+++ b/tests/components/deconz/test_switch.py
@@ -38,6 +38,13 @@
"state": {"reachable": True},
"uniqueid": "00:00:00:00:00:00:00:03-00",
},
+ "5": {
+ "id": "On off relay id",
+ "name": "On off relay",
+ "state": {"on": True, "reachable": True},
+ "type": "On/Off light",
+ "uniqueid": "00:00:00:00:00:00:00:04-00",
+ },
}
@@ -68,7 +75,8 @@ async def test_switches(hass):
assert "switch.smart_plug" in gateway.deconz_ids
assert "switch.warning_device" in gateway.deconz_ids
assert "switch.unsupported_switch" not in gateway.deconz_ids
- assert len(hass.states.async_all()) == 4
+ assert "switch.on_off_relay" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 5
on_off_switch = hass.states.get("switch.on_off_switch")
assert on_off_switch.state == "on"
@@ -79,6 +87,9 @@ async def test_switches(hass):
warning_device = hass.states.get("switch.warning_device")
assert warning_device.state == "on"
+ on_off_relay = hass.states.get("switch.on_off_relay")
+ assert on_off_relay.state == "on"
+
state_changed_event = {
"t": "event",
"e": "changed",
@@ -86,7 +97,7 @@ async def test_switches(hass):
"id": "1",
"state": {"on": False},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
state_changed_event = {
"t": "event",
"e": "changed",
@@ -94,7 +105,7 @@ async def test_switches(hass):
"id": "3",
"state": {"alert": None},
}
- gateway.api.async_event_handler(state_changed_event)
+ gateway.api.event_handler(state_changed_event)
await hass.async_block_till_done()
on_off_switch = hass.states.get("switch.on_off_switch")
diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py
index 30fb49be47d356..e30d65112e8e12 100644
--- a/tests/components/demo/test_notify.py
+++ b/tests/components/demo/test_notify.py
@@ -8,7 +8,7 @@
import homeassistant.components.demo.notify as demo
import homeassistant.components.notify as notify
from homeassistant.core import callback
-from homeassistant.helpers import discovery, script
+from homeassistant.helpers import discovery
from homeassistant.setup import setup_component
from tests.common import assert_setup_component, get_test_home_assistant
@@ -121,7 +121,7 @@ def test_method_forwards_correct_data(self):
def test_calling_notify_from_script_loaded_from_yaml_without_title(self):
"""Test if we can call a notify from a script."""
self._setup_notify()
- conf = {
+ step = {
"service": "notify.notify",
"data": {
"data": {
@@ -130,8 +130,8 @@ def test_calling_notify_from_script_loaded_from_yaml_without_title(self):
},
"data_template": {"message": "Test 123 {{ 2 + 2 }}\n"},
}
-
- script.call_from_config(self.hass, conf)
+ setup_component(self.hass, "script", {"script": {"test": {"sequence": step}}})
+ self.hass.services.call("script", "test")
self.hass.block_till_done()
assert len(self.events) == 1
assert {
@@ -144,7 +144,7 @@ def test_calling_notify_from_script_loaded_from_yaml_without_title(self):
def test_calling_notify_from_script_loaded_from_yaml_with_title(self):
"""Test if we can call a notify from a script."""
self._setup_notify()
- conf = {
+ step = {
"service": "notify.notify",
"data": {
"data": {
@@ -153,8 +153,8 @@ def test_calling_notify_from_script_loaded_from_yaml_with_title(self):
},
"data_template": {"message": "Test 123 {{ 2 + 2 }}\n", "title": "Test"},
}
-
- script.call_from_config(self.hass, conf)
+ setup_component(self.hass, "script", {"script": {"test": {"sequence": step}}})
+ self.hass.services.call("script", "test")
self.hass.block_till_done()
assert len(self.events) == 1
assert {
diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py
index 8893319ab36f67..dc160b283ad635 100644
--- a/tests/components/derivative/test_sensor.py
+++ b/tests/components/derivative/test_sensor.py
@@ -2,6 +2,7 @@
from datetime import timedelta
from unittest.mock import patch
+from homeassistant.const import TIME_HOURS, TIME_MINUTES, TIME_SECONDS
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -38,57 +39,30 @@ async def test_state(hass):
assert state.attributes.get("unit_of_measurement") == "kW"
-async def test_dataSet1(hass):
- """Test derivative sensor state."""
- config = {
- "sensor": {
- "platform": "derivative",
- "name": "power",
- "source": "sensor.energy",
- "unit_time": "s",
- "round": 2,
- }
+async def _setup_sensor(hass, config):
+ default_config = {
+ "platform": "derivative",
+ "name": "power",
+ "source": "sensor.energy",
+ "round": 2,
}
+ config = {"sensor": dict(default_config, **config)}
assert await async_setup_component(hass, "sensor", config)
entity_id = config["sensor"]["source"]
hass.states.async_set(entity_id, 0, {})
await hass.async_block_till_done()
- # Testing a energy sensor with non-monotonic intervals and values
- for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]:
- now = dt_util.utcnow() + timedelta(seconds=time)
- with patch("homeassistant.util.dt.utcnow", return_value=now):
- hass.states.async_set(entity_id, value, {}, force_update=True)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.power")
- assert state is not None
-
- assert round(float(state.state), config["sensor"]["round"]) == -0.5
+ return config, entity_id
-async def test_dataSet2(hass):
+async def setup_tests(hass, config, times, values, expected_state):
"""Test derivative sensor state."""
- config = {
- "sensor": {
- "platform": "derivative",
- "name": "power",
- "source": "sensor.energy",
- "unit_time": "s",
- "round": 2,
- }
- }
-
- assert await async_setup_component(hass, "sensor", config)
-
- entity_id = config["sensor"]["source"]
- hass.states.async_set(entity_id, 0, {})
- await hass.async_block_till_done()
+ config, entity_id = await _setup_sensor(hass, config)
# Testing a energy sensor with non-monotonic intervals and values
- for time, value in [(20, 5), (30, 0)]:
+ for time, value in zip(times, values):
now = dt_util.utcnow() + timedelta(seconds=time)
with patch("homeassistant.util.dt.utcnow", return_value=now):
hass.states.async_set(entity_id, value, {}, force_update=True)
@@ -97,132 +71,109 @@ async def test_dataSet2(hass):
state = hass.states.get("sensor.power")
assert state is not None
- assert round(float(state.state), config["sensor"]["round"]) == -0.5
+ assert round(float(state.state), config["sensor"]["round"]) == expected_state
+ return state
-async def test_dataSet3(hass):
- """Test derivative sensor state."""
- config = {
- "sensor": {
- "platform": "derivative",
- "name": "power",
- "source": "sensor.energy",
- "unit_time": "s",
- "round": 2,
- }
- }
- assert await async_setup_component(hass, "sensor", config)
+async def test_dataSet1(hass):
+ """Test derivative sensor state."""
+ await setup_tests(
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30, 40, 50],
+ values=[10, 30, 5, 0],
+ expected_state=-0.5,
+ )
- entity_id = config["sensor"]["source"]
- hass.states.async_set(entity_id, 0, {})
- await hass.async_block_till_done()
- # Testing a energy sensor with non-monotonic intervals and values
- for time, value in [(20, 5), (30, 10)]:
- now = dt_util.utcnow() + timedelta(seconds=time)
- with patch("homeassistant.util.dt.utcnow", return_value=now):
- hass.states.async_set(entity_id, value, {}, force_update=True)
- await hass.async_block_till_done()
+async def test_dataSet2(hass):
+ """Test derivative sensor state."""
+ await setup_tests(
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[5, 0],
+ expected_state=-0.5,
+ )
- state = hass.states.get("sensor.power")
- assert state is not None
- assert round(float(state.state), config["sensor"]["round"]) == 0.5
+async def test_dataSet3(hass):
+ """Test derivative sensor state."""
+ state = await setup_tests(
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[5, 10],
+ expected_state=0.5,
+ )
- assert state.attributes.get("unit_of_measurement") == "/s"
+ assert state.attributes.get("unit_of_measurement") == f"/{TIME_SECONDS}"
async def test_dataSet4(hass):
"""Test derivative sensor state."""
- config = {
- "sensor": {
- "platform": "derivative",
- "name": "power",
- "source": "sensor.energy",
- "unit_time": "s",
- "round": 2,
- }
- }
-
- assert await async_setup_component(hass, "sensor", config)
-
- entity_id = config["sensor"]["source"]
- hass.states.async_set(entity_id, 0, {})
- await hass.async_block_till_done()
-
- # Testing a energy sensor with non-monotonic intervals and values
- for time, value in [(20, 5), (30, 5)]:
- now = dt_util.utcnow() + timedelta(seconds=time)
- with patch("homeassistant.util.dt.utcnow", return_value=now):
- hass.states.async_set(entity_id, value, {}, force_update=True)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.power")
- assert state is not None
-
- assert round(float(state.state), config["sensor"]["round"]) == 0
+ await setup_tests(
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[5, 5],
+ expected_state=0,
+ )
async def test_dataSet5(hass):
"""Test derivative sensor state."""
- config = {
- "sensor": {
- "platform": "derivative",
- "name": "power",
- "source": "sensor.energy",
- "unit_time": "s",
- "round": 2,
- }
- }
-
- assert await async_setup_component(hass, "sensor", config)
-
- entity_id = config["sensor"]["source"]
- hass.states.async_set(entity_id, 0, {})
- await hass.async_block_till_done()
-
- # Testing a energy sensor with non-monotonic intervals and values
- for time, value in [(20, 10), (30, -10)]:
- now = dt_util.utcnow() + timedelta(seconds=time)
- with patch("homeassistant.util.dt.utcnow", return_value=now):
- hass.states.async_set(entity_id, value, {}, force_update=True)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.power")
- assert state is not None
-
- assert round(float(state.state), config["sensor"]["round"]) == -2
+ await setup_tests(
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[10, -10],
+ expected_state=-2,
+ )
async def test_dataSet6(hass):
"""Test derivative sensor state."""
- config = {
- "sensor": {
- "platform": "derivative",
- "name": "power",
- "source": "sensor.energy",
- "round": 2,
- }
- }
+ await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1)
- assert await async_setup_component(hass, "sensor", config)
- entity_id = config["sensor"]["source"]
- hass.states.async_set(entity_id, 0, {})
- await hass.async_block_till_done()
-
- # Testing a energy sensor with non-monotonic intervals and values
- for time, value in [(20, 0), (30, 36000)]:
+async def test_data_moving_average_for_discrete_sensor(hass):
+ """Test derivative sensor state."""
+ # We simulate the following situation:
+ # The temperature rises 1 °C per minute for 30 minutes long.
+ # There is a data point every 30 seconds, however, the sensor returns
+ # the temperature rounded down to an integer value.
+ # We use a time window of 10 minutes and therefore we can expect
+ # (because the true derivative is 1 °C/min) an error of less than 10%.
+
+ temperature_values = []
+ for temperature in range(30):
+ temperature_values += [temperature] * 2 # two values per minute
+ time_window = 600
+ times = list(range(0, 1800 + 30, 30))
+
+ config, entity_id = await _setup_sensor(
+ hass,
+ {
+ "time_window": {"seconds": time_window},
+ "unit_time": TIME_MINUTES,
+ "round": 1,
+ },
+ ) # two minute window
+
+ for time, value in zip(times, temperature_values):
now = dt_util.utcnow() + timedelta(seconds=time)
with patch("homeassistant.util.dt.utcnow", return_value=now):
hass.states.async_set(entity_id, value, {}, force_update=True)
await hass.async_block_till_done()
- state = hass.states.get("sensor.power")
- assert state is not None
-
- assert round(float(state.state), config["sensor"]["round"]) == 1
+ if time_window < time < times[-1] - time_window:
+ state = hass.states.get("sensor.power")
+ derivative = round(float(state.state), config["sensor"]["round"])
+ # Test that the error is never more than
+ # (time_window_in_minutes / true_derivative * 100) = 10% + ε
+ assert abs(1 - derivative) <= 0.1 + 1e-6
async def test_prefix(hass):
@@ -257,7 +208,7 @@ async def test_prefix(hass):
# Testing a power sensor at 1000 Watts for 1hour = 0kW/h
assert round(float(state.state), config["sensor"]["round"]) == 0.0
- assert state.attributes.get("unit_of_measurement") == "kW/h"
+ assert state.attributes.get("unit_of_measurement") == f"kW/{TIME_HOURS}"
async def test_suffix(hass):
@@ -269,7 +220,7 @@ async def test_suffix(hass):
"source": "sensor.bytes_per_second",
"round": 2,
"unit_prefix": "k",
- "unit_time": "s",
+ "unit_time": TIME_SECONDS,
}
}
diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py
index 5d997a485a596a..48426e2640e893 100644
--- a/tests/components/device_automation/test_init.py
+++ b/tests/components/device_automation/test_init.py
@@ -610,7 +610,7 @@ async def test_automation_with_bad_condition(hass, caplog):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
@@ -761,3 +761,17 @@ async def test_automation_with_bad_trigger(hass, caplog):
)
assert "required key not provided" in caplog.text
+
+
+async def test_websocket_device_not_found(hass, hass_ws_client):
+ """Test caling command with unknown device."""
+ await async_setup_component(hass, "device_automation", {})
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 1, "type": "device_automation/action/list", "device_id": "non-existing"}
+ )
+ msg = await client.receive_json()
+
+ assert msg["id"] == 1
+ assert not msg["success"]
+ assert msg["error"] == {"code": "not_found", "message": "Device not found"}
diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py
index dda4a90f31bf06..bc4d44e1b426e1 100644
--- a/tests/components/device_sun_light_trigger/test_init.py
+++ b/tests/components/device_sun_light_trigger/test_init.py
@@ -11,9 +11,7 @@
group,
light,
)
-from homeassistant.components.device_tracker.const import (
- ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT,
-)
+from homeassistant.components.device_tracker.const import DOMAIN
from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -122,7 +120,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner):
hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}}
)
- hass.states.async_set(DT_ENTITY_ID_FORMAT.format("device_2"), STATE_HOME)
+ hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME)
await hass.async_block_till_done()
@@ -133,8 +131,8 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner):
async def test_lights_turn_on_when_coming_home_after_sun_set_person(hass, scanner):
"""Test lights turn on when coming home after sun set."""
- device_1 = DT_ENTITY_ID_FORMAT.format("device_1")
- device_2 = DT_ENTITY_ID_FORMAT.format("device_2")
+ device_1 = f"{DOMAIN}.device_1"
+ device_2 = f"{DOMAIN}.device_2"
test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC)
with patch("homeassistant.util.dt.utcnow", return_value=test_time):
@@ -187,6 +185,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person(hass, scanne
# person home switches on
hass.states.async_set(device_1, STATE_HOME)
await hass.async_block_till_done()
+ await hass.async_block_till_done()
assert all(
light.is_on(hass, ent_id)
diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py
index 15cd28e8fae93f..950ace24335385 100644
--- a/tests/components/device_tracker/test_device_condition.py
+++ b/tests/components/device_tracker/test_device_condition.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py
index 4d82f93a0298dc..3ad9e741aaecdc 100644
--- a/tests/components/device_tracker/test_init.py
+++ b/tests/components/device_tracker/test_init.py
@@ -57,7 +57,7 @@ def mock_yaml_devices(hass):
async def test_is_on(hass):
"""Test is_on method."""
- entity_id = const.ENTITY_ID_FORMAT.format("test")
+ entity_id = f"{const.DOMAIN}.test"
hass.states.async_set(entity_id, STATE_HOME)
@@ -271,7 +271,7 @@ async def test_entity_attributes(hass, mock_device_tracker_conf):
"""Test the entity attributes."""
devices = mock_device_tracker_conf
dev_id = "test_entity"
- entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{const.DOMAIN}.{dev_id}"
friendly_name = "Paulus"
picture = "http://placehold.it/200x200"
icon = "mdi:kettle"
@@ -303,7 +303,7 @@ async def test_device_hidden(hass, mock_device_tracker_conf):
"""Test hidden devices."""
devices = mock_device_tracker_conf
dev_id = "test_entity"
- entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{const.DOMAIN}.{dev_id}"
device = legacy.Device(
hass, timedelta(seconds=180), True, dev_id, None, hide_if_away=True
)
@@ -350,7 +350,7 @@ async def test_see_service_guard_config_entry(hass, mock_device_tracker_conf):
"""Test the guard if the device is registered in the entity registry."""
mock_entry = Mock()
dev_id = "test"
- entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{const.DOMAIN}.{dev_id}"
mock_registry(hass, {entity_id: mock_entry})
devices = mock_device_tracker_conf
assert await async_setup_component(hass, device_tracker.DOMAIN, TEST_PLATFORM)
diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py
index 449147c3648bb3..be805d837f50b8 100644
--- a/tests/components/directv/test_media_player.py
+++ b/tests/components/directv/test_media_player.py
@@ -60,6 +60,7 @@
from tests.common import async_fire_time_changed
+ATTR_UNIQUE_ID = "unique_id"
CLIENT_ENTITY_ID = "media_player.client_dvr"
MAIN_ENTITY_ID = "media_player.main_dvr"
IP_ADDRESS = "127.0.0.1"
@@ -138,7 +139,7 @@ def main_dtv():
def dtv_side_effect(client_dtv, main_dtv):
"""Fixture to create DIRECTV instance for main and client."""
- def mock_dtv(ip, port, client_addr):
+ def mock_dtv(ip, port, client_addr="0"):
if client_addr != "0":
mocked_dtv = client_dtv
else:
@@ -174,7 +175,7 @@ def platforms(hass, dtv_side_effect, mock_now):
"name": "Client DVR",
"host": IP_ADDRESS,
"port": DEFAULT_PORT,
- "device": "1",
+ "device": "2CA17D1CD30X",
},
]
}
@@ -272,6 +273,20 @@ def get_locations(self):
return test_locations
+ def get_serial_num(self):
+ """Mock for get_serial_num method."""
+ test_serial_num = {
+ "serialNum": "9999999999",
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/info/getSerialNum",
+ },
+ }
+
+ return test_serial_num
+
def get_standby(self):
"""Mock for get_standby method."""
return self._standby
@@ -290,6 +305,24 @@ def get_tuned(self):
}
return test_attributes
+ def get_version(self):
+ """Mock for get_version method."""
+ test_version = {
+ "accessCardId": "0021-1495-6572",
+ "receiverId": "0288 7745 5858",
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/info/getVersion",
+ },
+ "stbSoftwareVersion": "0x4ed7",
+ "systemTime": 1281625203,
+ "version": "1.2",
+ }
+
+ return test_version
+
def key_press(self, keypress):
"""Mock for key_press method."""
if keypress == "poweron":
@@ -391,6 +424,17 @@ async def test_setup_platform_discover_client(hass):
assert len(hass.states.async_entity_ids("media_player")) == 3
+async def test_unique_id(hass, platforms):
+ """Test unique id."""
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ main = entity_registry.async_get(MAIN_ENTITY_ID)
+ assert main.unique_id == "028877455858"
+
+ client = entity_registry.async_get(CLIENT_ENTITY_ID)
+ assert client.unique_id == "2CA17D1CD30X"
+
+
async def test_supported_features(hass, platforms):
"""Test supported features."""
# Features supported for main DVR
diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py
index 81249c04046e5c..297447b5038745 100644
--- a/tests/components/dsmr/test_sensor.py
+++ b/tests/components/dsmr/test_sensor.py
@@ -15,6 +15,7 @@
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.dsmr.sensor import DerivativeDSMREntity
+from homeassistant.const import TIME_HOURS, VOLUME_CUBIC_METERS
from tests.common import assert_setup_component
@@ -52,8 +53,9 @@ async def test_default_setup(hass, mock_connection_factory):
from dsmr_parser.obis_references import (
CURRENT_ELECTRICITY_USAGE,
ELECTRICITY_ACTIVE_TARIFF,
+ GAS_METER_READING,
)
- from dsmr_parser.objects import CosemObject
+ from dsmr_parser.objects import CosemObject, MBusObject
config = {"platform": "dsmr"}
@@ -62,6 +64,12 @@ async def test_default_setup(hass, mock_connection_factory):
[{"value": Decimal("0.0"), "unit": "kWh"}]
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
+ GAS_METER_READING: MBusObject(
+ [
+ {"value": datetime.datetime.fromtimestamp(1551642213)},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
+ ]
+ ),
}
with assert_setup_component(1):
@@ -90,6 +98,11 @@ async def test_default_setup(hass, mock_connection_factory):
assert power_tariff.state == "low"
assert power_tariff.attributes.get("unit_of_measurement") == ""
+ # check if gas consumption is parsed correctly
+ gas_consumption = hass.states.get("sensor.gas_consumption")
+ assert gas_consumption.state == "745.695"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+
async def test_derivative():
"""Test calculation of derivative value."""
@@ -106,7 +119,7 @@ async def test_derivative():
"1.0.0": MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
]
)
}
@@ -118,7 +131,7 @@ async def test_derivative():
"1.0.0": MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642543)},
- {"value": Decimal(745.698), "unit": "m3"},
+ {"value": Decimal(745.698), "unit": VOLUME_CUBIC_METERS},
]
)
}
@@ -128,7 +141,123 @@ async def test_derivative():
abs(entity.state - 0.033) < 0.00001
), "state should be hourly usage calculated from first and second update"
- assert entity.unit_of_measurement == "m3/h"
+ assert entity.unit_of_measurement == f"{VOLUME_CUBIC_METERS}/{TIME_HOURS}"
+
+
+async def test_v4_meter(hass, mock_connection_factory):
+ """Test if v4 meter is correctly parsed."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ from dsmr_parser.obis_references import (
+ HOURLY_GAS_METER_READING,
+ ELECTRICITY_ACTIVE_TARIFF,
+ )
+ from dsmr_parser.objects import CosemObject, MBusObject
+
+ config = {"platform": "dsmr", "dsmr_version": "4"}
+
+ telegram = {
+ HOURLY_GAS_METER_READING: MBusObject(
+ [
+ {"value": datetime.datetime.fromtimestamp(1551642213)},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
+ ]
+ ),
+ ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
+ }
+
+ with assert_setup_component(1):
+ await async_setup_component(hass, "sensor", {"sensor": config})
+
+ telegram_callback = connection_factory.call_args_list[0][0][2]
+
+ # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
+ telegram_callback(telegram)
+
+ # after receiving telegram entities need to have the chance to update
+ await asyncio.sleep(0)
+
+ # tariff should be translated in human readable and have no unit
+ power_tariff = hass.states.get("sensor.power_tariff")
+ assert power_tariff.state == "low"
+ assert power_tariff.attributes.get("unit_of_measurement") == ""
+
+ # check if gas consumption is parsed correctly
+ gas_consumption = hass.states.get("sensor.gas_consumption")
+ assert gas_consumption.state == "745.695"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+
+
+async def test_belgian_meter(hass, mock_connection_factory):
+ """Test if Belgian meter is correctly parsed."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ from dsmr_parser.obis_references import (
+ BELGIUM_HOURLY_GAS_METER_READING,
+ ELECTRICITY_ACTIVE_TARIFF,
+ )
+ from dsmr_parser.objects import CosemObject, MBusObject
+
+ config = {"platform": "dsmr", "dsmr_version": "5B"}
+
+ telegram = {
+ BELGIUM_HOURLY_GAS_METER_READING: MBusObject(
+ [
+ {"value": datetime.datetime.fromtimestamp(1551642213)},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
+ ]
+ ),
+ ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
+ }
+
+ with assert_setup_component(1):
+ await async_setup_component(hass, "sensor", {"sensor": config})
+
+ telegram_callback = connection_factory.call_args_list[0][0][2]
+
+ # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
+ telegram_callback(telegram)
+
+ # after receiving telegram entities need to have the chance to update
+ await asyncio.sleep(0)
+
+ # tariff should be translated in human readable and have no unit
+ power_tariff = hass.states.get("sensor.power_tariff")
+ assert power_tariff.state == "normal"
+ assert power_tariff.attributes.get("unit_of_measurement") == ""
+
+ # check if gas consumption is parsed correctly
+ gas_consumption = hass.states.get("sensor.gas_consumption")
+ assert gas_consumption.state == "745.695"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+
+
+async def test_belgian_meter_low(hass, mock_connection_factory):
+ """Test if Belgian meter is correctly parsed."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ from dsmr_parser.obis_references import ELECTRICITY_ACTIVE_TARIFF
+ from dsmr_parser.objects import CosemObject
+
+ config = {"platform": "dsmr", "dsmr_version": "5B"}
+
+ telegram = {ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}])}
+
+ with assert_setup_component(1):
+ await async_setup_component(hass, "sensor", {"sensor": config})
+
+ telegram_callback = connection_factory.call_args_list[0][0][2]
+
+ # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
+ telegram_callback(telegram)
+
+ # after receiving telegram entities need to have the chance to update
+ await asyncio.sleep(0)
+
+ # tariff should be translated in human readable and have no unit
+ power_tariff = hass.states.get("sensor.power_tariff")
+ assert power_tariff.state == "low"
+ assert power_tariff.attributes.get("unit_of_measurement") == ""
async def test_tcp(hass, mock_connection_factory):
diff --git a/tests/components/dynalite/__init__.py b/tests/components/dynalite/__init__.py
new file mode 100755
index 00000000000000..f97770cbac90ca
--- /dev/null
+++ b/tests/components/dynalite/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Dynalite component."""
diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py
new file mode 100755
index 00000000000000..133e03d9f3d5c9
--- /dev/null
+++ b/tests/components/dynalite/test_bridge.py
@@ -0,0 +1,81 @@
+"""Test Dynalite bridge."""
+from unittest.mock import Mock, call
+
+from asynctest import patch
+from dynalite_lib import CONF_ALL
+import pytest
+
+from homeassistant.components import dynalite
+
+
+@pytest.fixture
+def dyn_bridge():
+ """Define a basic mock bridge."""
+ hass = Mock()
+ host = "1.2.3.4"
+ bridge = dynalite.DynaliteBridge(hass, {dynalite.CONF_HOST: host})
+ return bridge
+
+
+async def test_update_device(dyn_bridge):
+ """Test a successful setup."""
+ async_dispatch = Mock()
+
+ with patch(
+ "homeassistant.components.dynalite.bridge.async_dispatcher_send", async_dispatch
+ ):
+ dyn_bridge.update_device(CONF_ALL)
+ async_dispatch.assert_called_once()
+ assert async_dispatch.mock_calls[0] == call(
+ dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}"
+ )
+ async_dispatch.reset_mock()
+ device = Mock
+ device.unique_id = "abcdef"
+ dyn_bridge.update_device(device)
+ async_dispatch.assert_called_once()
+ assert async_dispatch.mock_calls[0] == call(
+ dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}-{device.unique_id}"
+ )
+
+
+async def test_add_devices_then_register(dyn_bridge):
+ """Test that add_devices work."""
+ # First test empty
+ dyn_bridge.add_devices_when_registered([])
+ assert not dyn_bridge.waiting_devices
+ # Now with devices
+ device1 = Mock()
+ device1.category = "light"
+ device2 = Mock()
+ device2.category = "switch"
+ dyn_bridge.add_devices_when_registered([device1, device2])
+ reg_func = Mock()
+ dyn_bridge.register_add_devices(reg_func)
+ reg_func.assert_called_once()
+ assert reg_func.mock_calls[0][1][0][0] is device1
+
+
+async def test_register_then_add_devices(dyn_bridge):
+ """Test that add_devices work after register_add_entities."""
+ device1 = Mock()
+ device1.category = "light"
+ device2 = Mock()
+ device2.category = "switch"
+ reg_func = Mock()
+ dyn_bridge.register_add_devices(reg_func)
+ dyn_bridge.add_devices_when_registered([device1, device2])
+ reg_func.assert_called_once()
+ assert reg_func.mock_calls[0][1][0][0] is device1
+
+
+async def test_try_connection(dyn_bridge):
+ """Test that try connection works."""
+ # successful
+ with patch.object(dyn_bridge.dynalite_devices, "connected", True):
+ assert await dyn_bridge.try_connection()
+ # unsuccessful
+ with patch.object(dyn_bridge.dynalite_devices, "connected", False), patch(
+ "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0
+ ):
+ assert not await dyn_bridge.try_connection()
diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py
new file mode 100755
index 00000000000000..1f8be61f646004
--- /dev/null
+++ b/tests/components/dynalite/test_config_flow.py
@@ -0,0 +1,90 @@
+"""Test Dynalite config flow."""
+from asynctest import patch
+
+from homeassistant import config_entries
+from homeassistant.components import dynalite
+
+from tests.common import MockConfigEntry
+
+
+async def run_flow(hass, setup, connection):
+ """Run a flow with or without errors and return result."""
+ host = "1.2.3.4"
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=setup,
+ ), patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.available", connection
+ ), patch(
+ "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0
+ ):
+ result = await hass.config_entries.flow.async_init(
+ dynalite.DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={dynalite.CONF_HOST: host},
+ )
+ return result
+
+
+async def test_flow_works(hass):
+ """Test a successful config flow."""
+ result = await run_flow(hass, True, True)
+ assert result["type"] == "create_entry"
+
+
+async def test_flow_setup_fails(hass):
+ """Test a flow where async_setup fails."""
+ result = await run_flow(hass, False, True)
+ assert result["type"] == "abort"
+ assert result["reason"] == "bridge_setup_failed"
+
+
+async def test_flow_no_connection(hass):
+ """Test a flow where connection times out."""
+ result = await run_flow(hass, True, False)
+ assert result["type"] == "abort"
+ assert result["reason"] == "no_connection"
+
+
+async def test_existing(hass):
+ """Test when the entry exists with the same config."""
+ host = "1.2.3.4"
+ MockConfigEntry(
+ domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host}
+ ).add_to_hass(hass)
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True
+ ):
+ result = await hass.config_entries.flow.async_init(
+ dynalite.DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={dynalite.CONF_HOST: host},
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+
+async def test_existing_update(hass):
+ """Test when the entry exists with the same config."""
+ host = "1.2.3.4"
+ mock_entry = MockConfigEntry(
+ domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host}
+ )
+ mock_entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True
+ ):
+ result = await hass.config_entries.flow.async_init(
+ dynalite.DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={dynalite.CONF_HOST: host, "aaa": "bbb"},
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+ assert mock_entry.data.get("aaa") == "bbb"
diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py
new file mode 100755
index 00000000000000..d8ef0d7d259db3
--- /dev/null
+++ b/tests/components/dynalite/test_init.py
@@ -0,0 +1,62 @@
+"""Test Dynalite __init__."""
+
+from asynctest import patch
+
+from homeassistant.components import dynalite
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+async def test_empty_config(hass):
+ """Test with an empty config."""
+ assert await async_setup_component(hass, dynalite.DOMAIN, {}) is True
+ assert len(hass.config_entries.flow.async_progress()) == 0
+ assert hass.data[dynalite.DOMAIN] == {}
+
+
+async def test_async_setup(hass):
+ """Test a successful setup."""
+ host = "1.2.3.4"
+ with patch(
+ "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True
+ ), patch("dynalite_devices_lib.DynaliteDevices.available", True):
+ assert await async_setup_component(
+ hass,
+ dynalite.DOMAIN,
+ {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
+ )
+
+ assert len(hass.data[dynalite.DOMAIN]) == 1
+
+
+async def test_async_setup_failed(hass):
+ """Test a setup when DynaliteBridge.async_setup fails."""
+ host = "1.2.3.4"
+ with patch("dynalite_devices_lib.DynaliteDevices.async_setup", return_value=False):
+ assert await async_setup_component(
+ hass,
+ dynalite.DOMAIN,
+ {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
+ )
+ assert hass.data[dynalite.DOMAIN] == {}
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ host = "1.2.3.4"
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={"host": host})
+ entry.add_to_hass(hass)
+
+ with patch(
+ "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True
+ ), patch("dynalite_devices_lib.DynaliteDevices.available", True):
+ assert await async_setup_component(
+ hass,
+ dynalite.DOMAIN,
+ {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
+ )
+ assert hass.data[dynalite.DOMAIN].get(entry.entry_id)
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ assert not hass.data[dynalite.DOMAIN].get(entry.entry_id)
diff --git a/tests/components/dynalite/test_light.py b/tests/components/dynalite/test_light.py
new file mode 100755
index 00000000000000..9934bac8720065
--- /dev/null
+++ b/tests/components/dynalite/test_light.py
@@ -0,0 +1,78 @@
+"""Test Dynalite light."""
+from unittest.mock import Mock
+
+from asynctest import CoroutineMock, patch
+import pytest
+
+from homeassistant.components import dynalite
+from homeassistant.components.light import SUPPORT_BRIGHTNESS
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture
+def mock_device():
+ """Mock a Dynalite device."""
+ device = Mock()
+ device.category = "light"
+ device.unique_id = "UNIQUE"
+ device.name = "NAME"
+ device.device_info = {
+ "identifiers": {(dynalite.DOMAIN, device.unique_id)},
+ "name": device.name,
+ "manufacturer": "Dynalite",
+ }
+ return device
+
+
+async def create_light_from_device(hass, device):
+ """Set up the component and platform and create a light based on the device provided."""
+ host = "1.2.3.4"
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True
+ ):
+ assert await async_setup_component(
+ hass,
+ dynalite.DOMAIN,
+ {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
+ )
+ await hass.async_block_till_done()
+ # Find the bridge
+ bridge = None
+ assert len(hass.data[dynalite.DOMAIN]) == 1
+ key = next(iter(hass.data[dynalite.DOMAIN]))
+ bridge = hass.data[dynalite.DOMAIN][key]
+ bridge.dynalite_devices.newDeviceFunc([device])
+ await hass.async_block_till_done()
+
+
+async def test_light_setup(hass, mock_device):
+ """Test a successful setup."""
+ await create_light_from_device(hass, mock_device)
+ entity_state = hass.states.get("light.name")
+ assert entity_state.attributes["brightness"] == mock_device.brightness
+ assert entity_state.attributes["supported_features"] == SUPPORT_BRIGHTNESS
+
+
+async def test_turn_on(hass, mock_device):
+ """Test turning a light on."""
+ mock_device.async_turn_on = CoroutineMock(return_value=True)
+ await create_light_from_device(hass, mock_device)
+ await hass.services.async_call(
+ "light", "turn_on", {"entity_id": "light.name"}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_device.async_turn_on.assert_awaited_once()
+
+
+async def test_turn_off(hass, mock_device):
+ """Test turning a light off."""
+ mock_device.async_turn_off = CoroutineMock(return_value=True)
+ await create_light_from_device(hass, mock_device)
+ await hass.services.async_call(
+ "light", "turn_off", {"entity_id": "light.name"}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_device.async_turn_off.assert_awaited_once()
diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py
index 442ea913b46fe1..e540d3047312f7 100644
--- a/tests/components/dyson/test_sensor.py
+++ b/tests/components/dyson/test_sensor.py
@@ -8,7 +8,7 @@
from homeassistant.components import dyson as dyson_parent
from homeassistant.components.dyson import sensor as dyson
-from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, TIME_HOURS
from homeassistant.helpers import discovery
from homeassistant.setup import async_setup_component
@@ -123,7 +123,7 @@ def test_dyson_filter_life_sensor(self):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state is None
- assert sensor.unit_of_measurement == "hours"
+ assert sensor.unit_of_measurement == TIME_HOURS
assert sensor.name == "Device_name Filter Life"
assert sensor.entity_id == "sensor.dyson_1"
sensor.on_message("message")
@@ -135,7 +135,7 @@ def test_dyson_filter_life_sensor_with_values(self):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state == 100
- assert sensor.unit_of_measurement == "hours"
+ assert sensor.unit_of_measurement == TIME_HOURS
assert sensor.name == "Device_name Filter Life"
assert sensor.entity_id == "sensor.dyson_1"
sensor.on_message("message")
diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py
index 2fb5c48e768390..30b715c136b105 100644
--- a/tests/components/emulated_hue/test_hue_api.py
+++ b/tests/components/emulated_hue/test_hue_api.py
@@ -32,10 +32,20 @@
HueOneLightStateView,
HueUsernameView,
)
-from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_OFF,
+ STATE_ON,
+)
import homeassistant.util.dt as dt_util
-from tests.common import async_fire_time_changed, get_test_instance_port
+from tests.common import (
+ async_fire_time_changed,
+ async_mock_service,
+ get_test_instance_port,
+)
HTTP_SERVER_PORT = get_test_instance_port()
BRIDGE_SERVER_PORT = get_test_instance_port()
@@ -228,7 +238,65 @@ async def test_light_without_brightness_supported(hass_hue, hue_client):
)
assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True
- assert light_without_brightness_json["type"] == "On/off light"
+ assert light_without_brightness_json["type"] == "Dimmable light"
+
+
+async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client):
+ """Test that light without brightness can be turned off."""
+ hass_hue.states.async_set("light.no_brightness", "on", {})
+
+ # Check if light can be turned off
+ turn_off_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_OFF)
+
+ no_brightness_result = await perform_put_light_state(
+ hass_hue, hue_client, "light.no_brightness", False
+ )
+ no_brightness_result_json = await no_brightness_result.json()
+
+ assert no_brightness_result.status == 200
+ assert "application/json" in no_brightness_result.headers["content-type"]
+ assert len(no_brightness_result_json) == 1
+
+ # Verify that SERVICE_TURN_OFF has been called
+ await hass_hue.async_block_till_done()
+ assert 1 == len(turn_off_calls)
+ call = turn_off_calls[-1]
+
+ assert light.DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert "light.no_brightness" in call.data[ATTR_ENTITY_ID]
+
+
+async def test_light_without_brightness_can_be_turned_on(hass_hue, hue_client):
+ """Test that light without brightness can be turned on."""
+ hass_hue.states.async_set("light.no_brightness", "off", {})
+
+ # Check if light can be turned on
+ turn_on_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_ON)
+
+ no_brightness_result = await perform_put_light_state(
+ hass_hue,
+ hue_client,
+ "light.no_brightness",
+ True,
+ # Some remotes, like HarmonyHub send brightness value regardless of light's features
+ brightness=0,
+ )
+
+ no_brightness_result_json = await no_brightness_result.json()
+
+ assert no_brightness_result.status == 200
+ assert "application/json" in no_brightness_result.headers["content-type"]
+ assert len(no_brightness_result_json) == 1
+
+ # Verify that SERVICE_TURN_ON has been called
+ await hass_hue.async_block_till_done()
+ assert 1 == len(turn_on_calls)
+ call = turn_on_calls[-1]
+
+ assert light.DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert "light.no_brightness" in call.data[ATTR_ENTITY_ID]
@pytest.mark.parametrize(
@@ -542,7 +610,7 @@ async def test_close_cover(hass_hue, hue_client):
async def test_set_position_cover(hass_hue, hue_client):
- """Test setting postion cover ."""
+ """Test setting position cover ."""
COVER_ID = "cover.living_room_window"
# Turn the office light off first
await hass_hue.services.async_call(
diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py
index e23cc4f09826cf..c4c85d1cee06af 100644
--- a/tests/components/facebook/test_notify.py
+++ b/tests/components/facebook/test_notify.py
@@ -88,7 +88,7 @@ def test_send_targetless_message(self, mock):
"""Test sending a message without a target."""
mock.register_uri(requests_mock.POST, facebook.BASE_URL, status_code=200)
- self.facebook.send_message(message="goin nowhere")
+ self.facebook.send_message(message="going nowhere")
assert not mock.called
@requests_mock.Mocker()
diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py
index 6b248ba1c3c3fd..8506cd2d817120 100644
--- a/tests/components/facebox/test_image_processing.py
+++ b/tests/components/facebox/test_image_processing.py
@@ -119,7 +119,7 @@ def mock_open_file():
def test_check_box_health(caplog):
"""Test check box health."""
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/healthz".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/healthz"
mock_req.get(url, status_code=HTTP_OK, json=MOCK_HEALTH)
assert fb.check_box_health(url, "user", "pass") == MOCK_BOX_ID
@@ -184,7 +184,7 @@ def mock_face_event(event):
hass.bus.async_listen("image_processing.detect_face", mock_face_event)
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check"
mock_req.post(url, json=MOCK_JSON)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
@@ -219,7 +219,7 @@ async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog):
# Test connection error.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check"
mock_req.register_uri("POST", url, exc=requests.exceptions.ConnectTimeout)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
@@ -233,7 +233,7 @@ async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog):
# Now test with bad auth.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check"
mock_req.register_uri("POST", url, status_code=HTTP_UNAUTHORIZED)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
@@ -253,7 +253,7 @@ async def test_teach_service(
# Test successful teach.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, status_code=HTTP_OK)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -267,7 +267,7 @@ async def test_teach_service(
# Now test with bad auth.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, status_code=HTTP_UNAUTHORIZED)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -282,7 +282,7 @@ async def test_teach_service(
# Now test the failed teaching.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, status_code=HTTP_BAD_REQUEST, text=MOCK_ERROR_NO_FACE)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -297,7 +297,7 @@ async def test_teach_service(
# Now test connection error.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, exc=requests.exceptions.ConnectTimeout)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -313,7 +313,7 @@ async def test_teach_service(
async def test_setup_platform_with_name(hass, mock_healthybox):
"""Set up platform with one entity and a name."""
- named_entity_id = "image_processing.{}".format(MOCK_NAME)
+ named_entity_id = f"image_processing.{MOCK_NAME}"
valid_config_named = VALID_CONFIG.copy()
valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME
diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py
index e665f9d5ddc26b..939fee154c5c6b 100644
--- a/tests/components/fan/test_device_condition.py
+++ b/tests/components/fan/test_device_condition.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py
index 3d4f4229965660..c46b3a6fcec8ed 100644
--- a/tests/components/fan/test_device_trigger.py
+++ b/tests/components/fan/test_device_trigger.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
@@ -119,14 +119,10 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("fan.entity", STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 1
- assert calls[0].data["some"] == "turn_on - device - {} - off - on - None".format(
- "fan.entity"
- )
+ assert calls[0].data["some"] == "turn_on - device - fan.entity - off - on - None"
# Fake that the entity is turning off.
hass.states.async_set("fan.entity", STATE_OFF)
await hass.async_block_till_done()
assert len(calls) == 2
- assert calls[1].data["some"] == "turn_off - device - {} - on - off - None".format(
- "fan.entity"
- )
+ assert calls[1].data["some"] == "turn_off - device - fan.entity - on - off - None"
diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py
index 048be11e079c84..58a660fcb5d0f9 100644
--- a/tests/components/feedreader/test_init.py
+++ b/tests/components/feedreader/test_init.py
@@ -39,7 +39,7 @@ def setUp(self):
"""Initialize values for this testcase class."""
self.hass = get_test_home_assistant()
# Delete any previously stored data
- data_file = self.hass.config.path("{}.pickle".format("feedreader"))
+ data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
if exists(data_file):
remove(data_file)
@@ -85,7 +85,7 @@ def record_event(event):
# Loading raw data from fixture and plug in to data object as URL
# works since the third-party feedparser library accepts a URL
# as well as the actual data.
- data_file = self.hass.config.path("{}.pickle".format(feedreader.DOMAIN))
+ data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
storage = StoredData(data_file)
with patch(
"homeassistant.components.feedreader.track_time_interval"
@@ -179,7 +179,7 @@ def test_feed_invalid_data(self):
@mock.patch("feedparser.parse", return_value=None)
def test_feed_parsing_failed(self, mock_parse):
"""Test feed where parsing fails."""
- data_file = self.hass.config.path("{}.pickle".format(feedreader.DOMAIN))
+ data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
storage = StoredData(data_file)
manager = FeedManager(
"FEED DATA", DEFAULT_SCAN_INTERVAL, DEFAULT_MAX_ENTRIES, self.hass, storage
diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py
index 52524d5b189968..bd5ae68cb3729a 100644
--- a/tests/components/file/test_notify.py
+++ b/tests/components/file/test_notify.py
@@ -56,8 +56,9 @@ def _test_notify_file(self, timestamp):
):
mock_st.return_value.st_size = 0
- title = "{} notifications (Log started: {})\n{}\n".format(
- ATTR_TITLE_DEFAULT, dt_util.utcnow().isoformat(), "-" * 80
+ title = (
+ f"{ATTR_TITLE_DEFAULT} notifications "
+ f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n"
)
self.hass.services.call(
@@ -72,12 +73,12 @@ def _test_notify_file(self, timestamp):
if not timestamp:
assert m_open.return_value.write.call_args_list == [
call(title),
- call("{}\n".format(message)),
+ call(f"{message}\n"),
]
else:
assert m_open.return_value.write.call_args_list == [
call(title),
- call("{} {}\n".format(dt_util.utcnow().isoformat(), message)),
+ call(f"{dt_util.utcnow().isoformat()} {message}\n"),
]
def test_notify_file(self):
diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py
index b3d0a00896179a..13824dff9c31ec 100644
--- a/tests/components/flux/test_switch.py
+++ b/tests/components/flux/test_switch.py
@@ -923,9 +923,9 @@ async def test_flux_with_multiple_lights(hass):
def event_date(hass, event, now=None):
if event == SUN_EVENT_SUNRISE:
- print("sunrise {}".format(sunrise_time))
+ print(f"sunrise {sunrise_time}")
return sunrise_time
- print("sunset {}".format(sunset_time))
+ print(f"sunset {sunset_time}")
return sunset_time
with patch(
diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py
index 9c6a17264ebadb..a843f9a301244a 100644
--- a/tests/components/foobot/test_sensor.py
+++ b/tests/components/foobot/test_sensor.py
@@ -8,7 +8,12 @@
from homeassistant.components.foobot import sensor as foobot
import homeassistant.components.sensor as sensor
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
+ TEMP_CELSIUS,
+)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.setup import async_setup_component
@@ -33,11 +38,11 @@ async def test_default_setup(hass, aioclient_mock):
assert await async_setup_component(hass, sensor.DOMAIN, {"sensor": VALID_CONFIG})
metrics = {
- "co2": ["1232.0", "ppm"],
+ "co2": ["1232.0", CONCENTRATION_PARTS_PER_MILLION],
"temperature": ["21.1", TEMP_CELSIUS],
"humidity": ["49.5", "%"],
- "pm2_5": ["144.8", "µg/m3"],
- "voc": ["340.7", "ppb"],
+ "pm2_5": ["144.8", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER],
+ "voc": ["340.7", CONCENTRATION_PARTS_PER_BILLION],
"index": ["138.9", "%"],
}
diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py
index bbd332bb487042..b535b35e182c7d 100644
--- a/tests/components/fritzbox/test_climate.py
+++ b/tests/components/fritzbox/test_climate.py
@@ -91,7 +91,7 @@ def test_set_temperature_none(self, mock_set_op):
@patch.object(FritzboxThermostat, "set_hvac_mode")
def test_set_temperature_operation_mode_precedence(self, mock_set_op):
- """Test set_temperature for precedence of operation_mode arguement."""
+ """Test set_temperature for precedence of operation_mode argument."""
self.thermostat.set_temperature(hvac_mode="heat", temperature=23.0)
mock_set_op.assert_called_once_with("heat")
self.thermostat._device.set_target_temperature.assert_not_called()
diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py
index f9f2519221151e..627bf23341d9e8 100644
--- a/tests/components/frontend/test_init.py
+++ b/tests/components/frontend/test_init.py
@@ -126,6 +126,16 @@ async def test_themes_api(hass, hass_ws_client):
assert msg["result"]["default_theme"] == "default"
assert msg["result"]["themes"] == {"happy": {"primary-color": "red"}}
+ # safe mode
+ hass.config.safe_mode = True
+ await client.send_json({"id": 6, "type": "frontend/get_themes"})
+ msg = await client.receive_json()
+
+ assert msg["result"]["default_theme"] == "safe_mode"
+ assert msg["result"]["themes"] == {
+ "safe_mode": {"primary-color": "#db4437", "accent-color": "#eeee02"}
+ }
+
async def test_themes_set_theme(hass, hass_ws_client):
"""Test frontend.set_theme service."""
diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py
index d907f69bbf9073..d4cf1916c52a29 100644
--- a/tests/components/frontend/test_storage.py
+++ b/tests/components/frontend/test_storage.py
@@ -1,7 +1,7 @@
"""The tests for frontend storage."""
import pytest
-from homeassistant.components.frontend import storage
+from homeassistant.components.frontend import DOMAIN
from homeassistant.setup import async_setup_component
@@ -26,7 +26,7 @@ async def test_get_user_data_empty(hass, hass_ws_client, hass_storage):
async def test_get_user_data(hass, hass_ws_client, hass_admin_user, hass_storage):
"""Test get_user_data command."""
- storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id)
+ storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}"
hass_storage[storage_key] = {
"key": storage_key,
"version": 1,
@@ -102,7 +102,7 @@ async def test_set_user_data_empty(hass, hass_ws_client, hass_storage):
async def test_set_user_data(hass, hass_ws_client, hass_storage, hass_admin_user):
"""Test set_user_data command with initial data."""
- storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id)
+ storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}"
hass_storage[storage_key] = {
"version": 1,
"data": {"test-key": "test-value", "test-complex": "string"},
diff --git a/tests/components/garmin_connect/__init__py b/tests/components/garmin_connect/__init__py
new file mode 100644
index 00000000000000..26de06ae0ac8f3
--- /dev/null
+++ b/tests/components/garmin_connect/__init__py
@@ -0,0 +1 @@
+"""Tests for the Garmin Connect component."""
diff --git a/tests/components/garmin_connect/test_config_flow.py b/tests/components/garmin_connect/test_config_flow.py
new file mode 100644
index 00000000000000..276b6f46871c1e
--- /dev/null
+++ b/tests/components/garmin_connect/test_config_flow.py
@@ -0,0 +1,100 @@
+"""Test the Garmin Connect config flow."""
+from unittest.mock import patch
+
+from garminconnect import (
+ GarminConnectAuthenticationError,
+ GarminConnectConnectionError,
+ GarminConnectTooManyRequestsError,
+)
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.garmin_connect.const import DOMAIN
+from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME
+
+from tests.common import MockConfigEntry
+
+MOCK_CONF = {
+ CONF_ID: "First Lastname",
+ CONF_USERNAME: "my@email.address",
+ CONF_PASSWORD: "mypassw0rd",
+}
+
+
+@pytest.fixture(name="mock_garmin_connect")
+def mock_garmin():
+ """Mock Garmin."""
+ with patch("homeassistant.components.garmin_connect.config_flow.Garmin",) as garmin:
+ garmin.return_value.get_full_name.return_value = MOCK_CONF[CONF_ID]
+ yield garmin.return_value
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+
+async def test_step_user(hass, mock_garmin_connect):
+ """Test registering an integration and finishing flow works."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_CONF
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"] == MOCK_CONF
+
+
+async def test_connection_error(hass, mock_garmin_connect):
+ """Test for connection error."""
+ mock_garmin_connect.login.side_effect = GarminConnectConnectionError("errormsg")
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_CONF
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_authentication_error(hass, mock_garmin_connect):
+ """Test for authentication error."""
+ mock_garmin_connect.login.side_effect = GarminConnectAuthenticationError("errormsg")
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_CONF
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "invalid_auth"}
+
+
+async def test_toomanyrequest_error(hass, mock_garmin_connect):
+ """Test for toomanyrequests error."""
+ mock_garmin_connect.login.side_effect = GarminConnectTooManyRequestsError(
+ "errormsg"
+ )
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_CONF
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "too_many_requests"}
+
+
+async def test_unknown_error(hass, mock_garmin_connect):
+ """Test for unknown error."""
+ mock_garmin_connect.login.side_effect = Exception
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_CONF
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_abort_if_already_setup(hass, mock_garmin_connect):
+ """Test abort if already setup."""
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONF, unique_id=MOCK_CONF[CONF_ID])
+ entry.add_to_hass(hass)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_CONF
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
diff --git a/tests/components/gdacs/__init__.py b/tests/components/gdacs/__init__.py
new file mode 100644
index 00000000000000..6e61b86dbb7c4b
--- /dev/null
+++ b/tests/components/gdacs/__init__.py
@@ -0,0 +1,41 @@
+"""Tests for the GDACS component."""
+from unittest.mock import MagicMock
+
+
+def _generate_mock_feed_entry(
+ external_id,
+ title,
+ distance_to_home,
+ coordinates,
+ attribution=None,
+ alert_level=None,
+ country=None,
+ duration_in_week=None,
+ event_name=None,
+ event_type_short=None,
+ event_type=None,
+ from_date=None,
+ to_date=None,
+ population=None,
+ severity=None,
+ vulnerability=None,
+):
+ """Construct a mock feed entry for testing purposes."""
+ feed_entry = MagicMock()
+ feed_entry.external_id = external_id
+ feed_entry.title = title
+ feed_entry.distance_to_home = distance_to_home
+ feed_entry.coordinates = coordinates
+ feed_entry.attribution = attribution
+ feed_entry.alert_level = alert_level
+ feed_entry.country = country
+ feed_entry.duration_in_week = duration_in_week
+ feed_entry.event_name = event_name
+ feed_entry.event_type_short = event_type_short
+ feed_entry.event_type = event_type
+ feed_entry.from_date = from_date
+ feed_entry.to_date = to_date
+ feed_entry.population = population
+ feed_entry.severity = severity
+ feed_entry.vulnerability = vulnerability
+ return feed_entry
diff --git a/tests/components/gdacs/conftest.py b/tests/components/gdacs/conftest.py
new file mode 100644
index 00000000000000..47185cf5387055
--- /dev/null
+++ b/tests/components/gdacs/conftest.py
@@ -0,0 +1,31 @@
+"""Configuration for GDACS tests."""
+import pytest
+
+from homeassistant.components.gdacs import CONF_CATEGORIES, DOMAIN
+from homeassistant.const import (
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_RADIUS,
+ CONF_SCAN_INTERVAL,
+ CONF_UNIT_SYSTEM,
+)
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def config_entry():
+ """Create a mock GDACS config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_LATITUDE: -41.2,
+ CONF_LONGITUDE: 174.7,
+ CONF_RADIUS: 25,
+ CONF_UNIT_SYSTEM: "metric",
+ CONF_SCAN_INTERVAL: 300.0,
+ CONF_CATEGORIES: [],
+ },
+ title="-41.2, 174.7",
+ unique_id="-41.2, 174.7",
+ )
diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py
new file mode 100644
index 00000000000000..f04f8158862dc4
--- /dev/null
+++ b/tests/components/gdacs/test_config_flow.py
@@ -0,0 +1,76 @@
+"""Define tests for the GDACS config flow."""
+from datetime import timedelta
+
+from homeassistant import data_entry_flow
+from homeassistant.components.gdacs import CONF_CATEGORIES, DOMAIN
+from homeassistant.const import (
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_RADIUS,
+ CONF_SCAN_INTERVAL,
+)
+
+
+async def test_duplicate_error(hass, config_entry):
+ """Test that errors are shown when duplicates are added."""
+ conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25}
+ config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=conf
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+
+async def test_step_import(hass):
+ """Test that the import step works."""
+ conf = {
+ CONF_LATITUDE: -41.2,
+ CONF_LONGITUDE: 174.7,
+ CONF_RADIUS: 25,
+ CONF_SCAN_INTERVAL: timedelta(minutes=4),
+ CONF_CATEGORIES: ["Drought", "Earthquake"],
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data=conf
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "-41.2, 174.7"
+ assert result["data"] == {
+ CONF_LATITUDE: -41.2,
+ CONF_LONGITUDE: 174.7,
+ CONF_RADIUS: 25,
+ CONF_SCAN_INTERVAL: 240.0,
+ CONF_CATEGORIES: ["Drought", "Earthquake"],
+ }
+
+
+async def test_step_user(hass):
+ """Test that the user step works."""
+ hass.config.latitude = -41.2
+ hass.config.longitude = 174.7
+ conf = {CONF_RADIUS: 25}
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=conf
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "-41.2, 174.7"
+ assert result["data"] == {
+ CONF_LATITUDE: -41.2,
+ CONF_LONGITUDE: 174.7,
+ CONF_RADIUS: 25,
+ CONF_SCAN_INTERVAL: 300.0,
+ CONF_CATEGORIES: [],
+ }
diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py
new file mode 100644
index 00000000000000..c426b081e21740
--- /dev/null
+++ b/tests/components/gdacs/test_geo_location.py
@@ -0,0 +1,242 @@
+"""The tests for the GDACS Feed integration."""
+import datetime
+
+from asynctest import patch
+
+from homeassistant.components import gdacs
+from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED
+from homeassistant.components.gdacs.geo_location import (
+ ATTR_ALERT_LEVEL,
+ ATTR_COUNTRY,
+ ATTR_DESCRIPTION,
+ ATTR_DURATION_IN_WEEK,
+ ATTR_EVENT_TYPE,
+ ATTR_EXTERNAL_ID,
+ ATTR_FROM_DATE,
+ ATTR_POPULATION,
+ ATTR_SEVERITY,
+ ATTR_TO_DATE,
+ ATTR_VULNERABILITY,
+)
+from homeassistant.components.geo_location import ATTR_SOURCE
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ ATTR_FRIENDLY_NAME,
+ ATTR_ICON,
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ ATTR_UNIT_OF_MEASUREMENT,
+ CONF_RADIUS,
+ EVENT_HOMEASSISTANT_START,
+)
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+from homeassistant.util.unit_system import IMPERIAL_SYSTEM
+
+from tests.common import async_fire_time_changed
+from tests.components.gdacs import _generate_mock_feed_entry
+
+CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}}
+
+
+async def test_setup(hass):
+ """Test the general setup of the integration."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ "1234",
+ "Description 1",
+ 15.5,
+ (38.0, -3.0),
+ event_name="Name 1",
+ event_type_short="DR",
+ event_type="Drought",
+ alert_level="Alert Level 1",
+ country="Country 1",
+ attribution="Attribution 1",
+ from_date=datetime.datetime(2020, 1, 10, 8, 0, tzinfo=datetime.timezone.utc),
+ to_date=datetime.datetime(2020, 1, 20, 8, 0, tzinfo=datetime.timezone.utc),
+ duration_in_week=1,
+ population="Population 1",
+ severity="Severity 1",
+ vulnerability="Vulnerability 1",
+ )
+ mock_entry_2 = _generate_mock_feed_entry(
+ "2345",
+ "Description 2",
+ 20.5,
+ (38.1, -3.1),
+ event_name="Name 2",
+ event_type_short="TC",
+ event_type="Tropical Cyclone",
+ )
+ mock_entry_3 = _generate_mock_feed_entry(
+ "3456",
+ "Description 3",
+ 25.5,
+ (38.2, -3.2),
+ event_name="Name 3",
+ event_type_short="TC",
+ event_type="Tropical Cyclone",
+ country="Country 2",
+ )
+ mock_entry_4 = _generate_mock_feed_entry(
+ "4567", "Description 4", 12.5, (38.3, -3.3)
+ )
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch(
+ "aio_georss_client.feed.GeoRssFeed.update"
+ ) as mock_feed_update:
+ mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3]
+ assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG)
+ # Artificially trigger update and collect events.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ # 3 geolocation and 1 sensor entities
+ assert len(all_states) == 4
+
+ state = hass.states.get("geo_location.drought_name_1")
+ assert state is not None
+ assert state.name == "Drought: Name 1"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "1234",
+ ATTR_LATITUDE: 38.0,
+ ATTR_LONGITUDE: -3.0,
+ ATTR_FRIENDLY_NAME: "Drought: Name 1",
+ ATTR_DESCRIPTION: "Description 1",
+ ATTR_COUNTRY: "Country 1",
+ ATTR_ATTRIBUTION: "Attribution 1",
+ ATTR_FROM_DATE: datetime.datetime(
+ 2020, 1, 10, 8, 0, tzinfo=datetime.timezone.utc
+ ),
+ ATTR_TO_DATE: datetime.datetime(
+ 2020, 1, 20, 8, 0, tzinfo=datetime.timezone.utc
+ ),
+ ATTR_DURATION_IN_WEEK: 1,
+ ATTR_ALERT_LEVEL: "Alert Level 1",
+ ATTR_POPULATION: "Population 1",
+ ATTR_EVENT_TYPE: "Drought",
+ ATTR_SEVERITY: "Severity 1",
+ ATTR_VULNERABILITY: "Vulnerability 1",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: "gdacs",
+ ATTR_ICON: "mdi:water-off",
+ }
+ assert float(state.state) == 15.5
+
+ state = hass.states.get("geo_location.tropical_cyclone_name_2")
+ assert state is not None
+ assert state.name == "Tropical Cyclone: Name 2"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "2345",
+ ATTR_LATITUDE: 38.1,
+ ATTR_LONGITUDE: -3.1,
+ ATTR_FRIENDLY_NAME: "Tropical Cyclone: Name 2",
+ ATTR_DESCRIPTION: "Description 2",
+ ATTR_EVENT_TYPE: "Tropical Cyclone",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: "gdacs",
+ ATTR_ICON: "mdi:weather-hurricane",
+ }
+ assert float(state.state) == 20.5
+
+ state = hass.states.get("geo_location.tropical_cyclone_name_3")
+ assert state is not None
+ assert state.name == "Tropical Cyclone: Name 3"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "3456",
+ ATTR_LATITUDE: 38.2,
+ ATTR_LONGITUDE: -3.2,
+ ATTR_FRIENDLY_NAME: "Tropical Cyclone: Name 3",
+ ATTR_DESCRIPTION: "Description 3",
+ ATTR_EVENT_TYPE: "Tropical Cyclone",
+ ATTR_COUNTRY: "Country 2",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: "gdacs",
+ ATTR_ICON: "mdi:weather-hurricane",
+ }
+ assert float(state.state) == 25.5
+
+ # Simulate an update - two existing, one new entry, one outdated entry
+ mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_4, mock_entry_3]
+ async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 4
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed_update.return_value = "OK_NO_DATA", None
+ async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 4
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed_update.return_value = "ERROR", None
+ async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+
+
+async def test_setup_imperial(hass):
+ """Test the setup of the integration using imperial unit system."""
+ hass.config.units = IMPERIAL_SYSTEM
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ "1234",
+ "Description 1",
+ 15.5,
+ (38.0, -3.0),
+ event_name="Name 1",
+ event_type_short="DR",
+ event_type="Drought",
+ )
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch(
+ "aio_georss_client.feed.GeoRssFeed.update"
+ ) as mock_feed_update, patch(
+ "aio_georss_client.feed.GeoRssFeed.last_timestamp", create=True
+ ):
+ mock_feed_update.return_value = "OK", [mock_entry_1]
+ assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG)
+ # Artificially trigger update and collect events.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 2
+
+ # Test conversion of 200 miles to kilometers.
+ feeds = hass.data[DOMAIN][FEED]
+ assert feeds is not None
+ assert len(feeds) == 1
+ manager = list(feeds.values())[0]
+ # Ensure that the filter value in km is correctly set.
+ assert manager._feed_manager._feed._filter_radius == 321.8688
+
+ state = hass.states.get("geo_location.drought_name_1")
+ assert state is not None
+ assert state.name == "Drought: Name 1"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "1234",
+ ATTR_LATITUDE: 38.0,
+ ATTR_LONGITUDE: -3.0,
+ ATTR_FRIENDLY_NAME: "Drought: Name 1",
+ ATTR_DESCRIPTION: "Description 1",
+ ATTR_EVENT_TYPE: "Drought",
+ ATTR_UNIT_OF_MEASUREMENT: "mi",
+ ATTR_SOURCE: "gdacs",
+ ATTR_ICON: "mdi:water-off",
+ }
+ # 15.5km (as defined in mock entry) has been converted to 9.6mi.
+ assert float(state.state) == 9.6
diff --git a/tests/components/gdacs/test_init.py b/tests/components/gdacs/test_init.py
new file mode 100644
index 00000000000000..40bda2a196b982
--- /dev/null
+++ b/tests/components/gdacs/test_init.py
@@ -0,0 +1,19 @@
+"""Define tests for the GDACS general setup."""
+from asynctest import patch
+
+from homeassistant.components.gdacs import DOMAIN, FEED
+
+
+async def test_component_unload_config_entry(hass, config_entry):
+ """Test that loading and unloading of a config entry works."""
+ config_entry.add_to_hass(hass)
+ with patch("aio_georss_gdacs.GdacsFeedManager.update") as mock_feed_manager_update:
+ # Load config entry.
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert mock_feed_manager_update.call_count == 1
+ assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None
+ # Unload config entry.
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None
diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py
new file mode 100644
index 00000000000000..5e8fd5ad30f796
--- /dev/null
+++ b/tests/components/gdacs/test_sensor.py
@@ -0,0 +1,100 @@
+"""The tests for the GDACS Feed integration."""
+from asynctest import patch
+
+from homeassistant.components import gdacs
+from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL
+from homeassistant.components.gdacs.sensor import (
+ ATTR_CREATED,
+ ATTR_LAST_UPDATE,
+ ATTR_LAST_UPDATE_SUCCESSFUL,
+ ATTR_REMOVED,
+ ATTR_STATUS,
+ ATTR_UPDATED,
+)
+from homeassistant.const import (
+ ATTR_ICON,
+ ATTR_UNIT_OF_MEASUREMENT,
+ CONF_RADIUS,
+ EVENT_HOMEASSISTANT_START,
+)
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_fire_time_changed
+from tests.components.gdacs import _generate_mock_feed_entry
+
+CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}}
+
+
+async def test_setup(hass):
+ """Test the general setup of the integration."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ "1234", "Title 1", 15.5, (38.0, -3.0), attribution="Attribution 1",
+ )
+ mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (38.1, -3.1),)
+ mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (38.2, -3.2),)
+ mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3))
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch(
+ "aio_georss_client.feed.GeoRssFeed.update"
+ ) as mock_feed_update:
+ mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3]
+ assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG)
+ # Artificially trigger update and collect events.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ # 3 geolocation and 1 sensor entities
+ assert len(all_states) == 4
+
+ state = hass.states.get("sensor.gdacs_32_87336_117_22743")
+ assert state is not None
+ assert int(state.state) == 3
+ assert state.name == "GDACS (32.87336, -117.22743)"
+ attributes = state.attributes
+ assert attributes[ATTR_STATUS] == "OK"
+ assert attributes[ATTR_CREATED] == 3
+ assert attributes[ATTR_LAST_UPDATE].tzinfo == dt_util.UTC
+ assert attributes[ATTR_LAST_UPDATE_SUCCESSFUL].tzinfo == dt_util.UTC
+ assert attributes[ATTR_LAST_UPDATE] == attributes[ATTR_LAST_UPDATE_SUCCESSFUL]
+ assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "alerts"
+ assert attributes[ATTR_ICON] == "mdi:alert"
+
+ # Simulate an update - two existing, one new entry, one outdated entry
+ mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_4, mock_entry_3]
+ async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 4
+
+ state = hass.states.get("sensor.gdacs_32_87336_117_22743")
+ attributes = state.attributes
+ assert attributes[ATTR_CREATED] == 1
+ assert attributes[ATTR_UPDATED] == 2
+ assert attributes[ATTR_REMOVED] == 1
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed_update.return_value = "OK_NO_DATA", None
+ async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 4
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed_update.return_value = "ERROR", None
+ async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+
+ state = hass.states.get("sensor.gdacs_32_87336_117_22743")
+ attributes = state.attributes
+ assert attributes[ATTR_REMOVED] == 3
diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py
index 38c7200cce1ccf..8bfbed52a11764 100644
--- a/tests/components/geo_json_events/test_geo_location.py
+++ b/tests/components/geo_json_events/test_geo_location.py
@@ -5,8 +5,6 @@
from homeassistant.components.geo_json_events.geo_location import (
ATTR_EXTERNAL_ID,
SCAN_INTERVAL,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_UPDATE_ENTITY,
)
from homeassistant.components.geo_location import ATTR_SOURCE
from homeassistant.const import (
@@ -190,8 +188,8 @@ async def test_setup_race_condition(hass):
# Set up some mock feed entries for this test.
mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0))
- delete_signal = SIGNAL_DELETE_ENTITY.format("1234")
- update_signal = SIGNAL_UPDATE_ENTITY.format("1234")
+ delete_signal = f"geo_json_events_delete_1234"
+ update_signal = f"geo_json_events_update_1234"
# Patching 'utcnow' to gain more control over the timed update.
utcnow = dt_util.utcnow()
diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py
index 319a79966fdbe7..b988d613d6cd8a 100644
--- a/tests/components/geofency/test_init.py
+++ b/tests/components/geofency/test_init.py
@@ -163,7 +163,7 @@ async def webhook_id(hass, geofency_client):
async def test_data_validation(geofency_client, webhook_id):
"""Test data validation."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# No data
req = await geofency_client.post(url)
@@ -181,14 +181,14 @@ async def test_data_validation(geofency_client, webhook_id):
async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
"""Test GPS based zone enter and exit."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Home zone
req = await geofency_client.post(url, data=GPS_ENTER_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_ENTER_HOME["device"])
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
# Exit the Home zone
@@ -196,7 +196,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_EXIT_HOME["device"])
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
# Exit the Home zone with "Send Current Position" enabled
@@ -208,13 +208,13 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_EXIT_HOME["device"])
- current_latitude = hass.states.get(
- "{}.{}".format("device_tracker", device_name)
- ).attributes["latitude"]
+ current_latitude = hass.states.get(f"device_tracker.{device_name}").attributes[
+ "latitude"
+ ]
assert NOT_HOME_LATITUDE == current_latitude
- current_longitude = hass.states.get(
- "{}.{}".format("device_tracker", device_name)
- ).attributes["longitude"]
+ current_longitude = hass.states.get(f"device_tracker.{device_name}").attributes[
+ "longitude"
+ ]
assert NOT_HOME_LONGITUDE == current_longitude
dev_reg = await hass.helpers.device_registry.async_get_registry()
@@ -226,43 +226,43 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id):
"""Test iBeacon based zone enter and exit - a.k.a stationary iBeacon."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Home zone
req = await geofency_client.post(url, data=BEACON_ENTER_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_HOME['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
# Exit the Home zone
req = await geofency_client.post(url, data=BEACON_EXIT_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_HOME['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id):
"""Test use of mobile iBeacon."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Car away from Home zone
req = await geofency_client.post(url, data=BEACON_ENTER_CAR)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_CAR['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
# Exit the Car away from Home zone
req = await geofency_client.post(url, data=BEACON_EXIT_CAR)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_CAR['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
# Enter the Car in the Home zone
@@ -272,29 +272,29 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id):
req = await geofency_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(data["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{data['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
# Exit the Car in the Home zone
req = await geofency_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(data["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{data['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
async def test_load_unload_entry(hass, geofency_client, webhook_id):
"""Test that the appropriate dispatch signals are added and removed."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Home zone
req = await geofency_client.post(url, data=GPS_ENTER_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_ENTER_HOME["device"])
- state_1 = hass.states.get("{}.{}".format("device_tracker", device_name))
+ state_1 = hass.states.get(f"device_tracker.{device_name}")
assert STATE_HOME == state_1.state
assert len(hass.data[DOMAIN]["devices"]) == 1
@@ -307,7 +307,7 @@ async def test_load_unload_entry(hass, geofency_client, webhook_id):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
- state_2 = hass.states.get("{}.{}".format("device_tracker", device_name))
+ state_2 = hass.states.get(f"device_tracker.{device_name}")
assert state_2 is not None
assert state_1 is not state_2
diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py
index e5be52e6b33783..8734ca0e60d12b 100644
--- a/tests/components/glances/test_config_flow.py
+++ b/tests/components/glances/test_config_flow.py
@@ -3,8 +3,8 @@
from glances_api import Glances
-from homeassistant.components.glances import config_flow
-from homeassistant.components.glances.const import DOMAIN
+from homeassistant import data_entry_flow
+from homeassistant.components import glances
from homeassistant.const import CONF_SCAN_INTERVAL
from tests.common import MockConfigEntry, mock_coro
@@ -29,22 +29,22 @@
}
-def init_config_flow(hass):
- """Init a configuration flow."""
- flow = config_flow.GlancesFlowHandler()
- flow.hass = hass
- return flow
-
-
async def test_form(hass):
"""Test config entry configured successfully."""
- flow = init_config_flow(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ glances.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
with patch("glances_api.Glances"), patch.object(
Glances, "get_data", return_value=mock_coro()
):
- result = await flow.async_step_user(DEMO_USER_INPUT)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
assert result["type"] == "create_entry"
assert result["title"] == NAME
@@ -53,10 +53,14 @@ async def test_form(hass):
async def test_form_cannot_connect(hass):
"""Test to return error if we cannot connect."""
- flow = init_config_flow(hass)
with patch("glances_api.Glances"):
- result = await flow.async_step_user(DEMO_USER_INPUT)
+ result = await hass.config_entries.flow.async_init(
+ glances.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
assert result["type"] == "form"
assert result["errors"] == {"base": "cannot_connect"}
@@ -64,11 +68,15 @@ async def test_form_cannot_connect(hass):
async def test_form_wrong_version(hass):
"""Test to check if wrong version is entered."""
- flow = init_config_flow(hass)
user_input = DEMO_USER_INPUT.copy()
user_input.update(version=1)
- result = await flow.async_step_user(user_input)
+ result = await hass.config_entries.flow.async_init(
+ glances.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=user_input
+ )
assert result["type"] == "form"
assert result["errors"] == {"version": "wrong_version"}
@@ -77,13 +85,16 @@ async def test_form_wrong_version(hass):
async def test_form_already_configured(hass):
"""Test host is already configured."""
entry = MockConfigEntry(
- domain=DOMAIN, data=DEMO_USER_INPUT, options={CONF_SCAN_INTERVAL: 60}
+ domain=glances.DOMAIN, data=DEMO_USER_INPUT, options={CONF_SCAN_INTERVAL: 60}
)
entry.add_to_hass(hass)
- flow = init_config_flow(hass)
- result = await flow.async_step_user(DEMO_USER_INPUT)
-
+ result = await hass.config_entries.flow.async_init(
+ glances.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
@@ -91,12 +102,20 @@ async def test_form_already_configured(hass):
async def test_options(hass):
"""Test options for Glances."""
entry = MockConfigEntry(
- domain=DOMAIN, data=DEMO_USER_INPUT, options={CONF_SCAN_INTERVAL: 60}
+ domain=glances.DOMAIN, data=DEMO_USER_INPUT, options={CONF_SCAN_INTERVAL: 60}
)
entry.add_to_hass(hass)
- flow = init_config_flow(hass)
- options_flow = flow.async_get_options_flow(entry)
- result = await options_flow.async_step_init({CONF_SCAN_INTERVAL: 10})
- assert result["type"] == "create_entry"
- assert result["data"][CONF_SCAN_INTERVAL] == 10
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={glances.CONF_SCAN_INTERVAL: 10}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"] == {
+ glances.CONF_SCAN_INTERVAL: 10,
+ }
diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py
index 4aace6f5484e8d..ad7b6b12001e56 100644
--- a/tests/components/google/test_calendar.py
+++ b/tests/components/google/test_calendar.py
@@ -218,7 +218,7 @@ async def test_offset_in_progress_event(hass, mock_next_event):
event = copy.deepcopy(TEST_EVENT)
event["start"]["dateTime"] = start
event["end"]["dateTime"] = end
- event["summary"] = "{} !!-15".format(event_summary)
+ event["summary"] = f"{event_summary} !!-15"
mock_next_event.return_value.event = event
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
@@ -250,7 +250,7 @@ async def test_all_day_offset_in_progress_event(hass, mock_next_event):
event = copy.deepcopy(TEST_EVENT)
event["start"]["date"] = start
event["end"]["date"] = end
- event["summary"] = "{} !!-25:0".format(event_summary)
+ event["summary"] = f"{event_summary} !!-25:0"
mock_next_event.return_value.event = event
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
@@ -282,7 +282,7 @@ async def test_all_day_offset_event(hass, mock_next_event):
event = copy.deepcopy(TEST_EVENT)
event["start"]["date"] = start
event["end"]["date"] = end
- event["summary"] = "{} !!-{}:0".format(event_summary, offset_hours)
+ event["summary"] = f"{event_summary} !!-{offset_hours}:0"
mock_next_event.return_value.event = event
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py
index edb12f06f33889..c0b5aa7b1935ab 100644
--- a/tests/components/google_assistant/__init__.py
+++ b/tests/components/google_assistant/__init__.py
@@ -22,6 +22,7 @@ def __init__(
*,
secure_devices_pin=None,
should_expose=None,
+ should_2fa=None,
entity_config=None,
hass=None,
local_sdk_webhook_id=None,
@@ -103,7 +104,10 @@ def should_expose(self, state):
},
{
"id": "light.ceiling_lights",
- "name": {"name": "Roof Lights", "nicknames": ["top lights", "ceiling lights"]},
+ "name": {
+ "name": "Roof Lights",
+ "nicknames": ["Roof Lights", "top lights", "ceiling lights"],
+ },
"traits": [
"action.devices.traits.OnOff",
"action.devices.traits.Brightness",
diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py
index 3be97013e4de0d..f2f43b6dabdf25 100644
--- a/tests/components/google_assistant/test_google_assistant.py
+++ b/tests/components/google_assistant/test_google_assistant.py
@@ -31,7 +31,7 @@
@pytest.fixture
def auth_header(hass_access_token):
"""Generate an HTTP header with bearer token authorization."""
- return {AUTHORIZATION: "Bearer {}".format(hass_access_token)}
+ return {AUTHORIZATION: f"Bearer {hass_access_token}"}
@pytest.fixture
diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py
index 9c8a868e68da8a..8d2aaa63c48be1 100644
--- a/tests/components/google_assistant/test_helpers.py
+++ b/tests/components/google_assistant/test_helpers.py
@@ -7,6 +7,7 @@
from homeassistant.components.google_assistant import helpers
from homeassistant.components.google_assistant.const import ( # noqa: F401
EVENT_COMMAND_RECEIVED,
+ NOT_EXPOSE_LOCAL,
)
from homeassistant.setup import async_setup_component
from homeassistant.util import dt
@@ -46,6 +47,15 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass):
"webhookId": "mock-webhook-id",
}
+ for device_type in NOT_EXPOSE_LOCAL:
+ with patch(
+ "homeassistant.components.google_assistant.helpers.get_google_type",
+ return_value=device_type,
+ ):
+ serialized = await entity.sync_serialize(None)
+ assert "otherDeviceIds" not in serialized
+ assert "customData" not in serialized
+
async def test_config_local_sdk(hass, hass_client):
"""Test the local SDK."""
diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py
index 112935f0160a63..ff159e4e10c04a 100644
--- a/tests/components/google_assistant/test_http.py
+++ b/tests/components/google_assistant/test_http.py
@@ -27,7 +27,7 @@
MOCK_JSON = {"devices": {}}
MOCK_URL = "https://dummy"
MOCK_HEADER = {
- "Authorization": "Bearer {}".format(MOCK_TOKEN["access_token"]),
+ "Authorization": f"Bearer {MOCK_TOKEN['access_token']}",
"X-GFE-SSL": "yes",
}
@@ -57,7 +57,7 @@ async def test_get_access_token(hass, aioclient_mock):
await _get_homegraph_token(hass, jwt)
assert aioclient_mock.call_count == 1
assert aioclient_mock.mock_calls[0][3] == {
- "Authorization": "Bearer {}".format(jwt),
+ "Authorization": f"Bearer {jwt}",
"Content-Type": "application/x-www-form-urlencoded",
}
@@ -145,38 +145,6 @@ async def test_call_homegraph_api_retry(hass, aioclient_mock, hass_storage):
assert call[3] == MOCK_HEADER
-async def test_call_homegraph_api_key(hass, aioclient_mock, hass_storage):
- """Test the function to call the homegraph api."""
- config = GoogleConfig(
- hass, GOOGLE_ASSISTANT_SCHEMA({"project_id": "1234", "api_key": "dummy_key"}),
- )
- await config.async_initialize()
-
- aioclient_mock.post(MOCK_URL, status=200, json={})
-
- res = await config.async_call_homegraph_api_key(MOCK_URL, MOCK_JSON)
- assert res == 200
- assert aioclient_mock.call_count == 1
-
- call = aioclient_mock.mock_calls[0]
- assert call[1].query == {"key": "dummy_key"}
- assert call[2] == MOCK_JSON
-
-
-async def test_call_homegraph_api_key_fail(hass, aioclient_mock, hass_storage):
- """Test the function to call the homegraph api."""
- config = GoogleConfig(
- hass, GOOGLE_ASSISTANT_SCHEMA({"project_id": "1234", "api_key": "dummy_key"}),
- )
- await config.async_initialize()
-
- aioclient_mock.post(MOCK_URL, status=666, json={})
-
- res = await config.async_call_homegraph_api_key(MOCK_URL, MOCK_JSON)
- assert res == 666
- assert aioclient_mock.call_count == 1
-
-
async def test_report_state(hass, aioclient_mock, hass_storage):
"""Test the report state function."""
agent_user_id = "user"
diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py
index 2773f3c3329677..0df2b032b5a21f 100644
--- a/tests/components/google_assistant/test_init.py
+++ b/tests/components/google_assistant/test_init.py
@@ -3,17 +3,21 @@
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
-GA_API_KEY = "Agdgjsj399sdfkosd932ksd"
+from .test_http import DUMMY_CONFIG
async def test_request_sync_service(aioclient_mock, hass):
"""Test that it posts to the request_sync url."""
+ aioclient_mock.post(
+ ga.const.HOMEGRAPH_TOKEN_URL,
+ status=200,
+ json={"access_token": "1234", "expires_in": 3600},
+ )
+
aioclient_mock.post(ga.const.REQUEST_SYNC_BASE_URL, status=200)
await async_setup_component(
- hass,
- "google_assistant",
- {"google_assistant": {"project_id": "test_project", "api_key": GA_API_KEY}},
+ hass, "google_assistant", {"google_assistant": DUMMY_CONFIG},
)
assert aioclient_mock.call_count == 0
@@ -24,4 +28,4 @@ async def test_request_sync_service(aioclient_mock, hass):
context=Context(user_id="123"),
)
- assert aioclient_mock.call_count == 1
+ assert aioclient_mock.call_count == 2 # token + request
diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py
index 7ffe9cda477da4..7e98f162f2213e 100644
--- a/tests/components/google_assistant/test_smart_home.py
+++ b/tests/components/google_assistant/test_smart_home.py
@@ -82,6 +82,7 @@ async def test_sync_message(hass):
config,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -91,7 +92,10 @@ async def test_sync_message(hass):
"devices": [
{
"id": "light.demo_light",
- "name": {"name": "Demo Light", "nicknames": ["Hello", "World"]},
+ "name": {
+ "name": "Demo Light",
+ "nicknames": ["Demo Light", "Hello", "World"],
+ },
"traits": [
trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF,
@@ -115,7 +119,7 @@ async def test_sync_message(hass):
assert len(events) == 1
assert events[0].event_type == EVENT_SYNC_RECEIVED
- assert events[0].data == {"request_id": REQ_ID}
+ assert events[0].data == {"request_id": REQ_ID, "source": "cloud"}
# pylint: disable=redefined-outer-name
@@ -148,6 +152,7 @@ async def test_sync_in_area(hass, registries):
config,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -181,7 +186,7 @@ async def test_sync_in_area(hass, registries):
assert len(events) == 1
assert events[0].event_type == EVENT_SYNC_RECEIVED
- assert events[0].data == {"request_id": REQ_ID}
+ assert events[0].data == {"request_id": REQ_ID, "source": "cloud"}
async def test_query_message(hass):
@@ -220,6 +225,7 @@ async def test_query_message(hass):
}
],
},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -247,11 +253,23 @@ async def test_query_message(hass):
assert len(events) == 3
assert events[0].event_type == EVENT_QUERY_RECEIVED
- assert events[0].data == {"request_id": REQ_ID, "entity_id": "light.demo_light"}
+ assert events[0].data == {
+ "request_id": REQ_ID,
+ "entity_id": "light.demo_light",
+ "source": "cloud",
+ }
assert events[1].event_type == EVENT_QUERY_RECEIVED
- assert events[1].data == {"request_id": REQ_ID, "entity_id": "light.another_light"}
+ assert events[1].data == {
+ "request_id": REQ_ID,
+ "entity_id": "light.another_light",
+ "source": "cloud",
+ }
assert events[2].event_type == EVENT_QUERY_RECEIVED
- assert events[2].data == {"request_id": REQ_ID, "entity_id": "light.non_existing"}
+ assert events[2].data == {
+ "request_id": REQ_ID,
+ "entity_id": "light.non_existing",
+ "source": "cloud",
+ }
async def test_execute(hass):
@@ -300,6 +318,7 @@ async def test_execute(hass):
}
],
},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -341,6 +360,7 @@ async def test_execute(hass):
"command": "action.devices.commands.OnOff",
"params": {"on": True},
},
+ "source": "cloud",
}
assert events[1].event_type == EVENT_COMMAND_RECEIVED
assert events[1].data == {
@@ -350,6 +370,7 @@ async def test_execute(hass):
"command": "action.devices.commands.BrightnessAbsolute",
"params": {"brightness": 20},
},
+ "source": "cloud",
}
assert events[2].event_type == EVENT_COMMAND_RECEIVED
assert events[2].data == {
@@ -359,6 +380,7 @@ async def test_execute(hass):
"command": "action.devices.commands.OnOff",
"params": {"on": True},
},
+ "source": "cloud",
}
assert events[3].event_type == EVENT_COMMAND_RECEIVED
assert events[3].data == {
@@ -368,6 +390,7 @@ async def test_execute(hass):
"command": "action.devices.commands.BrightnessAbsolute",
"params": {"brightness": 20},
},
+ "source": "cloud",
}
assert len(service_events) == 2
@@ -424,6 +447,7 @@ async def test_raising_error_trait(hass):
}
],
},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -448,6 +472,7 @@ async def test_raising_error_trait(hass):
"command": "action.devices.commands.ThermostatTemperatureSetpoint",
"params": {"thermostatTemperatureSetpoint": 10},
},
+ "source": "cloud",
}
@@ -483,6 +508,7 @@ async def test_unavailable_state_does_sync(hass):
BASIC_CONFIG,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -515,7 +541,7 @@ async def test_unavailable_state_does_sync(hass):
assert len(events) == 1
assert events[0].event_type == EVENT_SYNC_RECEIVED
- assert events[0].data == {"request_id": REQ_ID}
+ assert events[0].data == {"request_id": REQ_ID, "source": "cloud"}
@pytest.mark.parametrize(
@@ -545,6 +571,7 @@ async def test_device_class_switch(hass, device_class, google_type):
BASIC_CONFIG,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -589,6 +616,7 @@ async def test_device_class_binary_sensor(hass, device_class, google_type):
BASIC_CONFIG,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -629,6 +657,7 @@ async def test_device_class_cover(hass, device_class, google_type):
BASIC_CONFIG,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -653,7 +682,6 @@ async def test_device_class_cover(hass, device_class, google_type):
"device_class,google_type",
[
("non_existing_class", "action.devices.types.SWITCH"),
- ("speaker", "action.devices.types.SPEAKER"),
("tv", "action.devices.types.TV"),
],
)
@@ -669,6 +697,7 @@ async def test_device_media_player(hass, device_class, google_type):
BASIC_CONFIG,
"test-agent",
{"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -702,6 +731,7 @@ async def test_query_disconnect(hass):
config,
"test-agent",
{"inputs": [{"intent": "action.devices.DISCONNECT"}], "requestId": REQ_ID},
+ const.SOURCE_CLOUD,
)
assert result is None
assert len(mock_disconnect.mock_calls) == 1
@@ -751,6 +781,7 @@ async def test_trait_execute_adding_query_data(hass):
}
],
},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -817,6 +848,7 @@ async def test_identify(hass):
}
],
},
+ const.SOURCE_CLOUD,
)
assert result == {
@@ -851,8 +883,11 @@ async def test_reachable_devices(hass):
# Not passed in as google_id
hass.states.async_set("light.not_mentioned", "on")
+ # Has 2FA
+ hass.states.async_set("lock.has_2fa", "on")
+
config = MockConfig(
- should_expose=lambda state: state.entity_id != "light.not_expose"
+ should_expose=lambda state: state.entity_id != "light.not_expose",
)
user_agent_id = "mock-user-id"
@@ -898,9 +933,19 @@ async def test_reachable_devices(hass):
"webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219",
},
},
+ {
+ "id": "lock.has_2fa",
+ "customData": {
+ "httpPort": 8123,
+ "httpSSL": False,
+ "proxyDeviceId": proxy_device_id,
+ "webhookId": "dde3b9800a905e886cc4d38e226a6e7e3f2a6993d2b9b9f63d13e42ee7de3219",
+ },
+ },
{"id": proxy_device_id, "customData": {}},
],
},
+ const.SOURCE_CLOUD,
)
assert result == {
diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py
index f59d4006d297f5..232da039ea76ab 100644
--- a/tests/components/google_assistant/test_trait.py
+++ b/tests/components/google_assistant/test_trait.py
@@ -51,11 +51,15 @@
REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
-BASIC_DATA = helpers.RequestData(BASIC_CONFIG, "test-agent", REQ_ID, None)
+BASIC_DATA = helpers.RequestData(
+ BASIC_CONFIG, "test-agent", const.SOURCE_CLOUD, REQ_ID, None
+)
PIN_CONFIG = MockConfig(secure_devices_pin="1234")
-PIN_DATA = helpers.RequestData(PIN_CONFIG, "test-agent", REQ_ID, None)
+PIN_DATA = helpers.RequestData(
+ PIN_CONFIG, "test-agent", const.SOURCE_CLOUD, REQ_ID, None
+)
async def test_brightness_light(hass):
diff --git a/tests/components/google_domains/test_init.py b/tests/components/google_domains/test_init.py
index 66e334d342fc84..1ebc5cfda80d1b 100644
--- a/tests/components/google_domains/test_init.py
+++ b/tests/components/google_domains/test_init.py
@@ -13,7 +13,7 @@
USERNAME = "abc123"
PASSWORD = "xyz789"
-UPDATE_URL = google_domains.UPDATE_URL.format(USERNAME, PASSWORD)
+UPDATE_URL = f"https://{USERNAME}:{PASSWORD}@domains.google.com/nic/update"
@pytest.fixture
diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py
index 15e84b384c08c8..37609e151bd124 100644
--- a/tests/components/google_translate/test_tts.py
+++ b/tests/components/google_translate/test_tts.py
@@ -65,7 +65,10 @@ def test_service_say(self, mock_calculate, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"google_translate_say",
- {tts.ATTR_MESSAGE: "90% of I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -89,7 +92,10 @@ def test_service_say_german_config(self, mock_calculate, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"google_translate_say",
- {tts.ATTR_MESSAGE: "90% of I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -115,6 +121,7 @@ def test_service_say_german_service(self, mock_calculate, aioclient_mock):
tts.DOMAIN,
"google_say",
{
+ "entity_id": "media_player.something",
tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
tts.ATTR_LANGUAGE: "de",
},
@@ -139,7 +146,10 @@ def test_service_say_error(self, mock_calculate, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"google_translate_say",
- {tts.ATTR_MESSAGE: "90% of I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -161,7 +171,10 @@ def test_service_say_timeout(self, mock_calculate, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"google_translate_say",
- {tts.ATTR_MESSAGE: "90% of I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -193,6 +206,7 @@ def test_service_say_long_size(self, mock_calculate, aioclient_mock):
tts.DOMAIN,
"google_say",
{
+ "entity_id": "media_player.something",
tts.ATTR_MESSAGE: (
"I person is on front of your door."
"I person is on front of your door."
@@ -203,7 +217,7 @@ def test_service_say_long_size(self, mock_calculate, aioclient_mock):
"I person is on front of your door."
"I person is on front of your door."
"I person is on front of your door."
- )
+ ),
},
)
self.hass.block_till_done()
diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py
index 8a529f93f721c2..bddee724966a2c 100644
--- a/tests/components/google_wifi/test_sensor.py
+++ b/tests/components/google_wifi/test_sensor.py
@@ -48,9 +48,7 @@ def tearDown(self):
@requests_mock.Mocker()
def test_setup_minimum(self, mock_req):
"""Test setup with minimum configuration."""
- resource = "{}{}{}".format(
- "http://", google_wifi.DEFAULT_HOST, google_wifi.ENDPOINT
- )
+ resource = f"http://{google_wifi.DEFAULT_HOST}{google_wifi.ENDPOINT}"
mock_req.get(resource, status_code=200)
assert setup_component(
self.hass,
@@ -62,7 +60,7 @@ def test_setup_minimum(self, mock_req):
@requests_mock.Mocker()
def test_setup_get(self, mock_req):
"""Test setup with full configuration."""
- resource = "{}{}{}".format("http://", "localhost", google_wifi.ENDPOINT)
+ resource = f"http://localhost{google_wifi.ENDPOINT}"
mock_req.get(resource, status_code=200)
assert setup_component(
self.hass,
@@ -101,7 +99,7 @@ def tearDown(self):
def setup_api(self, data, mock_req):
"""Set up API with fake data."""
- resource = "{}{}{}".format("http://", "localhost", google_wifi.ENDPOINT)
+ resource = f"http://localhost{google_wifi.ENDPOINT}"
now = datetime(1970, month=1, day=1)
with patch("homeassistant.util.dt.now", return_value=now):
mock_req.get(resource, text=data, status_code=200)
@@ -111,7 +109,7 @@ def setup_api(self, data, mock_req):
self.sensor_dict = dict()
for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items():
sensor = google_wifi.GoogleWifiSensor(self.api, self.name, condition)
- name = "{}_{}".format(self.name, condition)
+ name = f"{self.name}_{condition}"
units = cond_list[1]
icon = cond_list[2]
self.sensor_dict[condition] = {
diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py
index f81ef45a6481dc..9135f583d19d4c 100644
--- a/tests/components/gpslogger/test_init.py
+++ b/tests/components/gpslogger/test_init.py
@@ -77,7 +77,7 @@ async def webhook_id(hass, gpslogger_client):
async def test_missing_data(hass, gpslogger_client, webhook_id):
"""Test missing data."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {"latitude": 1.0, "longitude": 1.1, "device": "123"}
@@ -103,7 +103,7 @@ async def test_missing_data(hass, gpslogger_client, webhook_id):
async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
"""Test when there is a known zone."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE, "device": "123"}
@@ -111,18 +111,14 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_HOME == state_name
# Enter Home again
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_HOME == state_name
data["longitude"] = 0
@@ -132,9 +128,7 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_NOT_HOME == state_name
dev_reg = await hass.helpers.device_registry.async_get_registry()
@@ -146,7 +140,7 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
"""Test when additional attributes are present."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {
"latitude": 1.0,
@@ -164,7 +158,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]))
+ state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}")
assert state.state == STATE_NOT_HOME
assert state.attributes["gps_accuracy"] == 10.5
assert state.attributes["battery_level"] == 10.0
@@ -190,7 +184,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]))
+ state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}")
assert state.state == STATE_HOME
assert state.attributes["gps_accuracy"] == 123
assert state.attributes["battery_level"] == 23
@@ -206,16 +200,14 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
)
async def test_load_unload_entry(hass, gpslogger_client, webhook_id):
"""Test that the appropriate dispatch signals are added and removed."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE, "device": "123"}
# Enter the Home
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_HOME == state_name
assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1
diff --git a/tests/components/group/common.py b/tests/components/group/common.py
index 9d86b41d77ad78..69de1cfee7534d 100644
--- a/tests/components/group/common.py
+++ b/tests/components/group/common.py
@@ -5,17 +5,13 @@
"""
from homeassistant.components.group import (
ATTR_ADD_ENTITIES,
- ATTR_CONTROL,
ATTR_ENTITIES,
ATTR_OBJECT_ID,
- ATTR_VIEW,
- ATTR_VISIBLE,
DOMAIN,
SERVICE_REMOVE,
SERVICE_SET,
- SERVICE_SET_VISIBILITY,
)
-from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, ATTR_NAME, SERVICE_RELOAD
+from homeassistant.const import ATTR_ICON, ATTR_NAME, SERVICE_RELOAD
from homeassistant.core import callback
from homeassistant.loader import bind_hass
@@ -35,43 +31,18 @@ def async_reload(hass):
@bind_hass
def set_group(
- hass,
- object_id,
- name=None,
- entity_ids=None,
- visible=None,
- icon=None,
- view=None,
- control=None,
- add=None,
+ hass, object_id, name=None, entity_ids=None, icon=None, add=None,
):
"""Create/Update a group."""
hass.add_job(
- async_set_group,
- hass,
- object_id,
- name,
- entity_ids,
- visible,
- icon,
- view,
- control,
- add,
+ async_set_group, hass, object_id, name, entity_ids, icon, add,
)
@callback
@bind_hass
def async_set_group(
- hass,
- object_id,
- name=None,
- entity_ids=None,
- visible=None,
- icon=None,
- view=None,
- control=None,
- add=None,
+ hass, object_id, name=None, entity_ids=None, icon=None, add=None,
):
"""Create/Update a group."""
data = {
@@ -80,10 +51,7 @@ def async_set_group(
(ATTR_OBJECT_ID, object_id),
(ATTR_NAME, name),
(ATTR_ENTITIES, entity_ids),
- (ATTR_VISIBLE, visible),
(ATTR_ICON, icon),
- (ATTR_VIEW, view),
- (ATTR_CONTROL, control),
(ATTR_ADD_ENTITIES, add),
]
if value is not None
@@ -98,10 +66,3 @@ def async_remove(hass, object_id):
"""Remove a user group."""
data = {ATTR_OBJECT_ID: object_id}
hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_REMOVE, data))
-
-
-@bind_hass
-def set_visibility(hass, entity_id=None, visible=True):
- """Hide or shows a group."""
- data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible}
- hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data)
diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py
index febe261c9e4f70..e8878b7cf4aee0 100644
--- a/tests/components/group/test_init.py
+++ b/tests/components/group/test_init.py
@@ -8,7 +8,6 @@
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_FRIENDLY_NAME,
- ATTR_HIDDEN,
ATTR_ICON,
STATE_HOME,
STATE_NOT_HOME,
@@ -44,10 +43,7 @@ def test_setup_group_with_mixed_groupable_states(self):
)
assert (
- STATE_ON
- == self.hass.states.get(
- group.ENTITY_ID_FORMAT.format("person_and_light")
- ).state
+ STATE_ON == self.hass.states.get(f"{group.DOMAIN}.person_and_light").state
)
def test_setup_group_with_a_non_existing_state(self):
@@ -291,38 +287,28 @@ def test_setup(self):
group_conf["second_group"] = {
"entities": "light.Bowl, " + test_group.entity_id,
"icon": "mdi:work",
- "view": True,
- "control": "hidden",
}
group_conf["test_group"] = "hello.world,sensor.happy"
group_conf["empty_group"] = {"name": "Empty Group", "entities": None}
setup_component(self.hass, "group", {"group": group_conf})
- group_state = self.hass.states.get(
- group.ENTITY_ID_FORMAT.format("second_group")
- )
+ group_state = self.hass.states.get(f"{group.DOMAIN}.second_group")
assert STATE_ON == group_state.state
assert set((test_group.entity_id, "light.bowl")) == set(
group_state.attributes["entity_id"]
)
assert group_state.attributes.get(group.ATTR_AUTO) is None
assert "mdi:work" == group_state.attributes.get(ATTR_ICON)
- assert group_state.attributes.get(group.ATTR_VIEW)
- assert "hidden" == group_state.attributes.get(group.ATTR_CONTROL)
- assert group_state.attributes.get(ATTR_HIDDEN)
assert 1 == group_state.attributes.get(group.ATTR_ORDER)
- group_state = self.hass.states.get(group.ENTITY_ID_FORMAT.format("test_group"))
+ group_state = self.hass.states.get(f"{group.DOMAIN}.test_group")
assert STATE_UNKNOWN == group_state.state
assert set(("sensor.happy", "hello.world")) == set(
group_state.attributes["entity_id"]
)
assert group_state.attributes.get(group.ATTR_AUTO) is None
assert group_state.attributes.get(ATTR_ICON) is None
- assert group_state.attributes.get(group.ATTR_VIEW) is None
- assert group_state.attributes.get(group.ATTR_CONTROL) is None
- assert group_state.attributes.get(ATTR_HIDDEN) is None
assert 2 == group_state.attributes.get(group.ATTR_ORDER)
def test_groups_get_unique_names(self):
@@ -382,10 +368,7 @@ def test_group_updated_after_device_tracker_zone_change(self):
)
self.hass.states.set("device_tracker.Adam", "cool_state_not_home")
self.hass.block_till_done()
- assert (
- STATE_NOT_HOME
- == self.hass.states.get(group.ENTITY_ID_FORMAT.format("peeps")).state
- )
+ assert STATE_NOT_HOME == self.hass.states.get(f"{group.DOMAIN}.peeps").state
def test_reloading_groups(self):
"""Test reloading the group config."""
@@ -394,11 +377,7 @@ def test_reloading_groups(self):
"group",
{
"group": {
- "second_group": {
- "entities": "light.Bowl",
- "icon": "mdi:work",
- "view": True,
- },
+ "second_group": {"entities": "light.Bowl", "icon": "mdi:work"},
"test_group": "hello.world,sensor.happy",
"empty_group": {"name": "Empty Group", "entities": None},
}
@@ -420,13 +399,7 @@ def test_reloading_groups(self):
with patch(
"homeassistant.config.load_yaml_config_file",
return_value={
- "group": {
- "hello": {
- "entities": "light.Bowl",
- "icon": "mdi:work",
- "view": True,
- }
- }
+ "group": {"hello": {"entities": "light.Bowl", "icon": "mdi:work"}}
},
):
common.reload(self.hass)
@@ -438,26 +411,6 @@ def test_reloading_groups(self):
]
assert self.hass.bus.listeners["state_changed"] == 2
- def test_changing_group_visibility(self):
- """Test that a group can be hidden and shown."""
- assert setup_component(
- self.hass, "group", {"group": {"test_group": "hello.world,sensor.happy"}}
- )
-
- group_entity_id = group.ENTITY_ID_FORMAT.format("test_group")
-
- # Hide the group
- common.set_visibility(self.hass, group_entity_id, False)
- self.hass.block_till_done()
- group_state = self.hass.states.get(group_entity_id)
- assert group_state.attributes.get(ATTR_HIDDEN)
-
- # Show it again
- common.set_visibility(self.hass, group_entity_id, True)
- self.hass.block_till_done()
- group_state = self.hass.states.get(group_entity_id)
- assert group_state.attributes.get(ATTR_HIDDEN) is None
-
def test_modify_group(self):
"""Test modifying a group."""
group_conf = OrderedDict()
@@ -470,9 +423,7 @@ def test_modify_group(self):
common.set_group(self.hass, "modify_group", icon="mdi:play")
self.hass.block_till_done()
- group_state = self.hass.states.get(
- group.ENTITY_ID_FORMAT.format("modify_group")
- )
+ group_state = self.hass.states.get(f"{group.DOMAIN}.modify_group")
assert self.hass.states.entity_ids() == ["group.modify_group"]
assert group_state.attributes.get(ATTR_ICON) == "mdi:play"
@@ -502,20 +453,12 @@ async def test_service_group_set_group_remove_group(hass):
assert group_state.attributes[group.ATTR_AUTO]
assert group_state.attributes["friendly_name"] == "Test"
- common.async_set_group(
- hass,
- "user_test_group",
- view=True,
- visible=False,
- entity_ids=["test.entity_bla1"],
- )
+ common.async_set_group(hass, "user_test_group", entity_ids=["test.entity_bla1"])
await hass.async_block_till_done()
group_state = hass.states.get("group.user_test_group")
assert group_state
- assert group_state.attributes[group.ATTR_VIEW]
assert group_state.attributes[group.ATTR_AUTO]
- assert group_state.attributes["hidden"]
assert group_state.attributes["friendly_name"] == "Test"
assert list(group_state.attributes["entity_id"]) == ["test.entity_bla1"]
@@ -524,19 +467,15 @@ async def test_service_group_set_group_remove_group(hass):
"user_test_group",
icon="mdi:camera",
name="Test2",
- control="hidden",
add=["test.entity_id2"],
)
await hass.async_block_till_done()
group_state = hass.states.get("group.user_test_group")
assert group_state
- assert group_state.attributes[group.ATTR_VIEW]
assert group_state.attributes[group.ATTR_AUTO]
- assert group_state.attributes["hidden"]
assert group_state.attributes["friendly_name"] == "Test2"
assert group_state.attributes["icon"] == "mdi:camera"
- assert group_state.attributes[group.ATTR_CONTROL] == "hidden"
assert sorted(list(group_state.attributes["entity_id"])) == sorted(
["test.entity_bla1", "test.entity_id2"]
)
diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py
index 1e227f943edeca..2751062dedf63a 100644
--- a/tests/components/hassio/test_init.py
+++ b/tests/components/hassio/test_init.py
@@ -52,7 +52,7 @@ async def test_setup_api_panel(hass, aioclient_mock):
assert panels.get("hassio").to_response() == {
"component_name": "custom",
"icon": "hass:home-assistant",
- "title": "Hass.io",
+ "title": "Supervisor",
"url_path": "hassio",
"require_admin": True,
"config": {
diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py
index 354751be0d2c35..31aced7f807f79 100644
--- a/tests/components/heos/test_media_player.py
+++ b/tests/components/heos/test_media_player.py
@@ -506,7 +506,7 @@ async def test_select_radio_favorite(hass, config_entry, config, controller, fav
async def test_select_radio_favorite_command_error(
hass, config_entry, config, controller, favorites, caplog
):
- """Tests command error loged when playing favorite."""
+ """Tests command error logged when playing favorite."""
await setup_platform(hass, config_entry, config)
player = controller.players[1]
# Test set radio preset
diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py
index 4456b256f6ee75..fcae8bd1f8cec8 100644
--- a/tests/components/here_travel_time/test_sensor.py
+++ b/tests/components/here_travel_time/test_sensor.py
@@ -28,6 +28,7 @@
ROUTE_MODE_FASTEST,
ROUTE_MODE_SHORTEST,
SCAN_INTERVAL,
+ TIME_MINUTES,
TRAFFIC_MODE_DISABLED,
TRAFFIC_MODE_ENABLED,
TRAVEL_MODE_BICYCLE,
@@ -36,7 +37,6 @@
TRAVEL_MODE_PUBLIC,
TRAVEL_MODE_PUBLIC_TIME_TABLE,
TRAVEL_MODE_TRUCK,
- UNIT_OF_MEASUREMENT,
)
from homeassistant.const import ATTR_ICON, EVENT_HOMEASSISTANT_START
from homeassistant.setup import async_setup_component
@@ -83,7 +83,7 @@ def _build_mock_url(origin, destination, modes, api_key, departure):
def _assert_truck_sensor(sensor):
"""Assert that states and attributes are correct for truck_response."""
assert sensor.state == "14"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 13.533333333333333
@@ -177,7 +177,7 @@ async def test_car(hass, requests_mock_car_disabled_response):
sensor = hass.states.get("sensor.test")
assert sensor.state == "30"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 30.05
assert sensor.attributes.get(ATTR_DISTANCE) == 23.903
@@ -381,7 +381,7 @@ async def test_public_transport(hass, requests_mock_credentials_check):
sensor = hass.states.get("sensor.test")
assert sensor.state == "89"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 89.16666666666667
@@ -431,7 +431,7 @@ async def test_public_transport_time_table(hass, requests_mock_credentials_check
sensor = hass.states.get("sensor.test")
assert sensor.state == "80"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 79.73333333333333
@@ -481,7 +481,7 @@ async def test_pedestrian(hass, requests_mock_credentials_check):
sensor = hass.states.get("sensor.test")
assert sensor.state == "211"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 210.51666666666668
@@ -532,7 +532,7 @@ async def test_bicycle(hass, requests_mock_credentials_check):
sensor = hass.states.get("sensor.test")
assert sensor.state == "55"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 54.86666666666667
diff --git a/tests/components/history_graph/__init__.py b/tests/components/history_graph/__init__.py
deleted file mode 100644
index 2cb34499938e03..00000000000000
--- a/tests/components/history_graph/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for the history_graph component."""
diff --git a/tests/components/history_graph/test_init.py b/tests/components/history_graph/test_init.py
deleted file mode 100644
index ef41f70aaa7b5e..00000000000000
--- a/tests/components/history_graph/test_init.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""The tests the Graph component."""
-
-import unittest
-
-from homeassistant.setup import setup_component
-
-from tests.common import get_test_home_assistant, init_recorder_component
-
-
-class TestGraph(unittest.TestCase):
- """Test the Google component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_component(self):
- """Test setup component."""
- self.init_recorder()
- config = {"history": {}, "history_graph": {"name_1": {"entities": "test.test"}}}
-
- assert setup_component(self.hass, "history_graph", config)
- assert dict(self.hass.states.get("history_graph.name_1").attributes) == {
- "entity_id": ["test.test"],
- "friendly_name": "name_1",
- "hours_to_show": 24,
- "refresh": 0,
- }
-
- def init_recorder(self):
- """Initialize the recorder."""
- init_recorder_component(self.hass)
- self.hass.start()
diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py
index 6c2b7f78e2491f..38a76b7c3fbb1d 100644
--- a/tests/components/homeassistant/test_init.py
+++ b/tests/components/homeassistant/test_init.py
@@ -4,6 +4,8 @@
import unittest
from unittest.mock import Mock, patch
+import pytest
+import voluptuous as vol
import yaml
from homeassistant import config
@@ -11,9 +13,12 @@
from homeassistant.components.homeassistant import (
SERVICE_CHECK_CONFIG,
SERVICE_RELOAD_CORE_CONFIG,
+ SERVICE_SET_LOCATION,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
+ ENTITY_MATCH_ALL,
+ ENTITY_MATCH_NONE,
EVENT_CORE_CONFIG_UPDATE,
SERVICE_HOMEASSISTANT_RESTART,
SERVICE_HOMEASSISTANT_STOP,
@@ -24,9 +29,8 @@
STATE_ON,
)
import homeassistant.core as ha
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import entity
-import homeassistant.helpers.intent as intent
from homeassistant.setup import async_setup_component
from tests.common import (
@@ -249,95 +253,6 @@ def test_check_config(self, mock_check, mock_stop):
assert not mock_stop.called
-async def test_turn_on_intent(hass):
- """Test HassTurnOn intent."""
- result = await async_setup_component(hass, "homeassistant", {})
- assert result
-
- hass.states.async_set("light.test_light", "off")
- calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
-
- response = await intent.async_handle(
- hass, "test", "HassTurnOn", {"name": {"value": "test light"}}
- )
- await hass.async_block_till_done()
-
- assert response.speech["plain"]["speech"] == "Turned test light on"
- assert len(calls) == 1
- call = calls[0]
- assert call.domain == "light"
- assert call.service == "turn_on"
- assert call.data == {"entity_id": ["light.test_light"]}
-
-
-async def test_turn_off_intent(hass):
- """Test HassTurnOff intent."""
- result = await async_setup_component(hass, "homeassistant", {})
- assert result
-
- hass.states.async_set("light.test_light", "on")
- calls = async_mock_service(hass, "light", SERVICE_TURN_OFF)
-
- response = await intent.async_handle(
- hass, "test", "HassTurnOff", {"name": {"value": "test light"}}
- )
- await hass.async_block_till_done()
-
- assert response.speech["plain"]["speech"] == "Turned test light off"
- assert len(calls) == 1
- call = calls[0]
- assert call.domain == "light"
- assert call.service == "turn_off"
- assert call.data == {"entity_id": ["light.test_light"]}
-
-
-async def test_toggle_intent(hass):
- """Test HassToggle intent."""
- result = await async_setup_component(hass, "homeassistant", {})
- assert result
-
- hass.states.async_set("light.test_light", "off")
- calls = async_mock_service(hass, "light", SERVICE_TOGGLE)
-
- response = await intent.async_handle(
- hass, "test", "HassToggle", {"name": {"value": "test light"}}
- )
- await hass.async_block_till_done()
-
- assert response.speech["plain"]["speech"] == "Toggled test light"
- assert len(calls) == 1
- call = calls[0]
- assert call.domain == "light"
- assert call.service == "toggle"
- assert call.data == {"entity_id": ["light.test_light"]}
-
-
-async def test_turn_on_multiple_intent(hass):
- """Test HassTurnOn intent with multiple similar entities.
-
- This tests that matching finds the proper entity among similar names.
- """
- result = await async_setup_component(hass, "homeassistant", {})
- assert result
-
- hass.states.async_set("light.test_light", "off")
- hass.states.async_set("light.test_lights_2", "off")
- hass.states.async_set("light.test_lighter", "off")
- calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
-
- response = await intent.async_handle(
- hass, "test", "HassTurnOn", {"name": {"value": "test lights"}}
- )
- await hass.async_block_till_done()
-
- assert response.speech["plain"]["speech"] == "Turned test lights 2 on"
- assert len(calls) == 1
- call = calls[0]
- assert call.domain == "light"
- assert call.service == "turn_on"
- assert call.data == {"entity_id": ["light.test_lights_2"]}
-
-
async def test_turn_on_to_not_block_for_domains_without_service(hass):
"""Test if turn_on is blocking domain with no service."""
await async_setup_component(hass, "homeassistant", {})
@@ -411,3 +326,63 @@ async def test_setting_location(hass):
assert len(events) == 1
assert hass.config.latitude == 30
assert hass.config.longitude == 40
+
+
+async def test_require_admin(hass, hass_read_only_user):
+ """Test services requiring admin."""
+ await async_setup_component(hass, "homeassistant", {})
+
+ for service in (
+ SERVICE_HOMEASSISTANT_RESTART,
+ SERVICE_HOMEASSISTANT_STOP,
+ SERVICE_CHECK_CONFIG,
+ SERVICE_RELOAD_CORE_CONFIG,
+ ):
+ with pytest.raises(Unauthorized):
+ await hass.services.async_call(
+ ha.DOMAIN,
+ service,
+ {},
+ context=ha.Context(user_id=hass_read_only_user.id),
+ blocking=True,
+ )
+ assert False, f"Should have raises for {service}"
+
+ with pytest.raises(Unauthorized):
+ await hass.services.async_call(
+ ha.DOMAIN,
+ SERVICE_SET_LOCATION,
+ {"latitude": 0, "longitude": 0},
+ context=ha.Context(user_id=hass_read_only_user.id),
+ blocking=True,
+ )
+
+
+async def test_turn_on_off_toggle_schema(hass, hass_read_only_user):
+ """Test the schemas for the turn on/off/toggle services."""
+ await async_setup_component(hass, "homeassistant", {})
+
+ for service in SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE:
+ for invalid in None, "nothing", ENTITY_MATCH_ALL, ENTITY_MATCH_NONE:
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call(
+ ha.DOMAIN,
+ service,
+ {"entity_id": invalid},
+ context=ha.Context(user_id=hass_read_only_user.id),
+ blocking=True,
+ )
+
+
+async def test_not_allowing_recursion(hass, caplog):
+ """Test we do not allow recursion."""
+ await async_setup_component(hass, "homeassistant", {})
+
+ for service in SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE:
+ await hass.services.async_call(
+ ha.DOMAIN, service, {"entity_id": "homeassistant.light"}, blocking=True,
+ )
+ assert (
+ f"Called service homeassistant.{service} with invalid entity IDs homeassistant.light"
+ in caplog.text
+ ), service
diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py
index c423f66c7b8a9f..127d7044be93b8 100644
--- a/tests/components/homeassistant/test_scene.py
+++ b/tests/components/homeassistant/test_scene.py
@@ -258,3 +258,33 @@ async def test_entities_in_scene(hass):
("scene.scene_3", ["light.kitchen", "light.living_room"]),
):
assert ha_scene.entities_in_scene(hass, scene_id) == entities
+
+
+async def test_config(hass):
+ """Test passing config in YAML."""
+ assert await async_setup_component(
+ hass,
+ "scene",
+ {
+ "scene": [
+ {
+ "id": "scene_id",
+ "name": "Scene Icon",
+ "icon": "mdi:party",
+ "entities": {"light.kitchen": "on"},
+ },
+ {
+ "name": "Scene No Icon",
+ "entities": {"light.kitchen": {"state": "on"}},
+ },
+ ]
+ },
+ )
+
+ icon = hass.states.get("scene.scene_icon")
+ assert icon is not None
+ assert icon.attributes["icon"] == "mdi:party"
+
+ no_icon = hass.states.get("scene.scene_no_icon")
+ assert no_icon is not None
+ assert "icon" not in no_icon.attributes
diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py
index f67e0e2478d57a..ddca9698987e60 100644
--- a/tests/components/homekit/test_accessories.py
+++ b/tests/components/homekit/test_accessories.py
@@ -246,7 +246,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog):
async def test_missing_linked_battery_sensor(hass, hk_driver, caplog):
- """Test battery service with mising linked_battery_sensor."""
+ """Test battery service with missing linked_battery_sensor."""
entity_id = "homekit.accessory"
linked_battery = "sensor.battery"
hass.states.async_set(entity_id, "open")
diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py
index 025c8c565f2dfa..51a1281512471c 100644
--- a/tests/components/homekit_controller/common.py
+++ b/tests/components/homekit_controller/common.py
@@ -4,14 +4,10 @@
import os
from unittest import mock
-from homekit.exceptions import AccessoryNotFoundError
-from homekit.model import Accessory, get_id
-from homekit.model.characteristics import (
- AbstractCharacteristic,
- CharacteristicPermissions,
- CharacteristicsTypes,
-)
-from homekit.model.services import AbstractService, ServicesTypes
+from aiohomekit.model import Accessories, Accessory
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+from aiohomekit.testing import FakeController
from homeassistant import config_entries
from homeassistant.components.homekit_controller import config_flow
@@ -26,77 +22,6 @@
from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
-class FakePairing:
- """
- A test fake that pretends to be a paired HomeKit accessory.
-
- This only contains methods and values that exist on the upstream Pairing
- class.
- """
-
- def __init__(self, accessories):
- """Create a fake pairing from an accessory model."""
- self.accessories = accessories
- self.pairing_data = {}
- self.available = True
-
- def list_accessories_and_characteristics(self):
- """Fake implementation of list_accessories_and_characteristics."""
- accessories = [a.to_accessory_and_service_list() for a in self.accessories]
- # replicate what happens upstream right now
- self.pairing_data["accessories"] = accessories
- return accessories
-
- def get_characteristics(self, characteristics):
- """Fake implementation of get_characteristics."""
- if not self.available:
- raise AccessoryNotFoundError("Accessory not found")
-
- results = {}
- for aid, cid in characteristics:
- for accessory in self.accessories:
- if aid != accessory.aid:
- continue
- for service in accessory.services:
- for char in service.characteristics:
- if char.iid != cid:
- continue
- results[(aid, cid)] = {"value": char.get_value()}
- return results
-
- def put_characteristics(self, characteristics):
- """Fake implementation of put_characteristics."""
- for aid, cid, new_val in characteristics:
- for accessory in self.accessories:
- if aid != accessory.aid:
- continue
- for service in accessory.services:
- for char in service.characteristics:
- if char.iid != cid:
- continue
- char.set_value(new_val)
- return {}
-
-
-class FakeController:
- """
- A test fake that pretends to be a paired HomeKit accessory.
-
- This only contains methods and values that exist on the upstream Controller
- class.
- """
-
- def __init__(self):
- """Create a Fake controller with no pairings."""
- self.pairings = {}
-
- def add(self, accessories):
- """Create and register a fake pairing for a simulated accessory."""
- pairing = FakePairing(accessories)
- self.pairings["00:00:00:00:00:00"] = pairing
- return pairing
-
-
class Helper:
"""Helper methods for interacting with HomeKit fakes."""
@@ -124,45 +49,6 @@ async def poll_and_get_state(self):
return state
-class FakeCharacteristic(AbstractCharacteristic):
- """
- A model of a generic HomeKit characteristic.
-
- Base is abstract and can't be instanced directly so this subclass is
- needed even though it doesn't add any methods.
- """
-
- def to_accessory_and_service_list(self):
- """Serialize the characteristic."""
- # Upstream doesn't correctly serialize valid_values
- # This fix will be upstreamed and this function removed when it
- # is fixed.
- record = super().to_accessory_and_service_list()
- if self.valid_values:
- record["valid-values"] = self.valid_values
- return record
-
-
-class FakeService(AbstractService):
- """A model of a generic HomeKit service."""
-
- def __init__(self, service_name):
- """Create a fake service by its short form HAP spec name."""
- char_type = ServicesTypes.get_uuid(service_name)
- super().__init__(char_type, get_id())
-
- def add_characteristic(self, name):
- """Add a characteristic to this service by name."""
- full_name = "public.hap.characteristic." + name
- char = FakeCharacteristic(get_id(), full_name, None)
- char.perms = [
- CharacteristicPermissions.paired_read,
- CharacteristicPermissions.paired_write,
- ]
- self.characteristics.append(char)
- return char
-
-
async def time_changed(hass, seconds):
"""Trigger time changed."""
next_update = dt_util.utcnow() + timedelta(seconds)
@@ -176,40 +62,7 @@ async def setup_accessories_from_file(hass, path):
load_fixture, os.path.join("homekit_controller", path)
)
accessories_json = json.loads(accessories_fixture)
-
- accessories = []
-
- for accessory_data in accessories_json:
- accessory = Accessory("Name", "Mfr", "Model", "0001", "0.1")
- accessory.services = []
- accessory.aid = accessory_data["aid"]
- for service_data in accessory_data["services"]:
- service = FakeService("public.hap.service.accessory-information")
- service.type = service_data["type"]
- service.iid = service_data["iid"]
-
- for char_data in service_data["characteristics"]:
- char = FakeCharacteristic(1, "23", None)
- char.type = char_data["type"]
- char.iid = char_data["iid"]
- char.perms = char_data["perms"]
- char.format = char_data["format"]
- if "description" in char_data:
- char.description = char_data["description"]
- if "value" in char_data:
- char.value = char_data["value"]
- if "minValue" in char_data:
- char.minValue = char_data["minValue"]
- if "maxValue" in char_data:
- char.maxValue = char_data["maxValue"]
- if "valid-values" in char_data:
- char.valid_values = char_data["valid-values"]
- service.characteristics.append(char)
-
- accessory.services.append(service)
-
- accessories.append(accessory)
-
+ accessories = Accessory.setup_accessories_from_list(accessories_json)
return accessories
@@ -217,7 +70,7 @@ async def setup_platform(hass):
"""Load the platform but with a fake Controller API."""
config = {"discovery": {}}
- with mock.patch("homekit.Controller") as controller:
+ with mock.patch("aiohomekit.Controller") as controller:
fake_controller = controller.return_value = FakeController()
await async_setup_component(hass, DOMAIN, config)
@@ -227,28 +80,22 @@ async def setup_platform(hass):
async def setup_test_accessories(hass, accessories):
"""Load a fake homekit device based on captured JSON profile."""
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add(accessories)
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1},
- }
+ pairing_id = "00:00:00:00:00:00"
- pairing.pairing_data.update(
- {"AccessoryPairingID": discovery_info["properties"]["id"]}
- )
+ accessories_obj = Accessories()
+ for accessory in accessories:
+ accessories_obj.add_accessory(accessory)
+ pairing = await fake_controller.add_paired_device(accessories_obj, pairing_id)
config_entry = MockConfigEntry(
version=1,
domain="homekit_controller",
entry_id="TestData",
- data=pairing.pairing_data,
+ data={"AccessoryPairingID": pairing_id},
title="test",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
)
-
config_entry.add_to_hass(hass)
pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing"
@@ -265,7 +112,11 @@ async def device_config_changed(hass, accessories):
# Update the accessories our FakePairing knows about
controller = hass.data[CONTROLLER]
pairing = controller.pairings["00:00:00:00:00:00"]
- pairing.accessories = accessories
+
+ accessories_obj = Accessories()
+ for accessory in accessories:
+ accessories_obj.add_accessory(accessory)
+ pairing.accessories = accessories_obj
discovery_info = {
"name": "TestDevice",
@@ -293,15 +144,18 @@ async def device_config_changed(hass, accessories):
await hass.async_block_till_done()
-async def setup_test_component(hass, services, capitalize=False, suffix=None):
+async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=None):
"""Load a fake homekit accessory based on a homekit accessory model.
If capitalize is True, property names will be in upper case.
If suffix is set, entityId will include the suffix
"""
+ accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
+ setup_accessory(accessory)
+
domain = None
- for service in services:
+ for service in accessory.services:
service_name = ServicesTypes.get_short(service.type)
if service_name in HOMEKIT_ACCESSORY_DISPATCH:
domain = HOMEKIT_ACCESSORY_DISPATCH[service_name]
@@ -309,9 +163,6 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None):
assert domain, "Cannot map test homekit services to Home Assistant domain"
- accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.extend(services)
-
config_entry, pairing = await setup_test_accessories(hass, [accessory])
entity = "testdevice" if suffix is None else "testdevice_{}".format(suffix)
return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)
diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py
index cca272be062b6a..99e86335cdb867 100644
--- a/tests/components/homekit_controller/conftest.py
+++ b/tests/components/homekit_controller/conftest.py
@@ -2,6 +2,8 @@
import datetime
from unittest import mock
+from aiohomekit.testing import FakeController
+import asynctest
import pytest
@@ -12,3 +14,11 @@ def utcnow(request):
with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow:
dt_utcnow.return_value = start_dt
yield dt_utcnow
+
+
+@pytest.fixture
+def controller(hass):
+ """Replace aiohomekit.Controller with an instance of aiohomekit.testing.FakeController."""
+ instance = FakeController()
+ with asynctest.patch("aiohomekit.Controller", return_value=instance):
+ yield instance
diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py
index bb7695840f0ec4..7a18dad4f5c4a2 100644
--- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py
+++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py
@@ -6,7 +6,8 @@
from unittest import mock
-from homekit import AccessoryDisconnectedError
+from aiohomekit import AccessoryDisconnectedError
+from aiohomekit.testing import FakePairing
from homeassistant.components.climate.const import (
SUPPORT_TARGET_HUMIDITY,
@@ -15,7 +16,6 @@
from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY
from tests.components.homekit_controller.common import (
- FakePairing,
Helper,
device_config_changed,
setup_accessories_from_file,
diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py
new file mode 100644
index 00000000000000..b1a8c0a636fab4
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py
@@ -0,0 +1,37 @@
+"""
+Regression tests for Ecobee occupancy.
+
+https://github.com/home-assistant/home-assistant/issues/31827
+"""
+
+from tests.components.homekit_controller.common import (
+ Helper,
+ setup_accessories_from_file,
+ setup_test_accessories,
+)
+
+
+async def test_ecobee_occupancy_setup(hass):
+ """Test that an Ecbobee occupancy sensor be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json")
+ config_entry, pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor = entity_registry.async_get("binary_sensor.master_fan")
+ assert sensor.unique_id == "homekit-111111111111-56"
+
+ sensor_helper = Helper(
+ hass, "binary_sensor.master_fan", pairing, accessories[0], config_entry
+ )
+ sensor_state = await sensor_helper.poll_and_get_state()
+ assert sensor_state.attributes["friendly_name"] == "Master Fan"
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ device = device_registry.async_get(sensor.device_id)
+ assert device.manufacturer == "ecobee Inc."
+ assert device.name == "Master Fan"
+ assert device.model == "ecobee Switch+"
+ assert device.sw_version == "4.5.130201"
+ assert device.via_device_id is None
diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
index 52339bb6635ad5..2abd12b3df4ccd 100644
--- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
+++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
@@ -3,7 +3,8 @@
from datetime import timedelta
from unittest import mock
-from homekit.exceptions import AccessoryDisconnectedError, EncryptionError
+from aiohomekit.exceptions import AccessoryDisconnectedError, EncryptionError
+from aiohomekit.testing import FakePairing
import pytest
from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR
@@ -11,7 +12,6 @@
from tests.common import async_fire_time_changed
from tests.components.homekit_controller.common import (
- FakePairing,
Helper,
setup_accessories_from_file,
setup_test_accessories,
diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py
index 41f39d7d7a3fa7..52c79f2b28a863 100644
--- a/tests/components/homekit_controller/test_air_quality.py
+++ b/tests/components/homekit_controller/test_air_quality.py
@@ -1,39 +1,39 @@
"""Basic checks for HomeKit air quality sensor."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+from tests.components.homekit_controller.common import setup_test_component
-def create_air_quality_sensor_service():
+
+def create_air_quality_sensor_service(accessory):
"""Define temperature characteristics."""
- service = FakeService("public.hap.service.sensor.air-quality")
+ service = accessory.add_service(ServicesTypes.AIR_QUALITY_SENSOR)
- cur_state = service.add_characteristic("air-quality")
+ cur_state = service.add_char(CharacteristicsTypes.AIR_QUALITY)
cur_state.value = 5
- cur_state = service.add_characteristic("density.ozone")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_OZONE)
cur_state.value = 1111
- cur_state = service.add_characteristic("density.no2")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_NO2)
cur_state.value = 2222
- cur_state = service.add_characteristic("density.so2")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_SO2)
cur_state.value = 3333
- cur_state = service.add_characteristic("density.pm25")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM25)
cur_state.value = 4444
- cur_state = service.add_characteristic("density.pm10")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM10)
cur_state.value = 5555
- cur_state = service.add_characteristic("density.voc")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_VOC)
cur_state.value = 6666
- return service
-
async def test_air_quality_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
- sensor = create_air_quality_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_air_quality_sensor_service)
state = await helper.poll_and_get_state()
assert state.state == "4444"
diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py
index 39ad429d6efc8b..5694be5f955fad 100644
--- a/tests/components/homekit_controller/test_alarm_control_panel.py
+++ b/tests/components/homekit_controller/test_alarm_control_panel.py
@@ -1,34 +1,34 @@
"""Basic checks for HomeKitalarm_control_panel."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
CURRENT_STATE = ("security-system", "security-system-state.current")
TARGET_STATE = ("security-system", "security-system-state.target")
-def create_security_system_service():
+def create_security_system_service(accessory):
"""Define a security-system characteristics as per page 219 of HAP spec."""
- service = FakeService("public.hap.service.security-system")
+ service = accessory.add_service(ServicesTypes.SECURITY_SYSTEM)
- cur_state = service.add_characteristic("security-system-state.current")
+ cur_state = service.add_char(CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT)
cur_state.value = 0
- targ_state = service.add_characteristic("security-system-state.target")
+ targ_state = service.add_char(CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET)
targ_state.value = 0
# According to the spec, a battery-level characteristic is normally
- # part of a seperate service. However as the code was written (which
+ # part of a separate service. However as the code was written (which
# predates this test) the battery level would have to be part of the lock
# service as it is here.
- targ_state = service.add_characteristic("battery-level")
+ targ_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL)
targ_state.value = 50
- return service
-
async def test_switch_change_alarm_state(hass, utcnow):
"""Test that we can turn a HomeKit alarm on and off again."""
- alarm_control_panel = create_security_system_service()
- helper = await setup_test_component(hass, [alarm_control_panel])
+ helper = await setup_test_component(hass, create_security_system_service)
await hass.services.async_call(
"alarm_control_panel",
@@ -65,8 +65,7 @@ async def test_switch_change_alarm_state(hass, utcnow):
async def test_switch_read_alarm_state(hass, utcnow):
"""Test that we can read the state of a HomeKit alarm accessory."""
- alarm_control_panel = create_security_system_service()
- helper = await setup_test_component(hass, [alarm_control_panel])
+ helper = await setup_test_component(hass, create_security_system_service)
helper.characteristics[CURRENT_STATE].value = 0
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py
index f472ac38d1db8e..8817ed5c22d02a 100644
--- a/tests/components/homekit_controller/test_binary_sensor.py
+++ b/tests/components/homekit_controller/test_binary_sensor.py
@@ -1,25 +1,33 @@
"""Basic checks for HomeKit motion sensors and contact sensors."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_OCCUPANCY,
+ DEVICE_CLASS_OPENING,
+ DEVICE_CLASS_SMOKE,
+)
+
+from tests.components.homekit_controller.common import setup_test_component
MOTION_DETECTED = ("motion", "motion-detected")
CONTACT_STATE = ("contact", "contact-state")
SMOKE_DETECTED = ("smoke", "smoke-detected")
+OCCUPANCY_DETECTED = ("occupancy", "occupancy-detected")
-def create_motion_sensor_service():
+def create_motion_sensor_service(accessory):
"""Define motion characteristics as per page 225 of HAP spec."""
- service = FakeService("public.hap.service.sensor.motion")
+ service = accessory.add_service(ServicesTypes.MOTION_SENSOR)
- cur_state = service.add_characteristic("motion-detected")
+ cur_state = service.add_char(CharacteristicsTypes.MOTION_DETECTED)
cur_state.value = 0
- return service
-
async def test_motion_sensor_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit motion sensor accessory."""
- sensor = create_motion_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_motion_sensor_service)
helper.characteristics[MOTION_DETECTED].value = False
state = await helper.poll_and_get_state()
@@ -29,21 +37,20 @@ async def test_motion_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "on"
+ assert state.attributes["device_class"] == DEVICE_CLASS_MOTION
-def create_contact_sensor_service():
+
+def create_contact_sensor_service(accessory):
"""Define contact characteristics."""
- service = FakeService("public.hap.service.sensor.contact")
+ service = accessory.add_service(ServicesTypes.CONTACT_SENSOR)
- cur_state = service.add_characteristic("contact-state")
+ cur_state = service.add_char(CharacteristicsTypes.CONTACT_STATE)
cur_state.value = 0
- return service
-
async def test_contact_sensor_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit contact accessory."""
- sensor = create_contact_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_contact_sensor_service)
helper.characteristics[CONTACT_STATE].value = 0
state = await helper.poll_and_get_state()
@@ -53,21 +60,20 @@ async def test_contact_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "on"
+ assert state.attributes["device_class"] == DEVICE_CLASS_OPENING
-def create_smoke_sensor_service():
+
+def create_smoke_sensor_service(accessory):
"""Define smoke sensor characteristics."""
- service = FakeService("public.hap.service.sensor.smoke")
+ service = accessory.add_service(ServicesTypes.SMOKE_SENSOR)
- cur_state = service.add_characteristic("smoke-detected")
+ cur_state = service.add_char(CharacteristicsTypes.SMOKE_DETECTED)
cur_state.value = 0
- return service
-
async def test_smoke_sensor_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit contact accessory."""
- sensor = create_smoke_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_smoke_sensor_service)
helper.characteristics[SMOKE_DETECTED].value = 0
state = await helper.poll_and_get_state()
@@ -77,4 +83,27 @@ async def test_smoke_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "on"
- assert state.attributes["device_class"] == "smoke"
+ assert state.attributes["device_class"] == DEVICE_CLASS_SMOKE
+
+
+def create_occupancy_sensor_service(accessory):
+ """Define occupancy characteristics."""
+ service = accessory.add_service(ServicesTypes.OCCUPANCY_SENSOR)
+
+ cur_state = service.add_char(CharacteristicsTypes.OCCUPANCY_DETECTED)
+ cur_state.value = 0
+
+
+async def test_occupancy_sensor_read_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit occupancy sensor accessory."""
+ helper = await setup_test_component(hass, create_occupancy_sensor_service)
+
+ helper.characteristics[OCCUPANCY_DETECTED].value = False
+ state = await helper.poll_and_get_state()
+ assert state.state == "off"
+
+ helper.characteristics[OCCUPANCY_DETECTED].value = True
+ state = await helper.poll_and_get_state()
+ assert state.state == "on"
+
+ assert state.attributes["device_class"] == DEVICE_CLASS_OCCUPANCY
diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py
index e076b2975e2c71..9bcadb6604e224 100644
--- a/tests/components/homekit_controller/test_climate.py
+++ b/tests/components/homekit_controller/test_climate.py
@@ -1,4 +1,7 @@
"""Basic checks for HomeKitclimate."""
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from homeassistant.components.climate.const import (
DOMAIN,
HVAC_MODE_COOL,
@@ -10,7 +13,7 @@
SERVICE_SET_TEMPERATURE,
)
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from tests.components.homekit_controller.common import setup_test_component
HEATING_COOLING_TARGET = ("thermostat", "heating-cooling.target")
HEATING_COOLING_CURRENT = ("thermostat", "heating-cooling.current")
@@ -20,63 +23,65 @@
HUMIDITY_CURRENT = ("thermostat", "relative-humidity.current")
-def create_thermostat_service():
+def create_thermostat_service(accessory):
"""Define thermostat characteristics."""
- service = FakeService("public.hap.service.thermostat")
+ service = accessory.add_service(ServicesTypes.THERMOSTAT)
- char = service.add_characteristic("heating-cooling.target")
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET)
char.value = 0
- char = service.add_characteristic("heating-cooling.current")
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_CURRENT)
char.value = 0
- char = service.add_characteristic("temperature.target")
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_TARGET)
+ char.minValue = 7
+ char.maxValue = 35
char.value = 0
- char = service.add_characteristic("temperature.current")
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT)
char.value = 0
- char = service.add_characteristic("relative-humidity.target")
+ char = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET)
char.value = 0
- char = service.add_characteristic("relative-humidity.current")
+ char = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
char.value = 0
- return service
-
-async def test_climate_respect_supported_op_modes_1(hass, utcnow):
- """Test that climate respects minValue/maxValue hints."""
- service = FakeService("public.hap.service.thermostat")
- char = service.add_characteristic("heating-cooling.target")
+def create_thermostat_service_min_max(accessory):
+ """Define thermostat characteristics."""
+ service = accessory.add_service(ServicesTypes.THERMOSTAT)
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET)
char.value = 0
char.minValue = 0
char.maxValue = 1
- helper = await setup_test_component(hass, [service])
+async def test_climate_respect_supported_op_modes_1(hass, utcnow):
+ """Test that climate respects minValue/maxValue hints."""
+ helper = await setup_test_component(hass, create_thermostat_service_min_max)
state = await helper.poll_and_get_state()
assert state.attributes["hvac_modes"] == ["off", "heat"]
-async def test_climate_respect_supported_op_modes_2(hass, utcnow):
- """Test that climate respects validValue hints."""
- service = FakeService("public.hap.service.thermostat")
- char = service.add_characteristic("heating-cooling.target")
+def create_thermostat_service_valid_vals(accessory):
+ """Define thermostat characteristics."""
+ service = accessory.add_service(ServicesTypes.THERMOSTAT)
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET)
char.value = 0
char.valid_values = [0, 1, 2]
- helper = await setup_test_component(hass, [service])
+async def test_climate_respect_supported_op_modes_2(hass, utcnow):
+ """Test that climate respects validValue hints."""
+ helper = await setup_test_component(hass, create_thermostat_service_valid_vals)
state = await helper.poll_and_get_state()
assert state.attributes["hvac_modes"] == ["off", "heat", "cool"]
async def test_climate_change_thermostat_state(hass, utcnow):
"""Test that we can turn a HomeKit thermostat on and off again."""
- from homekit.model.services import ThermostatService
-
- helper = await setup_test_component(hass, [ThermostatService()])
+ helper = await setup_test_component(hass, create_thermostat_service)
await hass.services.async_call(
DOMAIN,
@@ -114,9 +119,7 @@ async def test_climate_change_thermostat_state(hass, utcnow):
async def test_climate_change_thermostat_temperature(hass, utcnow):
"""Test that we can turn a HomeKit thermostat on and off again."""
- from homekit.model.services import ThermostatService
-
- helper = await setup_test_component(hass, [ThermostatService()])
+ helper = await setup_test_component(hass, create_thermostat_service)
await hass.services.async_call(
DOMAIN,
@@ -137,7 +140,7 @@ async def test_climate_change_thermostat_temperature(hass, utcnow):
async def test_climate_change_thermostat_humidity(hass, utcnow):
"""Test that we can turn a HomeKit thermostat on and off again."""
- helper = await setup_test_component(hass, [create_thermostat_service()])
+ helper = await setup_test_component(hass, create_thermostat_service)
await hass.services.async_call(
DOMAIN,
@@ -158,7 +161,7 @@ async def test_climate_change_thermostat_humidity(hass, utcnow):
async def test_climate_read_thermostat_state(hass, utcnow):
"""Test that we can read the state of a HomeKit thermostat accessory."""
- helper = await setup_test_component(hass, [create_thermostat_service()])
+ helper = await setup_test_component(hass, create_thermostat_service)
# Simulate that heating is on
helper.characteristics[TEMPERATURE_CURRENT].value = 19
@@ -200,7 +203,7 @@ async def test_climate_read_thermostat_state(hass, utcnow):
async def test_hvac_mode_vs_hvac_action(hass, utcnow):
"""Check that we haven't conflated hvac_mode and hvac_action."""
- helper = await setup_test_component(hass, [create_thermostat_service()])
+ helper = await setup_test_component(hass, create_thermostat_service)
# Simulate that current temperature is above target temp
# Heating might be on, but hvac_action currently 'off'
diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py
index 2a7f36ba470d84..2f2554caf85409 100644
--- a/tests/components/homekit_controller/test_config_flow.py
+++ b/tests/components/homekit_controller/test_config_flow.py
@@ -2,40 +2,40 @@
import json
from unittest import mock
-import homekit
+import aiohomekit
+from aiohomekit.model import Accessories, Accessory
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+import asynctest
+from asynctest import patch
import pytest
from homeassistant.components.homekit_controller import config_flow
-from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
from tests.common import MockConfigEntry
-from tests.components.homekit_controller.common import (
- Accessory,
- FakeService,
- setup_platform,
-)
+from tests.components.homekit_controller.common import setup_platform
PAIRING_START_FORM_ERRORS = [
- (homekit.BusyError, "busy_error"),
- (homekit.MaxTriesError, "max_tries_error"),
+ (aiohomekit.BusyError, "busy_error"),
+ (aiohomekit.MaxTriesError, "max_tries_error"),
(KeyError, "pairing_failed"),
]
PAIRING_START_ABORT_ERRORS = [
- (homekit.AccessoryNotFoundError, "accessory_not_found_error"),
- (homekit.UnavailableError, "already_paired"),
+ (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error"),
+ (aiohomekit.UnavailableError, "already_paired"),
]
PAIRING_FINISH_FORM_ERRORS = [
- (homekit.exceptions.MalformedPinError, "authentication_error"),
- (homekit.MaxPeersError, "max_peers_error"),
- (homekit.AuthenticationError, "authentication_error"),
- (homekit.UnknownError, "unknown_error"),
+ (aiohomekit.exceptions.MalformedPinError, "authentication_error"),
+ (aiohomekit.MaxPeersError, "max_peers_error"),
+ (aiohomekit.AuthenticationError, "authentication_error"),
+ (aiohomekit.UnknownError, "unknown_error"),
(KeyError, "pairing_failed"),
]
PAIRING_FINISH_ABORT_ERRORS = [
- (homekit.AccessoryNotFoundError, "accessory_not_found_error")
+ (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error")
]
INVALID_PAIRING_CODES = [
@@ -60,28 +60,30 @@
]
-def _setup_flow_handler(hass):
+def _setup_flow_handler(hass, pairing=None):
flow = config_flow.HomekitControllerFlowHandler()
flow.hass = hass
flow.context = {}
+ finish_pairing = asynctest.CoroutineMock(return_value=pairing)
+
+ discovery = mock.Mock()
+ discovery.device_id = "00:00:00:00:00:00"
+ discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing)
+
flow.controller = mock.Mock()
flow.controller.pairings = {}
+ flow.controller.find_ip_by_device_id = asynctest.CoroutineMock(
+ return_value=discovery
+ )
return flow
-async def _setup_flow_zeroconf(hass, discovery_info):
- result = await hass.config_entries.flow.async_init(
- "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
- )
- return result
-
-
@pytest.mark.parametrize("pairing_code", INVALID_PAIRING_CODES)
def test_invalid_pairing_codes(pairing_code):
"""Test ensure_pin_format raises for an invalid pin code."""
- with pytest.raises(homekit.exceptions.MalformedPinError):
+ with pytest.raises(aiohomekit.exceptions.MalformedPinError):
config_flow.ensure_pin_format(pairing_code)
@@ -95,243 +97,174 @@ def test_valid_pairing_codes(pairing_code):
assert len(valid_pin[2]) == 3
-async def test_discovery_works(hass):
- """Test a device being discovered."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
+def get_flow_context(hass, result):
+ """Get the flow context from the result of async_init or async_configure."""
+ flow = next(
+ (
+ flow
+ for flow in hass.config_entries.flow.async_progress()
+ if flow["flow_id"] == result["flow_id"]
+ )
+ )
- flow = _setup_flow_handler(hass)
+ return flow["context"]
- # Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
+
+def get_device_discovery_info(device, upper_case_props=False, missing_csharp=False):
+ """Turn a aiohomekit format zeroconf entry into a homeassistant one."""
+ record = device.info
+ result = {
+ "host": record["address"],
+ "port": record["port"],
+ "hostname": record["name"],
+ "type": "_hap._tcp.local.",
+ "name": record["name"],
+ "properties": {
+ "md": record["md"],
+ "pv": record["pv"],
+ "id": device.device_id,
+ "c#": record["c#"],
+ "s#": record["s#"],
+ "ff": record["ff"],
+ "ci": record["ci"],
+ "sf": 0x01, # record["sf"],
+ "sh": "",
+ },
}
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
+ if missing_csharp:
+ del result["properties"]["c#"]
- # Pairing doesn't error error and pairing results
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "create_entry"
- assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
+ if upper_case_props:
+ result["properties"] = {
+ key.upper(): val for (key, val) in result["properties"].items()
+ }
+ return result
-async def test_discovery_works_upper_case(hass):
- """Test a device being discovered."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"MD": "TestDevice", "ID": "00:00:00:00:00:00", "C#": 1, "SF": 1},
- }
- flow = _setup_flow_handler(hass)
+def setup_mock_accessory(controller):
+ """Add a bridge accessory to a test controller."""
+ bridge = Accessories()
- # Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
+ accessory = Accessory(
+ name="Koogeek-LS1-20833F",
+ manufacturer="Koogeek",
+ model="LS1",
+ serial_number="12345",
+ firmware_revision="1.1",
+ )
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "create_entry"
- assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
+ bridge.add_accessory(accessory)
+ return controller.add_device(bridge)
-async def test_discovery_works_missing_csharp(hass):
- """Test a device being discovered that has missing mdns attrs."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "sf": 1},
- }
- flow = _setup_flow_handler(hass)
+@pytest.mark.parametrize("upper_case_props", [True, False])
+@pytest.mark.parametrize("missing_csharp", [True, False])
+async def test_discovery_works(hass, controller, upper_case_props, missing_csharp):
+ """Test a device being discovered."""
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device, upper_case_props, missing_csharp)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.context == {
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
+ "source": "zeroconf",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
}
# User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ # Pairing doesn't error error and pairing results
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "create_entry"
assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
+ assert result["data"] == {}
-async def test_abort_duplicate_flow(hass):
+async def test_abort_duplicate_flow(hass, controller):
"""Already paired."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- result = await _setup_flow_zeroconf(hass, discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- result = await _setup_flow_zeroconf(hass, discovery_info)
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_in_progress"
-async def test_pair_already_paired_1(hass):
+async def test_pair_already_paired_1(hass, controller):
"""Already paired."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 0},
- }
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- flow = _setup_flow_handler(hass)
+ # Flag device as already paired
+ discovery_info["properties"]["sf"] = 0x0
- result = await flow.async_step_zeroconf(discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_paired"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-async def test_discovery_ignored_model(hass):
+async def test_discovery_ignored_model(hass, controller):
"""Already paired."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {
- "md": config_flow.HOMEKIT_IGNORE[0],
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "sf": 1,
- },
- }
-
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
+ discovery_info["properties"]["md"] = config_flow.HOMEKIT_IGNORE[0]
- result = await flow.async_step_zeroconf(discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "ignored_model"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-async def test_discovery_invalid_config_entry(hass):
- """There is already a config entry for the pairing id but its invalid."""
+async def test_discovery_invalid_config_entry(hass, controller):
+ """There is already a config entry for the pairing id but it's invalid."""
MockConfigEntry(
- domain="homekit_controller", data={"AccessoryPairingID": "00:00:00:00:00:00"}
+ domain="homekit_controller",
+ data={"AccessoryPairingID": "00:00:00:00:00:00"},
+ unique_id="00:00:00:00:00:00",
).add_to_hass(hass)
# We just added a mock config entry so it must be visible in hass
assert len(hass.config_entries.async_entries()) == 1
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
# Discovery of a HKID that is in a pairable state but for which there is
# already a config entry - in that case the stale config entry is
@@ -339,378 +272,227 @@ async def test_discovery_invalid_config_entry(hass):
config_entry_count = len(hass.config_entries.async_entries())
assert config_entry_count == 0
-
-async def test_discovery_already_configured(hass):
- """Already configured."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 0},
- }
-
- await setup_platform(hass)
-
- conn = mock.Mock()
- conn.config_num = 1
- hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] = conn
-
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "abort"
- assert result["reason"] == "already_configured"
- assert flow.context == {}
-
- assert conn.async_config_num_changed.call_count == 0
+ # And new config flow should continue allowing user to set up a new pairing
+ assert result["type"] == "form"
-async def test_discovery_already_configured_config_change(hass):
+async def test_discovery_already_configured(hass, controller):
"""Already configured."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 2, "sf": 0},
- }
-
- await setup_platform(hass)
+ MockConfigEntry(
+ domain="homekit_controller",
+ data={"AccessoryPairingID": "00:00:00:00:00:00"},
+ unique_id="00:00:00:00:00:00",
+ ).add_to_hass(hass)
- conn = mock.Mock()
- conn.config_num = 1
- hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] = conn
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- flow = _setup_flow_handler(hass)
+ # Set device as already paired
+ discovery_info["properties"]["sf"] = 0x00
- result = await flow.async_step_zeroconf(discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
- assert flow.context == {}
-
- assert conn.async_refresh_entity_map.call_args == mock.call(2)
-
-
-async def test_pair_unable_to_pair(hass):
- """Pairing completed without exception, but didn't create a pairing."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
-
- # Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- # Pairing doesn't error but no pairing object is generated
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "form"
- assert result["errors"]["pairing_code"] == "unable_to_pair"
@pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS)
-async def test_pair_abort_errors_on_start(hass, exception, expected):
+async def test_pair_abort_errors_on_start(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
# User initiates pairing - device refuses to enter pairing mode
- with mock.patch.object(flow.controller, "start_pairing") as start_pairing:
- start_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({})
-
+ test_exc = exception("error")
+ with patch.object(device, "start_pairing", side_effect=test_exc):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "abort"
assert result["reason"] == expected
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
@pytest.mark.parametrize("exception,expected", PAIRING_START_FORM_ERRORS)
-async def test_pair_form_errors_on_start(hass, exception, expected):
+async def test_pair_form_errors_on_start(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
# User initiates pairing - device refuses to enter pairing mode
- with mock.patch.object(flow.controller, "start_pairing") as start_pairing:
- start_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({})
-
+ test_exc = exception("error")
+ with patch.object(device, "start_pairing", side_effect=test_exc):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "form"
assert result["errors"]["pairing_code"] == expected
- assert flow.context == {
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_ABORT_ERRORS)
-async def test_pair_abort_errors_on_finish(hass, exception, expected):
+async def test_pair_abort_errors_on_finish(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
+ # User initiates pairing - this triggers the device to show a pairing code
+ # and then HA to show a pairing form
+ finish_pairing = asynctest.CoroutineMock(side_effect=exception("error"))
+ with patch.object(device, "start_pairing", return_value=finish_pairing):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
- # User submits code - pairing fails but can be retried
- flow.finish_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "abort"
- assert result["reason"] == expected
- assert flow.context == {
+ assert result["type"] == "form"
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
+ # User enters pairing code
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == expected
+
@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_FORM_ERRORS)
-async def test_pair_form_errors_on_finish(hass, exception, expected):
+async def test_pair_form_errors_on_finish(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
+ # User initiates pairing - this triggers the device to show a pairing code
+ # and then HA to show a pairing form
+ finish_pairing = asynctest.CoroutineMock(side_effect=exception("error"))
+ with patch.object(device, "start_pairing", return_value=finish_pairing):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
- # User submits code - pairing fails but can be retried
- flow.finish_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
assert result["type"] == "form"
- assert result["errors"]["pairing_code"] == expected
- assert flow.context == {
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
-
-async def test_import_works(hass):
- """Test a device being discovered."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- import_info = {"AccessoryPairingID": "00:00:00:00:00:00"}
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow = _setup_flow_handler(hass)
-
- pairing_cls_imp = (
- "homeassistant.components.homekit_controller.config_flow.IpPairing"
+ # User enters pairing code
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
)
+ assert result["type"] == "form"
+ assert result["errors"]["pairing_code"] == expected
- with mock.patch(pairing_cls_imp) as pairing_cls:
- pairing_cls.return_value = pairing
- result = await flow.async_import_legacy_pairing(
- discovery_info["properties"], import_info
- )
-
- assert result["type"] == "create_entry"
- assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-
-
-async def test_import_already_configured(hass):
- """Test importing a device from .homekit that is already a ConfigEntry."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
+ assert get_flow_context(hass, result) == {
+ "hkid": "00:00:00:00:00:00",
+ "title_placeholders": {"name": "TestDevice"},
+ "unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
- import_info = {"AccessoryPairingID": "00:00:00:00:00:00"}
-
- config_entry = MockConfigEntry(domain="homekit_controller", data=import_info)
- config_entry.add_to_hass(hass)
-
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_import_legacy_pairing(
- discovery_info["properties"], import_info
- )
- assert result["type"] == "abort"
- assert result["reason"] == "already_configured"
-
-async def test_user_works(hass):
+async def test_user_works(hass, controller):
"""Test user initiated disovers devices."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "sf": 1,
- }
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow = _setup_flow_handler(hass)
+ setup_mock_accessory(controller)
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- flow.controller.discover.return_value = [discovery_info]
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "user"}
+ )
- result = await flow.async_step_user()
assert result["type"] == "form"
assert result["step_id"] == "user"
+ assert get_flow_context(hass, result) == {
+ "source": "user",
+ }
- result = await flow.async_step_user({"device": "TestDevice"})
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"device": "TestDevice"}
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ assert get_flow_context(hass, result) == {
+ "source": "user",
+ "unique_id": "00:00:00:00:00:00",
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "create_entry"
assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-async def test_user_no_devices(hass):
+async def test_user_no_devices(hass, controller):
"""Test user initiated pairing where no devices discovered."""
- flow = _setup_flow_handler(hass)
-
- flow.controller.discover.return_value = []
- result = await flow.async_step_user()
-
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "user"}
+ )
assert result["type"] == "abort"
assert result["reason"] == "no_devices"
-async def test_user_no_unpaired_devices(hass):
+async def test_user_no_unpaired_devices(hass, controller):
"""Test user initiated pairing where no unpaired devices discovered."""
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "sf": 0,
- }
+ # Pair the mock device so that it shows as paired in discovery
+ finish_pairing = await device.start_pairing(device.device_id)
+ await finish_pairing(device.pairing_code)
- flow.controller.discover.return_value = [discovery_info]
- result = await flow.async_step_user()
+ # Device discovery is requested
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "user"}
+ )
assert result["type"] == "abort"
assert result["reason"] == "no_devices"
@@ -718,15 +500,16 @@ async def test_user_no_unpaired_devices(hass):
async def test_parse_new_homekit_json(hass):
"""Test migrating recent .homekit/pairings.json files."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
- on_char.value = 1
-
accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.append(service)
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
+
+ accessories = Accessories()
+ accessories.add_accessory(accessory)
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add([accessory])
+ pairing = await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00")
pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"}
mock_path = mock.Mock()
@@ -766,15 +549,16 @@ async def test_parse_new_homekit_json(hass):
async def test_parse_old_homekit_json(hass):
"""Test migrating original .homekit/hk-00:00:00:00:00:00 files."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
- on_char.value = 1
-
accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.append(service)
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
+
+ accessories = Accessories()
+ accessories.add_accessory(accessory)
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add([accessory])
+ pairing = await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00")
pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"}
mock_path = mock.Mock()
@@ -818,15 +602,16 @@ async def test_parse_old_homekit_json(hass):
async def test_parse_overlapping_homekit_json(hass):
"""Test migrating .homekit/pairings.json files when hk- exists too."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
- on_char.value = 1
-
accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.append(service)
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
+
+ accessories = Accessories()
+ accessories.add_accessory(accessory)
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add([accessory])
+ pairing = await fake_controller.add_paired_device(accessories)
pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"}
mock_listdir = mock.Mock()
@@ -857,7 +642,6 @@ async def test_parse_overlapping_homekit_json(hass):
pairing_cls_imp = (
"homeassistant.components.homekit_controller.config_flow.IpPairing"
)
-
with mock.patch(pairing_cls_imp) as pairing_cls:
pairing_cls.return_value = pairing
with mock.patch("builtins.open", side_effect=side_effects):
@@ -877,83 +661,48 @@ async def test_parse_overlapping_homekit_json(hass):
}
-async def test_unignore_works(hass):
+async def test_unignore_works(hass, controller):
"""Test rediscovery triggered disovers work."""
- discovery_info = {
- "name": "TestDevice",
- "address": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "pv": "1.0",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "s#": 1,
- "ff": 0,
- "ci": 0,
- "sf": 1,
- }
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
+ device = setup_mock_accessory(controller)
- flow = _setup_flow_handler(hass)
-
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- flow.controller.discover.return_value = [discovery_info]
-
- result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:00"})
+ # Device is unignored
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller",
+ context={"source": "unignore"},
+ data={"unique_id": device.device_id},
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.context == {
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "unignore",
}
# User initiates pairing by clicking on 'configure' - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
# Pairing finalized
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "create_entry"
assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-async def test_unignore_ignores_missing_devices(hass):
+async def test_unignore_ignores_missing_devices(hass, controller):
"""Test rediscovery triggered disovers handle devices that have gone away."""
- discovery_info = {
- "name": "TestDevice",
- "address": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "pv": "1.0",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "s#": 1,
- "ff": 0,
- "ci": 0,
- "sf": 1,
- }
+ setup_mock_accessory(controller)
- flow = _setup_flow_handler(hass)
- flow.controller.discover.return_value = [discovery_info]
+ # Device is unignored
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller",
+ context={"source": "unignore"},
+ data={"unique_id": "00:00:00:00:00:01"},
+ )
- result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:01"})
assert result["type"] == "abort"
- assert flow.context == {
- "unique_id": "00:00:00:00:00:01",
- }
+ assert result["reason"] == "no_devices"
diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py
index 53245176a04e34..45514b291222db 100644
--- a/tests/components/homekit_controller/test_cover.py
+++ b/tests/components/homekit_controller/test_cover.py
@@ -1,5 +1,8 @@
"""Basic checks for HomeKitalarm_control_panel."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
POSITION_STATE = ("window-covering", "position.state")
POSITION_CURRENT = ("window-covering", "position.current")
@@ -19,61 +22,56 @@
DOOR_OBSTRUCTION = ("garage-door-opener", "obstruction-detected")
-def create_window_covering_service():
+def create_window_covering_service(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec."""
- service = FakeService("public.hap.service.window-covering")
+ service = accessory.add_service(ServicesTypes.WINDOW_COVERING)
- cur_state = service.add_characteristic("position.current")
+ cur_state = service.add_char(CharacteristicsTypes.POSITION_CURRENT)
cur_state.value = 0
- targ_state = service.add_characteristic("position.target")
+ targ_state = service.add_char(CharacteristicsTypes.POSITION_TARGET)
targ_state.value = 0
- position_state = service.add_characteristic("position.state")
+ position_state = service.add_char(CharacteristicsTypes.POSITION_STATE)
position_state.value = 0
- position_hold = service.add_characteristic("position.hold")
+ position_hold = service.add_char(CharacteristicsTypes.POSITION_HOLD)
position_hold.value = 0
- obstruction = service.add_characteristic("obstruction-detected")
+ obstruction = service.add_char(CharacteristicsTypes.OBSTRUCTION_DETECTED)
obstruction.value = False
- name = service.add_characteristic("name")
+ name = service.add_char(CharacteristicsTypes.NAME)
name.value = "testdevice"
return service
-def create_window_covering_service_with_h_tilt():
+def create_window_covering_service_with_h_tilt(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec."""
- service = create_window_covering_service()
+ service = create_window_covering_service(accessory)
- tilt_current = service.add_characteristic("horizontal-tilt.current")
+ tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT)
tilt_current.value = 0
- tilt_target = service.add_characteristic("horizontal-tilt.target")
+ tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET)
tilt_target.value = 0
- return service
-
-def create_window_covering_service_with_v_tilt():
+def create_window_covering_service_with_v_tilt(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec."""
- service = create_window_covering_service()
+ service = create_window_covering_service(accessory)
- tilt_current = service.add_characteristic("vertical-tilt.current")
+ tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT)
tilt_current.value = 0
- tilt_target = service.add_characteristic("vertical-tilt.target")
+ tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET)
tilt_target.value = 0
- return service
-
async def test_change_window_cover_state(hass, utcnow):
"""Test that we can turn a HomeKit alarm on and off again."""
- window_cover = create_window_covering_service()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(hass, create_window_covering_service)
await hass.services.async_call(
"cover", "open_cover", {"entity_id": helper.entity_id}, blocking=True
@@ -88,8 +86,7 @@ async def test_change_window_cover_state(hass, utcnow):
async def test_read_window_cover_state(hass, utcnow):
"""Test that we can read the state of a HomeKit alarm accessory."""
- window_cover = create_window_covering_service()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(hass, create_window_covering_service)
helper.characteristics[POSITION_STATE].value = 0
state = await helper.poll_and_get_state()
@@ -110,8 +107,9 @@ async def test_read_window_cover_state(hass, utcnow):
async def test_read_window_cover_tilt_horizontal(hass, utcnow):
"""Test that horizontal tilt is handled correctly."""
- window_cover = create_window_covering_service_with_h_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_h_tilt
+ )
helper.characteristics[H_TILT_CURRENT].value = 75
state = await helper.poll_and_get_state()
@@ -120,8 +118,9 @@ async def test_read_window_cover_tilt_horizontal(hass, utcnow):
async def test_read_window_cover_tilt_vertical(hass, utcnow):
"""Test that vertical tilt is handled correctly."""
- window_cover = create_window_covering_service_with_v_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_v_tilt
+ )
helper.characteristics[V_TILT_CURRENT].value = 75
state = await helper.poll_and_get_state()
@@ -130,8 +129,9 @@ async def test_read_window_cover_tilt_vertical(hass, utcnow):
async def test_write_window_cover_tilt_horizontal(hass, utcnow):
"""Test that horizontal tilt is written correctly."""
- window_cover = create_window_covering_service_with_h_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_h_tilt
+ )
await hass.services.async_call(
"cover",
@@ -144,8 +144,9 @@ async def test_write_window_cover_tilt_horizontal(hass, utcnow):
async def test_write_window_cover_tilt_vertical(hass, utcnow):
"""Test that vertical tilt is written correctly."""
- window_cover = create_window_covering_service_with_v_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_v_tilt
+ )
await hass.services.async_call(
"cover",
@@ -158,8 +159,9 @@ async def test_write_window_cover_tilt_vertical(hass, utcnow):
async def test_window_cover_stop(hass, utcnow):
"""Test that vertical tilt is written correctly."""
- window_cover = create_window_covering_service_with_v_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_v_tilt
+ )
await hass.services.async_call(
"cover", "stop_cover", {"entity_id": helper.entity_id}, blocking=True
@@ -167,20 +169,20 @@ async def test_window_cover_stop(hass, utcnow):
assert helper.characteristics[POSITION_HOLD].value == 1
-def create_garage_door_opener_service():
+def create_garage_door_opener_service(accessory):
"""Define a garage-door-opener chars as per page 217 of HAP spec."""
- service = FakeService("public.hap.service.garage-door-opener")
+ service = accessory.add_service(ServicesTypes.GARAGE_DOOR_OPENER)
- cur_state = service.add_characteristic("door-state.current")
+ cur_state = service.add_char(CharacteristicsTypes.DOOR_STATE_CURRENT)
cur_state.value = 0
- targ_state = service.add_characteristic("door-state.target")
- targ_state.value = 0
+ cur_state = service.add_char(CharacteristicsTypes.DOOR_STATE_TARGET)
+ cur_state.value = 0
- obstruction = service.add_characteristic("obstruction-detected")
+ obstruction = service.add_char(CharacteristicsTypes.OBSTRUCTION_DETECTED)
obstruction.value = False
- name = service.add_characteristic("name")
+ name = service.add_char(CharacteristicsTypes.NAME)
name.value = "testdevice"
return service
@@ -188,8 +190,7 @@ def create_garage_door_opener_service():
async def test_change_door_state(hass, utcnow):
"""Test that we can turn open and close a HomeKit garage door."""
- door = create_garage_door_opener_service()
- helper = await setup_test_component(hass, [door])
+ helper = await setup_test_component(hass, create_garage_door_opener_service)
await hass.services.async_call(
"cover", "open_cover", {"entity_id": helper.entity_id}, blocking=True
@@ -204,8 +205,7 @@ async def test_change_door_state(hass, utcnow):
async def test_read_door_state(hass, utcnow):
"""Test that we can read the state of a HomeKit garage door."""
- door = create_garage_door_opener_service()
- helper = await setup_test_component(hass, [door])
+ helper = await setup_test_component(hass, create_garage_door_opener_service)
helper.characteristics[DOOR_CURRENT].value = 0
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py
index fe97451cfbbfd4..fd24f5215da991 100644
--- a/tests/components/homekit_controller/test_fan.py
+++ b/tests/components/homekit_controller/test_fan.py
@@ -1,5 +1,8 @@
"""Basic checks for HomeKit motion sensors and contact sensors."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
V1_ON = ("fan", "on")
V1_ROTATION_DIRECTION = ("fan", "rotation.direction")
@@ -11,50 +14,45 @@
V2_SWING_MODE = ("fanv2", "swing-mode")
-def create_fan_service():
+def create_fan_service(accessory):
"""
Define fan v1 characteristics as per HAP spec.
This service is no longer documented in R2 of the public HAP spec but existing
devices out there use it (like the SIMPLEconnect fan)
"""
- service = FakeService("public.hap.service.fan")
-
- cur_state = service.add_characteristic("on")
- cur_state.value = 0
+ service = accessory.add_service(ServicesTypes.FAN)
- cur_state = service.add_characteristic("rotation.direction")
+ cur_state = service.add_char(CharacteristicsTypes.ON)
cur_state.value = 0
- cur_state = service.add_characteristic("rotation.speed")
- cur_state.value = 0
+ direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION)
+ direction.value = 0
- return service
+ speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED)
+ speed.value = 0
-def create_fanv2_service():
+def create_fanv2_service(accessory):
"""Define fan v2 characteristics as per HAP spec."""
- service = FakeService("public.hap.service.fanv2")
+ service = accessory.add_service(ServicesTypes.FAN_V2)
- cur_state = service.add_characteristic("active")
+ cur_state = service.add_char(CharacteristicsTypes.ACTIVE)
cur_state.value = 0
- cur_state = service.add_characteristic("rotation.direction")
- cur_state.value = 0
-
- cur_state = service.add_characteristic("rotation.speed")
- cur_state.value = 0
+ direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION)
+ direction.value = 0
- cur_state = service.add_characteristic("swing-mode")
- cur_state.value = 0
+ speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED)
+ speed.value = 0
- return service
+ swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE)
+ swing_mode.value = 0
async def test_fan_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit fan accessory."""
- sensor = create_fan_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = False
state = await helper.poll_and_get_state()
@@ -67,8 +65,7 @@ async def test_fan_read_state(hass, utcnow):
async def test_turn_on(hass, utcnow):
"""Test that we can turn a fan on."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
await hass.services.async_call(
"fan",
@@ -100,8 +97,7 @@ async def test_turn_on(hass, utcnow):
async def test_turn_off(hass, utcnow):
"""Test that we can turn a fan off."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = 1
@@ -113,8 +109,7 @@ async def test_turn_off(hass, utcnow):
async def test_set_speed(hass, utcnow):
"""Test that we set fan speed."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = 1
@@ -153,8 +148,7 @@ async def test_set_speed(hass, utcnow):
async def test_speed_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = 1
helper.characteristics[V1_ROTATION_SPEED].value = 100
@@ -177,8 +171,7 @@ async def test_speed_read(hass, utcnow):
async def test_set_direction(hass, utcnow):
"""Test that we can set fan spin direction."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
await hass.services.async_call(
"fan",
@@ -199,8 +192,7 @@ async def test_set_direction(hass, utcnow):
async def test_direction_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ROTATION_DIRECTION].value = 0
state = await helper.poll_and_get_state()
@@ -213,8 +205,7 @@ async def test_direction_read(hass, utcnow):
async def test_fanv2_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit fan accessory."""
- sensor = create_fanv2_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = False
state = await helper.poll_and_get_state()
@@ -227,8 +218,7 @@ async def test_fanv2_read_state(hass, utcnow):
async def test_v2_turn_on(hass, utcnow):
"""Test that we can turn a fan on."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
await hass.services.async_call(
"fan",
@@ -260,8 +250,7 @@ async def test_v2_turn_on(hass, utcnow):
async def test_v2_turn_off(hass, utcnow):
"""Test that we can turn a fan off."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = 1
@@ -273,8 +262,7 @@ async def test_v2_turn_off(hass, utcnow):
async def test_v2_set_speed(hass, utcnow):
"""Test that we set fan speed."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = 1
@@ -313,8 +301,7 @@ async def test_v2_set_speed(hass, utcnow):
async def test_v2_speed_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = 1
helper.characteristics[V2_ROTATION_SPEED].value = 100
@@ -337,8 +324,7 @@ async def test_v2_speed_read(hass, utcnow):
async def test_v2_set_direction(hass, utcnow):
"""Test that we can set fan spin direction."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
await hass.services.async_call(
"fan",
@@ -359,8 +345,7 @@ async def test_v2_set_direction(hass, utcnow):
async def test_v2_direction_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ROTATION_DIRECTION].value = 0
state = await helper.poll_and_get_state()
@@ -373,8 +358,7 @@ async def test_v2_direction_read(hass, utcnow):
async def test_v2_oscillate(hass, utcnow):
"""Test that we can control a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
await hass.services.async_call(
"fan",
@@ -395,8 +379,7 @@ async def test_v2_oscillate(hass, utcnow):
async def test_v2_oscillate_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_SWING_MODE].value = 0
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py
index b558160a9f233d..d9e1d21e2fe070 100644
--- a/tests/components/homekit_controller/test_light.py
+++ b/tests/components/homekit_controller/test_light.py
@@ -1,7 +1,10 @@
"""Basic checks for HomeKitSwitch."""
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from tests.components.homekit_controller.common import setup_test_component
LIGHT_ON = ("lightbulb", "on")
LIGHT_BRIGHTNESS = ("lightbulb", "brightness")
@@ -10,37 +13,37 @@
LIGHT_COLOR_TEMP = ("lightbulb", "color-temperature")
-def create_lightbulb_service():
+def create_lightbulb_service(accessory):
"""Define lightbulb characteristics."""
- service = FakeService("public.hap.service.lightbulb")
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
- on_char = service.add_characteristic("on")
+ on_char = service.add_char(CharacteristicsTypes.ON)
on_char.value = 0
- brightness = service.add_characteristic("brightness")
+ brightness = service.add_char(CharacteristicsTypes.BRIGHTNESS)
brightness.value = 0
return service
-def create_lightbulb_service_with_hs():
+def create_lightbulb_service_with_hs(accessory):
"""Define a lightbulb service with hue + saturation."""
- service = create_lightbulb_service()
+ service = create_lightbulb_service(accessory)
- hue = service.add_characteristic("hue")
+ hue = service.add_char(CharacteristicsTypes.HUE)
hue.value = 0
- saturation = service.add_characteristic("saturation")
+ saturation = service.add_char(CharacteristicsTypes.SATURATION)
saturation.value = 0
return service
-def create_lightbulb_service_with_color_temp():
+def create_lightbulb_service_with_color_temp(accessory):
"""Define a lightbulb service with color temp."""
- service = create_lightbulb_service()
+ service = create_lightbulb_service(accessory)
- color_temp = service.add_characteristic("color-temperature")
+ color_temp = service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE)
color_temp.value = 0
return service
@@ -48,8 +51,7 @@ def create_lightbulb_service_with_color_temp():
async def test_switch_change_light_state(hass, utcnow):
"""Test that we can turn a HomeKit light on and off again."""
- bulb = create_lightbulb_service_with_hs()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_hs)
await hass.services.async_call(
"light",
@@ -71,8 +73,7 @@ async def test_switch_change_light_state(hass, utcnow):
async def test_switch_change_light_state_color_temp(hass, utcnow):
"""Test that we can turn change color_temp."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
await hass.services.async_call(
"light",
@@ -87,8 +88,7 @@ async def test_switch_change_light_state_color_temp(hass, utcnow):
async def test_switch_read_light_state(hass, utcnow):
"""Test that we can read the state of a HomeKit light accessory."""
- bulb = create_lightbulb_service_with_hs()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_hs)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
@@ -112,8 +112,7 @@ async def test_switch_read_light_state(hass, utcnow):
async def test_switch_read_light_state_color_temp(hass, utcnow):
"""Test that we can read the color_temp of a light accessory."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
@@ -132,8 +131,7 @@ async def test_switch_read_light_state_color_temp(hass, utcnow):
async def test_light_becomes_unavailable_but_recovers(hass, utcnow):
"""Test transition to and from unavailable state."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
@@ -158,8 +156,7 @@ async def test_light_becomes_unavailable_but_recovers(hass, utcnow):
async def test_light_unloaded(hass, utcnow):
"""Test entity and HKDevice are correctly unloaded."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py
index 3b17ad13e41535..197b7b3c3b949e 100644
--- a/tests/components/homekit_controller/test_lock.py
+++ b/tests/components/homekit_controller/test_lock.py
@@ -1,25 +1,28 @@
"""Basic checks for HomeKitLock."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
LOCK_CURRENT_STATE = ("lock-mechanism", "lock-mechanism.current-state")
LOCK_TARGET_STATE = ("lock-mechanism", "lock-mechanism.target-state")
-def create_lock_service():
+def create_lock_service(accessory):
"""Define a lock characteristics as per page 219 of HAP spec."""
- service = FakeService("public.hap.service.lock-mechanism")
+ service = accessory.add_service(ServicesTypes.LOCK_MECHANISM)
- cur_state = service.add_characteristic("lock-mechanism.current-state")
+ cur_state = service.add_char(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE)
cur_state.value = 0
- targ_state = service.add_characteristic("lock-mechanism.target-state")
+ targ_state = service.add_char(CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE)
targ_state.value = 0
# According to the spec, a battery-level characteristic is normally
- # part of a seperate service. However as the code was written (which
+ # part of a separate service. However as the code was written (which
# predates this test) the battery level would have to be part of the lock
# service as it is here.
- targ_state = service.add_characteristic("battery-level")
+ targ_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL)
targ_state.value = 50
return service
@@ -27,8 +30,7 @@ def create_lock_service():
async def test_switch_change_lock_state(hass, utcnow):
"""Test that we can turn a HomeKit lock on and off again."""
- lock = create_lock_service()
- helper = await setup_test_component(hass, [lock])
+ helper = await setup_test_component(hass, create_lock_service)
await hass.services.async_call(
"lock", "lock", {"entity_id": "lock.testdevice"}, blocking=True
@@ -43,8 +45,7 @@ async def test_switch_change_lock_state(hass, utcnow):
async def test_switch_read_lock_state(hass, utcnow):
"""Test that we can read the state of a HomeKit lock accessory."""
- lock = create_lock_service()
- helper = await setup_test_component(hass, [lock])
+ helper = await setup_test_component(hass, create_lock_service)
helper.characteristics[LOCK_CURRENT_STATE].value = 0
helper.characteristics[LOCK_TARGET_STATE].value = 0
diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py
index f9d84b069962ee..8b0528ea46d481 100644
--- a/tests/components/homekit_controller/test_sensor.py
+++ b/tests/components/homekit_controller/test_sensor.py
@@ -1,5 +1,15 @@
"""Basic checks for HomeKit sensor."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from homeassistant.const import (
+ DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_ILLUMINANCE,
+ DEVICE_CLASS_TEMPERATURE,
+)
+
+from tests.components.homekit_controller.common import setup_test_component
TEMPERATURE = ("temperature", "temperature.current")
HUMIDITY = ("humidity", "relative-humidity.current")
@@ -10,57 +20,49 @@
LO_BATT = ("battery", "status-lo-batt")
-def create_temperature_sensor_service():
+def create_temperature_sensor_service(accessory):
"""Define temperature characteristics."""
- service = FakeService("public.hap.service.sensor.temperature")
+ service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR)
- cur_state = service.add_characteristic("temperature.current")
+ cur_state = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT)
cur_state.value = 0
- return service
-
-def create_humidity_sensor_service():
+def create_humidity_sensor_service(accessory):
"""Define humidity characteristics."""
- service = FakeService("public.hap.service.sensor.humidity")
+ service = accessory.add_service(ServicesTypes.HUMIDITY_SENSOR)
- cur_state = service.add_characteristic("relative-humidity.current")
+ cur_state = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
cur_state.value = 0
- return service
-
-def create_light_level_sensor_service():
+def create_light_level_sensor_service(accessory):
"""Define light level characteristics."""
- service = FakeService("public.hap.service.sensor.light")
+ service = accessory.add_service(ServicesTypes.LIGHT_SENSOR)
- cur_state = service.add_characteristic("light-level.current")
+ cur_state = service.add_char(CharacteristicsTypes.LIGHT_LEVEL_CURRENT)
cur_state.value = 0
- return service
-
-def create_carbon_dioxide_level_sensor_service():
+def create_carbon_dioxide_level_sensor_service(accessory):
"""Define carbon dioxide level characteristics."""
- service = FakeService("public.hap.service.sensor.carbon-dioxide")
+ service = accessory.add_service(ServicesTypes.CARBON_DIOXIDE_SENSOR)
- cur_state = service.add_characteristic("carbon-dioxide.level")
+ cur_state = service.add_char(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL)
cur_state.value = 0
- return service
-
-def create_battery_level_sensor():
+def create_battery_level_sensor(accessory):
"""Define battery level characteristics."""
- service = FakeService("public.hap.service.battery")
+ service = accessory.add_service(ServicesTypes.BATTERY_SERVICE)
- cur_state = service.add_characteristic("battery-level")
+ cur_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL)
cur_state.value = 100
- low_battery = service.add_characteristic("status-lo-batt")
+ low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT)
low_battery.value = 0
- charging_state = service.add_characteristic("charging-state")
+ charging_state = service.add_char(CharacteristicsTypes.CHARGING_STATE)
charging_state.value = 0
return service
@@ -68,8 +70,9 @@ def create_battery_level_sensor():
async def test_temperature_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
- sensor = create_temperature_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="temperature")
+ helper = await setup_test_component(
+ hass, create_temperature_sensor_service, suffix="temperature"
+ )
helper.characteristics[TEMPERATURE].value = 10
state = await helper.poll_and_get_state()
@@ -79,11 +82,14 @@ async def test_temperature_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE
+
async def test_humidity_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit humidity sensor accessory."""
- sensor = create_humidity_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="humidity")
+ helper = await setup_test_component(
+ hass, create_humidity_sensor_service, suffix="humidity"
+ )
helper.characteristics[HUMIDITY].value = 10
state = await helper.poll_and_get_state()
@@ -93,11 +99,14 @@ async def test_humidity_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_HUMIDITY
+
async def test_light_level_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
- sensor = create_light_level_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="light_level")
+ helper = await setup_test_component(
+ hass, create_light_level_sensor_service, suffix="light_level"
+ )
helper.characteristics[LIGHT_LEVEL].value = 10
state = await helper.poll_and_get_state()
@@ -107,11 +116,14 @@ async def test_light_level_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_ILLUMINANCE
+
async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit carbon dioxide sensor accessory."""
- sensor = create_carbon_dioxide_level_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="co2")
+ helper = await setup_test_component(
+ hass, create_carbon_dioxide_level_sensor_service, suffix="co2"
+ )
helper.characteristics[CARBON_DIOXIDE_LEVEL].value = 10
state = await helper.poll_and_get_state()
@@ -124,8 +136,9 @@ async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow):
async def test_battery_level_sensor(hass, utcnow):
"""Test reading the state of a HomeKit battery level sensor."""
- sensor = create_battery_level_sensor()
- helper = await setup_test_component(hass, [sensor], suffix="battery")
+ helper = await setup_test_component(
+ hass, create_battery_level_sensor, suffix="battery"
+ )
helper.characteristics[BATTERY_LEVEL].value = 100
state = await helper.poll_and_get_state()
@@ -137,11 +150,14 @@ async def test_battery_level_sensor(hass, utcnow):
assert state.state == "20"
assert state.attributes["icon"] == "mdi:battery-20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_BATTERY
+
async def test_battery_charging(hass, utcnow):
"""Test reading the state of a HomeKit battery's charging state."""
- sensor = create_battery_level_sensor()
- helper = await setup_test_component(hass, [sensor], suffix="battery")
+ helper = await setup_test_component(
+ hass, create_battery_level_sensor, suffix="battery"
+ )
helper.characteristics[BATTERY_LEVEL].value = 0
helper.characteristics[CHARGING_STATE].value = 1
@@ -155,8 +171,9 @@ async def test_battery_charging(hass, utcnow):
async def test_battery_low(hass, utcnow):
"""Test reading the state of a HomeKit battery's low state."""
- sensor = create_battery_level_sensor()
- helper = await setup_test_component(hass, [sensor], suffix="battery")
+ helper = await setup_test_component(
+ hass, create_battery_level_sensor, suffix="battery"
+ )
helper.characteristics[LO_BATT].value = 0
helper.characteristics[BATTERY_LEVEL].value = 1
diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py
index 39b0d9d8250565..4f0dabb9bc863f 100644
--- a/tests/components/homekit_controller/test_storage.py
+++ b/tests/components/homekit_controller/test_storage.py
@@ -1,11 +1,13 @@
"""Basic checks for entity map storage."""
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from homeassistant import config_entries
from homeassistant.components.homekit_controller import async_remove_entry
from homeassistant.components.homekit_controller.const import ENTITY_MAP
from tests.common import flush_store
from tests.components.homekit_controller.common import (
- FakeService,
setup_platform,
setup_test_component,
)
@@ -57,18 +59,16 @@ async def test_storage_is_removed_idempotent(hass):
assert hkid not in entity_map.storage_data
-def create_lightbulb_service():
+def create_lightbulb_service(accessory):
"""Define lightbulb characteristics."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
on_char.value = 0
- return service
async def test_storage_is_updated_on_add(hass, hass_storage, utcnow):
"""Test entity map storage is cleaned up on adding an accessory."""
- bulb = create_lightbulb_service()
- await setup_test_component(hass, [bulb])
+ await setup_test_component(hass, create_lightbulb_service)
entity_map = hass.data[ENTITY_MAP]
hkid = "00:00:00:00:00:00"
@@ -83,8 +83,7 @@ async def test_storage_is_updated_on_add(hass, hass_storage, utcnow):
async def test_storage_is_removed_on_config_entry_removal(hass, utcnow):
"""Test entity map storage is cleaned up on config entry removal."""
- bulb = create_lightbulb_service()
- await setup_test_component(hass, [bulb])
+ await setup_test_component(hass, create_lightbulb_service)
hkid = "00:00:00:00:00:00"
diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py
index 82dae3f4a6e097..eb10d42e20805b 100644
--- a/tests/components/homekit_controller/test_switch.py
+++ b/tests/components/homekit_controller/test_switch.py
@@ -1,12 +1,25 @@
"""Basic checks for HomeKitSwitch."""
+
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from tests.components.homekit_controller.common import setup_test_component
+def create_switch_service(accessory):
+ """Define outlet characteristics."""
+ service = accessory.add_service(ServicesTypes.OUTLET)
+
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = False
+
+ outlet_in_use = service.add_char(CharacteristicsTypes.OUTLET_IN_USE)
+ outlet_in_use.value = False
+
+
async def test_switch_change_outlet_state(hass, utcnow):
"""Test that we can turn a HomeKit outlet on and off again."""
- from homekit.model.services import OutletService
-
- helper = await setup_test_component(hass, [OutletService()])
+ helper = await setup_test_component(hass, create_switch_service)
await hass.services.async_call(
"switch", "turn_on", {"entity_id": "switch.testdevice"}, blocking=True
@@ -21,9 +34,7 @@ async def test_switch_change_outlet_state(hass, utcnow):
async def test_switch_read_outlet_state(hass, utcnow):
"""Test that we can read the state of a HomeKit outlet accessory."""
- from homekit.model.services import OutletService
-
- helper = await setup_test_component(hass, [OutletService()])
+ helper = await setup_test_component(hass, create_switch_service)
# Initial state is that the switch is off and the outlet isn't in use
switch_1 = await helper.poll_and_get_state()
diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py
index fa19f573c7c13d..502e9d1b73ebd5 100644
--- a/tests/components/homematicip_cloud/conftest.py
+++ b/tests/components/homematicip_cloud/conftest.py
@@ -1,5 +1,5 @@
"""Initializer helpers for HomematicIP fake server."""
-from asynctest import MagicMock, Mock, patch
+from asynctest import CoroutineMock, MagicMock, Mock
from homematicip.aio.auth import AsyncAuth
from homematicip.aio.connection import AsyncConnection
from homematicip.aio.home import AsyncHome
@@ -9,14 +9,20 @@
from homeassistant.components.homematicip_cloud import (
DOMAIN as HMIPC_DOMAIN,
async_setup as hmip_async_setup,
- const as hmipc,
- hap as hmip_hap,
)
+from homeassistant.components.homematicip_cloud.const import (
+ HMIPC_AUTHTOKEN,
+ HMIPC_HAPID,
+ HMIPC_NAME,
+ HMIPC_PIN,
+)
+from homeassistant.components.homematicip_cloud.hap import HomematicipHAP
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
-from .helper import AUTH_TOKEN, HAPID, HAPPIN, HomeTemplate
+from .helper import AUTH_TOKEN, HAPID, HAPPIN, HomeFactory
-from tests.common import MockConfigEntry, mock_coro
+from tests.common import MockConfigEntry
@pytest.fixture(name="mock_connection")
@@ -30,8 +36,8 @@ def _rest_call_side_effect(path, body=None):
connection._restCall.side_effect = ( # pylint: disable=protected-access
_rest_call_side_effect
)
- connection.api_call.return_value = mock_coro(True)
- connection.init.side_effect = mock_coro(True)
+ connection.api_call = CoroutineMock(return_value=True)
+ connection.init = CoroutineMock(side_effect=True)
return connection
@@ -40,17 +46,18 @@ def _rest_call_side_effect(path, body=None):
def hmip_config_entry_fixture() -> config_entries.ConfigEntry:
"""Create a mock config entriy for homematic ip cloud."""
entry_data = {
- hmipc.HMIPC_HAPID: HAPID,
- hmipc.HMIPC_AUTHTOKEN: AUTH_TOKEN,
- hmipc.HMIPC_NAME: "",
- hmipc.HMIPC_PIN: HAPPIN,
+ HMIPC_HAPID: HAPID,
+ HMIPC_AUTHTOKEN: AUTH_TOKEN,
+ HMIPC_NAME: "",
+ HMIPC_PIN: HAPPIN,
}
config_entry = MockConfigEntry(
version=1,
domain=HMIPC_DOMAIN,
title=HAPID,
+ unique_id=HAPID,
data=entry_data,
- source="import",
+ source=SOURCE_IMPORT,
connection_class=config_entries.CONN_CLASS_CLOUD_PUSH,
system_options={"disable_new_entities": False},
)
@@ -58,44 +65,12 @@ def hmip_config_entry_fixture() -> config_entries.ConfigEntry:
return config_entry
-@pytest.fixture(name="default_mock_home")
-def default_mock_home_fixture(mock_connection) -> AsyncHome:
- """Create a fake homematic async home."""
- return HomeTemplate(connection=mock_connection).init_home().get_async_home_mock()
-
-
-@pytest.fixture(name="default_mock_hap")
-async def default_mock_hap_fixture(
+@pytest.fixture(name="default_mock_hap_factory")
+async def default_mock_hap_factory_fixture(
hass: HomeAssistantType, mock_connection, hmip_config_entry
-) -> hmip_hap.HomematicipHAP:
- """Create a mocked homematic access point."""
- return await get_mock_hap(hass, mock_connection, hmip_config_entry)
-
-
-async def get_mock_hap(
- hass: HomeAssistantType,
- mock_connection,
- hmip_config_entry: config_entries.ConfigEntry,
-) -> hmip_hap.HomematicipHAP:
+) -> HomematicipHAP:
"""Create a mocked homematic access point."""
- hass.config.components.add(HMIPC_DOMAIN)
- hap = hmip_hap.HomematicipHAP(hass, hmip_config_entry)
- home_name = hmip_config_entry.data["name"]
- mock_home = (
- HomeTemplate(connection=mock_connection, home_name=home_name)
- .init_home()
- .get_async_home_mock()
- )
- with patch.object(hap, "get_hap", return_value=mock_coro(mock_home)):
- assert await hap.async_setup()
- mock_home.on_update(hap.async_update)
- mock_home.on_create(hap.async_create_entity)
-
- hass.data[HMIPC_DOMAIN] = {HAPID: hap}
-
- await hass.async_block_till_done()
-
- return hap
+ return HomeFactory(hass, mock_connection, hmip_config_entry)
@pytest.fixture(name="hmip_config")
@@ -103,10 +78,10 @@ def hmip_config_fixture() -> ConfigType:
"""Create a config for homematic ip cloud."""
entry_data = {
- hmipc.HMIPC_HAPID: HAPID,
- hmipc.HMIPC_AUTHTOKEN: AUTH_TOKEN,
- hmipc.HMIPC_NAME: "",
- hmipc.HMIPC_PIN: HAPPIN,
+ HMIPC_HAPID: HAPID,
+ HMIPC_AUTHTOKEN: AUTH_TOKEN,
+ HMIPC_NAME: "",
+ HMIPC_PIN: HAPPIN,
}
return {HMIPC_DOMAIN: [entry_data]}
@@ -120,13 +95,14 @@ def dummy_config_fixture() -> ConfigType:
@pytest.fixture(name="mock_hap_with_service")
async def mock_hap_with_service_fixture(
- hass: HomeAssistantType, default_mock_hap, dummy_config
-) -> hmip_hap.HomematicipHAP:
+ hass: HomeAssistantType, default_mock_hap_factory, dummy_config
+) -> HomematicipHAP:
"""Create a fake homematic access point with hass services."""
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap()
await hmip_async_setup(hass, dummy_config)
await hass.async_block_till_done()
- hass.data[HMIPC_DOMAIN] = {HAPID: default_mock_hap}
- return default_mock_hap
+ hass.data[HMIPC_DOMAIN] = {HAPID: mock_hap}
+ return mock_hap
@pytest.fixture(name="simple_mock_home")
@@ -134,6 +110,7 @@ def simple_mock_home_fixture() -> AsyncHome:
"""Return a simple AsyncHome Mock."""
return Mock(
spec=AsyncHome,
+ name="Demo",
devices=[],
groups=[],
location=Mock(),
diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py
index 42ff2061698c8f..403dbd873bef70 100644
--- a/tests/components/homematicip_cloud/helper.py
+++ b/tests/components/homematicip_cloud/helper.py
@@ -1,7 +1,7 @@
"""Helper for HomematicIP Cloud Tests."""
import json
-from asynctest import Mock
+from asynctest import Mock, patch
from homematicip.aio.class_maps import (
TYPE_CLASS_MAP,
TYPE_GROUP_MAP,
@@ -12,10 +12,15 @@
from homematicip.aio.home import AsyncHome
from homematicip.home import Home
+from homeassistant import config_entries
+from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
from homeassistant.components.homematicip_cloud.device import (
ATTR_IS_GROUP,
ATTR_MODEL_TYPE,
)
+from homeassistant.components.homematicip_cloud.hap import HomematicipHAP
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.setup import async_setup_component
from tests.common import load_fixture
@@ -23,11 +28,10 @@
HAPPIN = "5678"
AUTH_TOKEN = "1234"
HOME_JSON = "homematicip_cloud.json"
+FIXTURE_DATA = load_fixture(HOME_JSON)
-def get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
-):
+def get_and_check_entity_basics(hass, mock_hap, entity_id, entity_name, device_model):
"""Get and test basic device."""
ha_state = hass.states.get(entity_id)
assert ha_state is not None
@@ -35,13 +39,13 @@ def get_and_check_entity_basics(
assert ha_state.attributes[ATTR_MODEL_TYPE] == device_model
assert ha_state.name == entity_name
- hmip_device = default_mock_hap.hmip_device_by_entity_id.get(entity_id)
+ hmip_device = mock_hap.hmip_device_by_entity_id.get(entity_id)
if hmip_device:
if isinstance(hmip_device, AsyncDevice):
assert ha_state.attributes[ATTR_IS_GROUP] is False
elif isinstance(hmip_device, AsyncGroup):
- assert ha_state.attributes[ATTR_IS_GROUP] is True
+ assert ha_state.attributes[ATTR_IS_GROUP]
return ha_state, hmip_device
@@ -67,6 +71,51 @@ async def async_manipulate_test_data(
await hass.async_block_till_done()
+class HomeFactory:
+ """Factory to create a HomematicIP Cloud Home."""
+
+ def __init__(
+ self,
+ hass: HomeAssistantType,
+ mock_connection,
+ hmip_config_entry: config_entries.ConfigEntry,
+ ):
+ """Initialize the Factory."""
+ self.hass = hass
+ self.mock_connection = mock_connection
+ self.hmip_config_entry = hmip_config_entry
+
+ async def async_get_mock_hap(
+ self, test_devices=[], test_groups=[]
+ ) -> HomematicipHAP:
+ """Create a mocked homematic access point."""
+ home_name = self.hmip_config_entry.data["name"]
+ mock_home = (
+ HomeTemplate(
+ connection=self.mock_connection,
+ home_name=home_name,
+ test_devices=test_devices,
+ test_groups=test_groups,
+ )
+ .init_home()
+ .get_async_home_mock()
+ )
+
+ self.hmip_config_entry.add_to_hass(self.hass)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.get_hap",
+ return_value=mock_home,
+ ):
+ assert await async_setup_component(self.hass, HMIPC_DOMAIN, {})
+
+ await self.hass.async_block_till_done()
+
+ hap = self.hass.data[HMIPC_DOMAIN][HAPID]
+ mock_home.on_update(hap.async_update)
+ mock_home.on_create(hap.async_create_entity)
+ return hap
+
+
class HomeTemplate(Home):
"""
Home template as builder for home mock.
@@ -84,17 +133,36 @@ class HomeTemplate(Home):
_typeGroupMap = TYPE_GROUP_MAP
_typeSecurityEventMap = TYPE_SECURITY_EVENT_MAP
- def __init__(self, connection=None, home_name=""):
+ def __init__(self, connection=None, home_name="", test_devices=[], test_groups=[]):
"""Init template with connection."""
super().__init__(connection=connection)
self.label = "Access Point"
self.name = home_name
self.model_type = "HmIP-HAP"
self.init_json_state = None
-
- def init_home(self, json_path=HOME_JSON):
+ self.test_devices = test_devices
+ self.test_groups = test_groups
+
+ def _cleanup_json(self, json):
+ if self.test_devices is not None:
+ new_devices = {}
+ for json_device in json["devices"].items():
+ if json_device[1]["label"] in self.test_devices:
+ new_devices.update([json_device])
+ json["devices"] = new_devices
+
+ if self.test_groups is not None:
+ new_groups = {}
+ for json_group in json["groups"].items():
+ if json_group[1]["label"] in self.test_groups:
+ new_groups.update([json_group])
+ json["groups"] = new_groups
+
+ return json
+
+ def init_home(self):
"""Init template with json."""
- self.init_json_state = json.loads(load_fixture(HOME_JSON))
+ self.init_json_state = self._cleanup_json(json.loads(FIXTURE_DATA))
self.update_home(json_state=self.init_json_state, clearConfig=True)
return self
diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py
index cf85e805143dfc..23e5beb40eb7ae 100644
--- a/tests/components/homematicip_cloud/test_alarm_control_panel.py
+++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py
@@ -31,37 +31,41 @@ async def _async_manipulate_security_zones(
internal_zone = home.search_group_by_id(internal_zone_id)
internal_zone.active = internal_active
+ home.from_json(json)
+ home._get_functionalHomes(json)
+ home._load_functionalChannels()
home.fire_update_event(json)
await hass.async_block_till_done()
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass,
- ALARM_CONTROL_PANEL_DOMAIN,
- {ALARM_CONTROL_PANEL_DOMAIN: {"platform": HMIPC_DOMAIN}},
- )
- is True
+ assert await async_setup_component(
+ hass,
+ ALARM_CONTROL_PANEL_DOMAIN,
+ {ALARM_CONTROL_PANEL_DOMAIN: {"platform": HMIPC_DOMAIN}},
)
+
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_alarm_control_panel(hass, default_mock_hap):
+async def test_hmip_alarm_control_panel(hass, default_mock_hap_factory):
"""Test HomematicipAlarmControlPanel."""
entity_id = "alarm_control_panel.hmip_alarm_control_panel"
entity_name = "HmIP Alarm Control Panel"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_groups=["EXTERNAL", "INTERNAL"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "disarmed"
assert not hmip_device
- home = default_mock_hap.home
+ home = mock_hap.home
await hass.services.async_call(
"alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True
diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py
index 1cfe06ff70133f..a66dd6d49ea35d 100644
--- a/tests/components/homematicip_cloud/test_binary_sensor.py
+++ b/tests/components/homematicip_cloud/test_binary_sensor.py
@@ -30,25 +30,23 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass,
- BINARY_SENSOR_DOMAIN,
- {BINARY_SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}},
- )
- is True
+ assert await async_setup_component(
+ hass, BINARY_SENSOR_DOMAIN, {BINARY_SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}},
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_acceleration_sensor(hass, default_mock_hap):
+async def test_hmip_acceleration_sensor(hass, default_mock_hap_factory):
"""Test HomematicipAccelerationSensor."""
entity_id = "binary_sensor.garagentor"
entity_name = "Garagentor"
device_model = "HmIP-SAM"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -75,14 +73,17 @@ async def test_hmip_acceleration_sensor(hass, default_mock_hap):
assert len(hmip_device.mock_calls) == service_call_counter + 2
-async def test_hmip_contact_interface(hass, default_mock_hap):
+async def test_hmip_contact_interface(hass, default_mock_hap_factory):
"""Test HomematicipContactInterface."""
entity_id = "binary_sensor.kontakt_schnittstelle_unterputz_1_fach"
entity_name = "Kontakt-Schnittstelle Unterputz – 1-fach"
device_model = "HmIP-FCI1"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -95,14 +96,17 @@ async def test_hmip_contact_interface(hass, default_mock_hap):
assert ha_state.state == STATE_OFF
-async def test_hmip_shutter_contact(hass, default_mock_hap):
+async def test_hmip_shutter_contact(hass, default_mock_hap_factory):
"""Test HomematicipShutterContact."""
entity_id = "binary_sensor.fenstergriffsensor"
entity_name = "Fenstergriffsensor"
device_model = "HmIP-SRH"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -124,14 +128,17 @@ async def test_hmip_shutter_contact(hass, default_mock_hap):
assert ha_state.attributes[ATTR_SABOTAGE]
-async def test_hmip_motion_detector(hass, default_mock_hap):
+async def test_hmip_motion_detector(hass, default_mock_hap_factory):
"""Test HomematicipMotionDetector."""
entity_id = "binary_sensor.bewegungsmelder_fur_55er_rahmen_innen"
entity_name = "Bewegungsmelder für 55er Rahmen – innen"
device_model = "HmIP-SMI55"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -140,14 +147,17 @@ async def test_hmip_motion_detector(hass, default_mock_hap):
assert ha_state.state == STATE_ON
-async def test_hmip_presence_detector(hass, default_mock_hap):
+async def test_hmip_presence_detector(hass, default_mock_hap_factory):
"""Test HomematicipPresenceDetector."""
entity_id = "binary_sensor.spi_1"
entity_name = "SPI_1"
device_model = "HmIP-SPI"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -161,14 +171,19 @@ async def test_hmip_presence_detector(hass, default_mock_hap):
assert ha_state.attributes[ATTR_EVENT_DELAY]
-async def test_hmip_pluggable_mains_failure_surveillance_sensor(hass, default_mock_hap):
+async def test_hmip_pluggable_mains_failure_surveillance_sensor(
+ hass, default_mock_hap_factory
+):
"""Test HomematicipPresenceDetector."""
- entity_id = "binary_sensor.netzausfall"
- entity_name = "Netzausfall"
+ entity_id = "binary_sensor.netzausfalluberwachung"
+ entity_name = "Netzausfallüberwachung"
device_model = "HmIP-PMFS"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -177,14 +192,17 @@ async def test_hmip_pluggable_mains_failure_surveillance_sensor(hass, default_mo
assert ha_state.state == STATE_OFF
-async def test_hmip_smoke_detector(hass, default_mock_hap):
+async def test_hmip_smoke_detector(hass, default_mock_hap_factory):
"""Test HomematicipSmokeDetector."""
entity_id = "binary_sensor.rauchwarnmelder"
entity_name = "Rauchwarnmelder"
device_model = "HmIP-SWSD"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -196,16 +214,24 @@ async def test_hmip_smoke_detector(hass, default_mock_hap):
)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_ON
+ await async_manipulate_test_data(
+ hass, hmip_device, "smokeDetectorAlarmType", None,
+ )
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OFF
-async def test_hmip_water_detector(hass, default_mock_hap):
+async def test_hmip_water_detector(hass, default_mock_hap_factory):
"""Test HomematicipWaterDetector."""
entity_id = "binary_sensor.wassersensor"
entity_name = "Wassersensor"
device_model = "HmIP-SWD"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -230,14 +256,17 @@ async def test_hmip_water_detector(hass, default_mock_hap):
assert ha_state.state == STATE_OFF
-async def test_hmip_storm_sensor(hass, default_mock_hap):
+async def test_hmip_storm_sensor(hass, default_mock_hap_factory):
"""Test HomematicipStormSensor."""
entity_id = "binary_sensor.weather_sensor_plus_storm"
entity_name = "Weather Sensor – plus Storm"
device_model = "HmIP-SWO-PL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Weather Sensor – plus"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -246,14 +275,17 @@ async def test_hmip_storm_sensor(hass, default_mock_hap):
assert ha_state.state == STATE_ON
-async def test_hmip_rain_sensor(hass, default_mock_hap):
+async def test_hmip_rain_sensor(hass, default_mock_hap_factory):
"""Test HomematicipRainSensor."""
entity_id = "binary_sensor.wettersensor_pro_raining"
entity_name = "Wettersensor - pro Raining"
device_model = "HmIP-SWO-PR"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wettersensor - pro"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -262,14 +294,17 @@ async def test_hmip_rain_sensor(hass, default_mock_hap):
assert ha_state.state == STATE_ON
-async def test_hmip_sunshine_sensor(hass, default_mock_hap):
+async def test_hmip_sunshine_sensor(hass, default_mock_hap_factory):
"""Test HomematicipSunshineSensor."""
entity_id = "binary_sensor.wettersensor_pro_sunshine"
entity_name = "Wettersensor - pro Sunshine"
device_model = "HmIP-SWO-PR"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wettersensor - pro"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -279,14 +314,17 @@ async def test_hmip_sunshine_sensor(hass, default_mock_hap):
assert ha_state.state == STATE_OFF
-async def test_hmip_battery_sensor(hass, default_mock_hap):
+async def test_hmip_battery_sensor(hass, default_mock_hap_factory):
"""Test HomematicipSunshineSensor."""
entity_id = "binary_sensor.wohnungsture_battery"
entity_name = "Wohnungstüre Battery"
device_model = "HMIP-SWDO"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wohnungstüre"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -295,14 +333,17 @@ async def test_hmip_battery_sensor(hass, default_mock_hap):
assert ha_state.state == STATE_ON
-async def test_hmip_security_zone_sensor_group(hass, default_mock_hap):
+async def test_hmip_security_zone_sensor_group(hass, default_mock_hap_factory):
"""Test HomematicipSecurityZoneSensorGroup."""
entity_id = "binary_sensor.internal_securityzone"
entity_name = "INTERNAL SecurityZone"
device_model = "HmIP-SecurityZone"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_groups=["INTERNAL"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -327,14 +368,15 @@ async def test_hmip_security_zone_sensor_group(hass, default_mock_hap):
assert ha_state.attributes[ATTR_WINDOW_STATE] == WindowState.OPEN
-async def test_hmip_security_sensor_group(hass, default_mock_hap):
+async def test_hmip_security_sensor_group(hass, default_mock_hap_factory):
"""Test HomematicipSecuritySensorGroup."""
entity_id = "binary_sensor.buro_sensors"
entity_name = "Büro Sensors"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Büro"])
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
await async_manipulate_test_data(
diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py
index db0529294748cd..52ca13aad62ff4 100644
--- a/tests/components/homematicip_cloud/test_climate.py
+++ b/tests/components/homematicip_cloud/test_climate.py
@@ -19,6 +19,7 @@
PRESET_AWAY,
PRESET_BOOST,
PRESET_ECO,
+ PRESET_NONE,
)
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
from homeassistant.components.homematicip_cloud.climate import (
@@ -32,23 +33,24 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: {"platform": HMIPC_DOMAIN}}
- )
- is True
+ assert await async_setup_component(
+ hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: {"platform": HMIPC_DOMAIN}}
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_heating_group_heat(hass, default_mock_hap):
+async def test_hmip_heating_group_heat(hass, default_mock_hap_factory):
"""Test HomematicipHeatingGroup."""
entity_id = "climate.badezimmer"
entity_name = "Badezimmer"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wandthermostat", "Heizkörperthermostat3"],
+ test_groups=[entity_name],
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == HVAC_MODE_AUTO
@@ -142,7 +144,7 @@ async def test_hmip_heating_group_heat(hass, default_mock_hap):
await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO")
await async_manipulate_test_data(
hass,
- default_mock_hap.home.get_functionalHome(IndoorClimateHome),
+ mock_hap.home.get_functionalHome(IndoorClimateHome),
"absenceType",
AbsenceType.VACATION,
fire_device=hmip_device,
@@ -153,7 +155,7 @@ async def test_hmip_heating_group_heat(hass, default_mock_hap):
await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO")
await async_manipulate_test_data(
hass,
- default_mock_hap.home.get_functionalHome(IndoorClimateHome),
+ mock_hap.home.get_functionalHome(IndoorClimateHome),
"absenceType",
AbsenceType.PERIOD,
fire_device=hmip_device,
@@ -172,7 +174,7 @@ async def test_hmip_heating_group_heat(hass, default_mock_hap):
assert hmip_device.mock_calls[-1][0] == "set_active_profile"
assert hmip_device.mock_calls[-1][1] == (1,)
- default_mock_hap.home.get_functionalHome(
+ mock_hap.home.get_functionalHome(
IndoorClimateHome
).absenceType = AbsenceType.PERMANENT
await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO")
@@ -230,14 +232,17 @@ async def test_hmip_heating_group_heat(hass, default_mock_hap):
assert ha_state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
-async def test_hmip_heating_group_cool(hass, default_mock_hap):
+async def test_hmip_heating_group_cool(hass, default_mock_hap_factory):
"""Test HomematicipHeatingGroup."""
entity_id = "climate.badezimmer"
entity_name = "Badezimmer"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_groups=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
hmip_device.activeProfile = hmip_device.profiles[3]
@@ -347,14 +352,17 @@ async def test_hmip_heating_group_cool(hass, default_mock_hap):
assert hmip_device.mock_calls[-1][1] == (4,)
-async def test_hmip_heating_group_heat_with_switch(hass, default_mock_hap):
+async def test_hmip_heating_group_heat_with_switch(hass, default_mock_hap_factory):
"""Test HomematicipHeatingGroup."""
entity_id = "climate.schlafzimmer"
entity_name = "Schlafzimmer"
device_model = None
-
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wandthermostat", "Heizkörperthermostat", "Pc"],
+ test_groups=[entity_name],
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert hmip_device
@@ -368,6 +376,28 @@ async def test_hmip_heating_group_heat_with_switch(hass, default_mock_hap):
assert ha_state.attributes[ATTR_PRESET_MODES] == [PRESET_BOOST, "STD", "P2"]
+async def test_hmip_heating_group_heat_with_radiator(hass, default_mock_hap_factory):
+ """Test HomematicipHeatingGroup."""
+ entity_id = "climate.vorzimmer"
+ entity_name = "Vorzimmer"
+ device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Heizkörperthermostat2"], test_groups=[entity_name],
+ )
+ ha_state, hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
+
+ assert hmip_device
+ assert ha_state.state == HVAC_MODE_AUTO
+ assert ha_state.attributes["current_temperature"] == 20
+ assert ha_state.attributes["min_temp"] == 5.0
+ assert ha_state.attributes["max_temp"] == 30.0
+ assert ha_state.attributes["temperature"] == 5.0
+ assert ha_state.attributes[ATTR_PRESET_MODE] is None
+ assert ha_state.attributes[ATTR_PRESET_MODES] == [PRESET_NONE, PRESET_BOOST]
+
+
async def test_hmip_climate_services(hass, mock_hap_with_service):
"""Test HomematicipHeatingGroup."""
@@ -480,14 +510,17 @@ async def test_hmip_climate_services(hass, mock_hap_with_service):
assert len(home._connection.mock_calls) == 10 # pylint: disable=protected-access
-async def test_hmip_heating_group_services(hass, mock_hap_with_service):
+async def test_hmip_heating_group_services(hass, default_mock_hap_factory):
"""Test HomematicipHeatingGroup services."""
entity_id = "climate.badezimmer"
entity_name = "Badezimmer"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_groups=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, mock_hap_with_service, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state
@@ -512,6 +545,5 @@ async def test_hmip_heating_group_services(hass, mock_hap_with_service):
assert hmip_device.mock_calls[-1][0] == "set_active_profile"
assert hmip_device.mock_calls[-1][1] == (1,)
assert (
- len(hmip_device._connection.mock_calls) # pylint: disable=protected-access
- == 12
+ len(hmip_device._connection.mock_calls) == 4 # pylint: disable=protected-access
)
diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py
index afaf71c67b5b31..01e820e7565f22 100644
--- a/tests/components/homematicip_cloud/test_config_flow.py
+++ b/tests/components/homematicip_cloud/test_config_flow.py
@@ -1,167 +1,180 @@
"""Tests for HomematicIP Cloud config flow."""
-from unittest.mock import patch
+from asynctest import patch
-from homeassistant.components.homematicip_cloud import config_flow, const, hap as hmipc
+from homeassistant.components.homematicip_cloud.const import (
+ DOMAIN as HMIPC_DOMAIN,
+ HMIPC_AUTHTOKEN,
+ HMIPC_HAPID,
+ HMIPC_NAME,
+ HMIPC_PIN,
+)
-from tests.common import MockConfigEntry, mock_coro
+from tests.common import MockConfigEntry
+
+DEFAULT_CONFIG = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"}
+
+IMPORT_CONFIG = {HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"}
async def test_flow_works(hass):
- """Test config flow works."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- hap = hmipc.HomematicipAuth(hass, config)
- with patch.object(hap, "get_auth", return_value=mock_coro()), patch.object(
- hmipc.HomematicipAuth, "async_checkbutton", return_value=mock_coro(True)
- ), patch.object(
- hmipc.HomematicipAuth, "async_setup", return_value=mock_coro(True)
- ), patch.object(
- hmipc.HomematicipAuth, "async_register", return_value=mock_coro(True)
+ """Test config flow."""
+
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=False,
):
- hap.authtoken = "ABC"
- result = await flow.async_step_init(user_input=config)
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG
+ )
- assert hap.authtoken == "ABC"
- assert result["type"] == "create_entry"
+ assert result["type"] == "form"
+ assert result["step_id"] == "link"
+ assert result["errors"] == {"base": "press_the_button"}
+
+ flow = next(
+ (
+ flow
+ for flow in hass.config_entries.flow.async_progress()
+ if flow["flow_id"] == result["flow_id"]
+ )
+ )
+ assert flow["context"]["unique_id"] == "ABC123"
+
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_register",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "ABC123"
+ assert result["data"] == {"hapid": "ABC123", "authtoken": True, "name": "hmip"}
+ assert result["result"].unique_id == "ABC123"
async def test_flow_init_connection_error(hass):
"""Test config flow with accesspoint connection error."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- with patch.object(
- hmipc.HomematicipAuth, "async_setup", return_value=mock_coro(False)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_setup",
+ return_value=False,
):
- result = await flow.async_step_init(user_input=config)
- assert result["type"] == "form"
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "init"
async def test_flow_link_connection_error(hass):
"""Test config flow client registration connection error."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- with patch.object(
- hmipc.HomematicipAuth, "async_setup", return_value=mock_coro(True)
- ), patch.object(
- hmipc.HomematicipAuth, "async_checkbutton", return_value=mock_coro(True)
- ), patch.object(
- hmipc.HomematicipAuth, "async_register", return_value=mock_coro(False)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_register",
+ return_value=False,
):
- result = await flow.async_step_init(user_input=config)
- assert result["type"] == "abort"
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "connection_aborted"
async def test_flow_link_press_button(hass):
"""Test config flow ask for pressing the blue button."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- with patch.object(
- hmipc.HomematicipAuth, "async_setup", return_value=mock_coro(True)
- ), patch.object(
- hmipc.HomematicipAuth, "async_checkbutton", return_value=mock_coro(False)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=False,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_setup",
+ return_value=True,
):
- result = await flow.async_step_init(user_input=config)
- assert result["type"] == "form"
- assert result["errors"] == {"base": "press_the_button"}
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG
+ )
-
-async def test_init_flow_show_form(hass):
- """Test config flow shows up with a form."""
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_init(user_input=None)
assert result["type"] == "form"
+ assert result["step_id"] == "link"
+ assert result["errors"] == {"base": "press_the_button"}
-async def test_init_flow_user_show_form(hass):
+async def test_init_flow_show_form(hass):
"""Test config flow shows up with a form."""
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
- result = await flow.async_step_user(user_input=None)
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "user"}
+ )
assert result["type"] == "form"
+ assert result["step_id"] == "init"
async def test_init_already_configured(hass):
"""Test accesspoint is already configured."""
- MockConfigEntry(
- domain=const.DOMAIN, data={const.HMIPC_HAPID: "ABC123"}
- ).add_to_hass(hass)
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
-
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_init(user_input=config)
+ MockConfigEntry(domain=HMIPC_DOMAIN, unique_id="ABC123").add_to_hass(hass)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG
+ )
+
assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
async def test_import_config(hass):
"""Test importing a host with an existing config file."""
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_import(
- {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
- )
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_register",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "import"}, data=IMPORT_CONFIG
+ )
assert result["type"] == "create_entry"
assert result["title"] == "ABC123"
- assert result["data"] == {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
+ assert result["data"] == {"authtoken": "123", "hapid": "ABC123", "name": "hmip"}
+ assert result["result"].unique_id == "ABC123"
async def test_import_existing_config(hass):
"""Test abort of an existing accesspoint from config."""
- flow = config_flow.HomematicipCloudFlowHandler()
- flow.hass = hass
-
- MockConfigEntry(
- domain=const.DOMAIN, data={hmipc.HMIPC_HAPID: "ABC123"}
- ).add_to_hass(hass)
-
- result = await flow.async_step_import(
- {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
- )
+ MockConfigEntry(domain=HMIPC_DOMAIN, unique_id="ABC123").add_to_hass(hass)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_register",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ HMIPC_DOMAIN, context={"source": "import"}, data=IMPORT_CONFIG
+ )
assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py
index 5b267628ae39dd..7da1a94bdd79cb 100644
--- a/tests/components/homematicip_cloud/test_cover.py
+++ b/tests/components/homematicip_cloud/test_cover.py
@@ -15,28 +15,27 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass, COVER_DOMAIN, {COVER_DOMAIN: {"platform": HMIPC_DOMAIN}}
- )
- is True
+ assert await async_setup_component(
+ hass, COVER_DOMAIN, {COVER_DOMAIN: {"platform": HMIPC_DOMAIN}}
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_cover_shutter(hass, default_mock_hap):
+async def test_hmip_cover_shutter(hass, default_mock_hap_factory):
"""Test HomematicipCoverShutte."""
- entity_id = "cover.sofa_links"
- entity_name = "Sofa links"
- device_model = "HmIP-FBL"
+ entity_id = "cover.broll_1"
+ entity_name = "BROLL_1"
+ device_model = "HmIP-BROLL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "closed"
assert ha_state.attributes["current_position"] == 0
- assert ha_state.attributes["current_tilt_position"] == 0
service_call_counter = len(hmip_device.mock_calls)
await hass.services.async_call(
@@ -49,7 +48,6 @@ async def test_hmip_cover_shutter(hass, default_mock_hap):
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_OPEN
assert ha_state.attributes[ATTR_CURRENT_POSITION] == 100
- assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0
await hass.services.async_call(
"cover",
@@ -64,7 +62,6 @@ async def test_hmip_cover_shutter(hass, default_mock_hap):
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_OPEN
assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50
- assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0
await hass.services.async_call(
"cover", "close_cover", {"entity_id": entity_id}, blocking=True
@@ -76,7 +73,6 @@ async def test_hmip_cover_shutter(hass, default_mock_hap):
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_CLOSED
assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0
- assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0
await hass.services.async_call(
"cover", "stop_cover", {"entity_id": entity_id}, blocking=True
@@ -90,14 +86,17 @@ async def test_hmip_cover_shutter(hass, default_mock_hap):
assert ha_state.state == STATE_UNKNOWN
-async def test_hmip_cover_slats(hass, default_mock_hap):
+async def test_hmip_cover_slats(hass, default_mock_hap_factory):
"""Test HomematicipCoverSlats."""
entity_id = "cover.sofa_links"
entity_name = "Sofa links"
device_model = "HmIP-FBL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_CLOSED
@@ -152,19 +151,26 @@ async def test_hmip_cover_slats(hass, default_mock_hap):
assert hmip_device.mock_calls[-1][0] == "set_shutter_stop"
assert hmip_device.mock_calls[-1][1] == ()
+ await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None)
+ ha_state = hass.states.get(entity_id)
+ assert not ha_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
+
await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_UNKNOWN
-async def test_hmip_garage_door_tormatic(hass, default_mock_hap):
+async def test_hmip_garage_door_tormatic(hass, default_mock_hap_factory):
"""Test HomematicipCoverShutte."""
entity_id = "cover.garage_door_module"
entity_name = "Garage Door Module"
device_model = "HmIP-MOD-TM"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "closed"
@@ -199,3 +205,139 @@ async def test_hmip_garage_door_tormatic(hass, default_mock_hap):
assert len(hmip_device.mock_calls) == service_call_counter + 5
assert hmip_device.mock_calls[-1][0] == "send_door_command"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,)
+
+
+async def test_hmip_cover_shutter_group(hass, default_mock_hap_factory):
+ """Test HomematicipCoverShutteGroup."""
+ entity_id = "cover.rollos_shuttergroup"
+ entity_name = "Rollos ShutterGroup"
+ device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Rollos"])
+
+ ha_state, hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
+
+ assert ha_state.state == "closed"
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0
+ service_call_counter = len(hmip_device.mock_calls)
+
+ await hass.services.async_call(
+ "cover", "open_cover", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 1
+ assert hmip_device.mock_calls[-1][0] == "set_shutter_level"
+ assert hmip_device.mock_calls[-1][1] == (0,)
+ await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OPEN
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 100
+
+ await hass.services.async_call(
+ "cover",
+ "set_cover_position",
+ {"entity_id": entity_id, "position": "50"},
+ blocking=True,
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 3
+ assert hmip_device.mock_calls[-1][0] == "set_shutter_level"
+ assert hmip_device.mock_calls[-1][1] == (0.5,)
+ await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OPEN
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50
+
+ await hass.services.async_call(
+ "cover", "close_cover", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 5
+ assert hmip_device.mock_calls[-1][0] == "set_shutter_level"
+ assert hmip_device.mock_calls[-1][1] == (1,)
+ await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_CLOSED
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0
+
+ await hass.services.async_call(
+ "cover", "stop_cover", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 7
+ assert hmip_device.mock_calls[-1][0] == "set_shutter_stop"
+ assert hmip_device.mock_calls[-1][1] == ()
+
+ await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_UNKNOWN
+
+
+async def test_hmip_cover_slats_group(hass, default_mock_hap_factory):
+ """Test slats with HomematicipCoverShutteGroup."""
+ entity_id = "cover.rollos_shuttergroup"
+ entity_name = "Rollos ShutterGroup"
+ device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Rollos"])
+
+ ha_state, hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
+ await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1)
+ ha_state = hass.states.get(entity_id)
+
+ assert ha_state.state == STATE_CLOSED
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0
+ assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0
+ service_call_counter = len(hmip_device.mock_calls)
+
+ await hass.services.async_call(
+ "cover",
+ "set_cover_position",
+ {"entity_id": entity_id, "position": "50"},
+ blocking=True,
+ )
+ await hass.services.async_call(
+ "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True
+ )
+
+ assert len(hmip_device.mock_calls) == service_call_counter + 2
+ assert hmip_device.mock_calls[-1][0] == "set_slats_level"
+ assert hmip_device.mock_calls[-1][1] == (0,)
+ await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5)
+ await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OPEN
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50
+ assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 100
+
+ await hass.services.async_call(
+ "cover",
+ "set_cover_tilt_position",
+ {"entity_id": entity_id, "tilt_position": "50"},
+ blocking=True,
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 5
+ assert hmip_device.mock_calls[-1][0] == "set_slats_level"
+ assert hmip_device.mock_calls[-1][1] == (0.5,)
+ await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OPEN
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50
+ assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 50
+
+ await hass.services.async_call(
+ "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 7
+ assert hmip_device.mock_calls[-1][0] == "set_slats_level"
+ assert hmip_device.mock_calls[-1][1] == (1,)
+ await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OPEN
+ assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50
+ assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0
+
+ await hass.services.async_call(
+ "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(hmip_device.mock_calls) == service_call_counter + 9
+ assert hmip_device.mock_calls[-1][0] == "set_shutter_stop"
+ assert hmip_device.mock_calls[-1][1] == ()
diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py
index 9626cc0620f1bd..c678bee5e3263b 100644
--- a/tests/components/homematicip_cloud/test_device.py
+++ b/tests/components/homematicip_cloud/test_device.py
@@ -1,19 +1,40 @@
"""Common tests for HomematicIP devices."""
+from asynctest import patch
+from homematicip.base.enums import EventType
+
+from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
+from homeassistant.components.homematicip_cloud.hap import HomematicipHAP
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from .conftest import get_mock_hap
-from .helper import async_manipulate_test_data, get_and_check_entity_basics
+from .helper import (
+ HAPID,
+ HomeFactory,
+ async_manipulate_test_data,
+ get_and_check_entity_basics,
+)
+
+
+async def test_hmip_load_all_supported_devices(hass, default_mock_hap_factory):
+ """Ensure that all supported devices could be loaded."""
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=None, test_groups=None
+ )
+
+ assert len(mock_hap.hmip_device_by_entity_id) == 183
-async def test_hmip_remove_device(hass, default_mock_hap):
+async def test_hmip_remove_device(hass, default_mock_hap_factory):
"""Test Remove of hmip device."""
entity_id = "light.treppe"
entity_name = "Treppe"
device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -24,7 +45,7 @@ async def test_hmip_remove_device(hass, default_mock_hap):
pre_device_count = len(device_registry.devices)
pre_entity_count = len(entity_registry.entities)
- pre_mapping_count = len(default_mock_hap.hmip_device_by_entity_id)
+ pre_mapping_count = len(mock_hap.hmip_device_by_entity_id)
hmip_device.fire_remove_event()
@@ -32,17 +53,66 @@ async def test_hmip_remove_device(hass, default_mock_hap):
assert len(device_registry.devices) == pre_device_count - 1
assert len(entity_registry.entities) == pre_entity_count - 3
- assert len(default_mock_hap.hmip_device_by_entity_id) == pre_mapping_count - 3
+ assert len(mock_hap.hmip_device_by_entity_id) == pre_mapping_count - 3
+
+
+async def test_hmip_add_device(hass, default_mock_hap_factory, hmip_config_entry):
+ """Test Remove of hmip device."""
+ entity_id = "light.treppe"
+ entity_name = "Treppe"
+ device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
+ ha_state, hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
-async def test_hmip_remove_group(hass, default_mock_hap):
+ assert ha_state.state == STATE_ON
+ assert hmip_device
+
+ device_registry = await dr.async_get_registry(hass)
+ entity_registry = await er.async_get_registry(hass)
+
+ pre_device_count = len(device_registry.devices)
+ pre_entity_count = len(entity_registry.entities)
+ pre_mapping_count = len(mock_hap.hmip_device_by_entity_id)
+
+ hmip_device.fire_remove_event()
+ await hass.async_block_till_done()
+
+ assert len(device_registry.devices) == pre_device_count - 1
+ assert len(entity_registry.entities) == pre_entity_count - 3
+ assert len(mock_hap.hmip_device_by_entity_id) == pre_mapping_count - 3
+
+ reloaded_hap = HomematicipHAP(hass, hmip_config_entry)
+ with patch(
+ "homeassistant.components.homematicip_cloud.HomematicipHAP",
+ return_value=reloaded_hap,
+ ), patch.object(reloaded_hap, "async_connect"), patch.object(
+ reloaded_hap, "get_hap", return_value=mock_hap.home
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.asyncio.sleep"
+ ):
+ mock_hap.home.fire_create_event(event_type=EventType.DEVICE_ADDED)
+ await hass.async_block_till_done()
+
+ assert len(device_registry.devices) == pre_device_count
+ assert len(entity_registry.entities) == pre_entity_count
+ new_hap = hass.data[HMIPC_DOMAIN][HAPID]
+ assert len(new_hap.hmip_device_by_entity_id) == pre_mapping_count
+
+
+async def test_hmip_remove_group(hass, default_mock_hap_factory):
"""Test Remove of hmip group."""
entity_id = "switch.strom_group"
entity_name = "Strom Group"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Strom"])
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -53,60 +123,67 @@ async def test_hmip_remove_group(hass, default_mock_hap):
pre_device_count = len(device_registry.devices)
pre_entity_count = len(entity_registry.entities)
- pre_mapping_count = len(default_mock_hap.hmip_device_by_entity_id)
+ pre_mapping_count = len(mock_hap.hmip_device_by_entity_id)
hmip_device.fire_remove_event()
-
await hass.async_block_till_done()
assert len(device_registry.devices) == pre_device_count
assert len(entity_registry.entities) == pre_entity_count - 1
- assert len(default_mock_hap.hmip_device_by_entity_id) == pre_mapping_count - 1
+ assert len(mock_hap.hmip_device_by_entity_id) == pre_mapping_count - 1
-async def test_all_devices_unavailable_when_hap_not_connected(hass, default_mock_hap):
+async def test_all_devices_unavailable_when_hap_not_connected(
+ hass, default_mock_hap_factory
+):
"""Test make all devices unavaulable when hap is not connected."""
entity_id = "light.treppe"
entity_name = "Treppe"
device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
assert hmip_device
- assert default_mock_hap.home.connected
+ assert mock_hap.home.connected
- await async_manipulate_test_data(hass, default_mock_hap.home, "connected", False)
+ await async_manipulate_test_data(hass, mock_hap.home, "connected", False)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_UNAVAILABLE
-async def test_hap_reconnected(hass, default_mock_hap):
+async def test_hap_reconnected(hass, default_mock_hap_factory):
"""Test reconnect hap."""
entity_id = "light.treppe"
entity_name = "Treppe"
device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
assert hmip_device
- assert default_mock_hap.home.connected
+ assert mock_hap.home.connected
- await async_manipulate_test_data(hass, default_mock_hap.home, "connected", False)
+ await async_manipulate_test_data(hass, mock_hap.home, "connected", False)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_UNAVAILABLE
- default_mock_hap._accesspoint_connected = False # pylint: disable=protected-access
- await async_manipulate_test_data(hass, default_mock_hap.home, "connected", True)
+ mock_hap._accesspoint_connected = False # pylint: disable=protected-access
+ await async_manipulate_test_data(hass, mock_hap.home, "connected", True)
await hass.async_block_till_done()
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_ON
@@ -120,7 +197,9 @@ async def test_hap_with_name(hass, mock_connection, hmip_config_entry):
device_model = "HmIP-BSL"
hmip_config_entry.data["name"] = home_name
- mock_hap = await get_mock_hap(hass, mock_connection, hmip_config_entry)
+ mock_hap = await HomeFactory(
+ hass, mock_connection, hmip_config_entry
+ ).async_get_mock_hap(test_devices=["Treppe"])
assert mock_hap
ha_state, hmip_device = get_and_check_entity_basics(
@@ -132,14 +211,17 @@ async def test_hap_with_name(hass, mock_connection, hmip_config_entry):
assert ha_state.attributes["friendly_name"] == entity_name
-async def test_hmip_reset_energy_counter_services(hass, mock_hap_with_service):
+async def test_hmip_reset_energy_counter_services(hass, default_mock_hap_factory):
"""Test reset_energy_counter service."""
entity_id = "switch.pc"
entity_name = "Pc"
device_model = "HMIP-PSM"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, mock_hap_with_service, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state
@@ -156,4 +238,4 @@ async def test_hmip_reset_energy_counter_services(hass, mock_hap_with_service):
"homematicip_cloud", "reset_energy_counter", {"entity_id": "all"}, blocking=True
)
assert hmip_device.mock_calls[-1][0] == "reset_energy_counter"
- assert len(hmip_device._connection.mock_calls) == 12 # pylint: disable=W0212
+ assert len(hmip_device._connection.mock_calls) == 4 # pylint: disable=W0212
diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py
index 324649ef515046..1dd5b2fc789700 100644
--- a/tests/components/homematicip_cloud/test_hap.py
+++ b/tests/components/homematicip_cloud/test_hap.py
@@ -5,95 +5,79 @@
from homematicip.base.base_connection import HmipConnectionError
import pytest
-from homeassistant.components.homematicip_cloud import (
- DOMAIN as HMIPC_DOMAIN,
- const,
- errors,
- hap as hmipc,
+from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
+from homeassistant.components.homematicip_cloud.const import (
+ HMIPC_AUTHTOKEN,
+ HMIPC_HAPID,
+ HMIPC_NAME,
+ HMIPC_PIN,
)
+from homeassistant.components.homematicip_cloud.errors import HmipcConnectionError
from homeassistant.components.homematicip_cloud.hap import (
HomematicipAuth,
HomematicipHAP,
)
+from homeassistant.config_entries import ENTRY_STATE_NOT_LOADED
from homeassistant.exceptions import ConfigEntryNotReady
from .helper import HAPID, HAPPIN
-from tests.common import mock_coro, mock_coro_func
-
async def test_auth_setup(hass):
"""Test auth setup for client registration."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipAuth(hass, config)
- with patch.object(hap, "get_auth", return_value=mock_coro()):
- assert await hap.async_setup()
+ config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"}
+ hmip_auth = HomematicipAuth(hass, config)
+ with patch.object(hmip_auth, "get_auth"):
+ assert await hmip_auth.async_setup()
async def test_auth_setup_connection_error(hass):
"""Test auth setup connection error behaviour."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipAuth(hass, config)
- with patch.object(hap, "get_auth", side_effect=errors.HmipcConnectionError):
- assert not await hap.async_setup()
+ config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"}
+ hmip_auth = HomematicipAuth(hass, config)
+ with patch.object(hmip_auth, "get_auth", side_effect=HmipcConnectionError):
+ assert not await hmip_auth.async_setup()
async def test_auth_auth_check_and_register(hass):
"""Test auth client registration."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipAuth(hass, config)
- hap.auth = Mock()
+ config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"}
+
+ hmip_auth = HomematicipAuth(hass, config)
+ hmip_auth.auth = Mock(spec=AsyncAuth)
with patch.object(
- hap.auth, "isRequestAcknowledged", return_value=mock_coro(True)
+ hmip_auth.auth, "isRequestAcknowledged", return_value=True
), patch.object(
- hap.auth, "requestAuthToken", return_value=mock_coro("ABC")
+ hmip_auth.auth, "requestAuthToken", return_value="ABC"
), patch.object(
- hap.auth, "confirmAuthToken", return_value=mock_coro()
+ hmip_auth.auth, "confirmAuthToken"
):
- assert await hap.async_checkbutton()
- assert await hap.async_register() == "ABC"
+ assert await hmip_auth.async_checkbutton()
+ assert await hmip_auth.async_register() == "ABC"
async def test_auth_auth_check_and_register_with_exception(hass):
"""Test auth client registration."""
- config = {
- const.HMIPC_HAPID: "ABC123",
- const.HMIPC_PIN: "123",
- const.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipAuth(hass, config)
- hap.auth = Mock(spec=AsyncAuth)
+ config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"}
+ hmip_auth = HomematicipAuth(hass, config)
+ hmip_auth.auth = Mock(spec=AsyncAuth)
with patch.object(
- hap.auth, "isRequestAcknowledged", side_effect=HmipConnectionError
- ), patch.object(hap.auth, "requestAuthToken", side_effect=HmipConnectionError):
- assert not await hap.async_checkbutton()
- assert await hap.async_register() is False
+ hmip_auth.auth, "isRequestAcknowledged", side_effect=HmipConnectionError
+ ), patch.object(
+ hmip_auth.auth, "requestAuthToken", side_effect=HmipConnectionError
+ ):
+ assert not await hmip_auth.async_checkbutton()
+ assert await hmip_auth.async_register() is False
-async def test_hap_setup_works(aioclient_mock):
+async def test_hap_setup_works():
"""Test a successful setup of a accesspoint."""
hass = Mock()
entry = Mock()
home = Mock()
- entry.data = {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipHAP(hass, entry)
- with patch.object(hap, "get_hap", return_value=mock_coro(home)):
+ entry.data = {HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"}
+ hap = HomematicipHAP(hass, entry)
+ with patch.object(hap, "get_hap", return_value=home):
assert await hap.async_setup()
assert hap.home is home
@@ -112,44 +96,28 @@ async def test_hap_setup_connection_error():
"""Test a failed accesspoint setup."""
hass = Mock()
entry = Mock()
- entry.data = {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipHAP(hass, entry)
- with patch.object(
- hap, "get_hap", side_effect=errors.HmipcConnectionError
- ), pytest.raises(ConfigEntryNotReady):
- await hap.async_setup()
+ entry.data = {HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"}
+ hap = HomematicipHAP(hass, entry)
+ with patch.object(hap, "get_hap", side_effect=HmipcConnectionError), pytest.raises(
+ ConfigEntryNotReady
+ ):
+ assert not await hap.async_setup()
assert not hass.async_add_job.mock_calls
assert not hass.config_entries.flow.async_init.mock_calls
-async def test_hap_reset_unloads_entry_if_setup():
+async def test_hap_reset_unloads_entry_if_setup(hass, default_mock_hap_factory):
"""Test calling reset while the entry has been setup."""
- hass = Mock()
- entry = Mock()
- home = Mock()
- home.disable_events = mock_coro_func()
- entry.data = {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
- hap = hmipc.HomematicipHAP(hass, entry)
- with patch.object(hap, "get_hap", return_value=mock_coro(home)):
- assert await hap.async_setup()
-
- assert hap.home is home
- assert not hass.services.async_register.mock_calls
- assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8
-
- hass.config_entries.async_forward_entry_unload.return_value = mock_coro(True)
- await hap.async_reset()
-
- assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 8
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap()
+ assert hass.data[HMIPC_DOMAIN][HAPID] == mock_hap
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 1
+ # hap_reset is called during unload
+ await hass.config_entries.async_unload(config_entries[0].entry_id)
+ # entry is unloaded
+ assert config_entries[0].state == ENTRY_STATE_NOT_LOADED
+ assert hass.data[HMIPC_DOMAIN] == {}
async def test_hap_create(hass, hmip_config_entry, simple_mock_home):
@@ -160,36 +128,33 @@ async def test_hap_create(hass, hmip_config_entry, simple_mock_home):
with patch(
"homeassistant.components.homematicip_cloud.hap.AsyncHome",
return_value=simple_mock_home,
- ), patch.object(hap, "async_connect", return_value=mock_coro(None)):
+ ), patch.object(hap, "async_connect"):
assert await hap.async_setup()
-async def test_hap_create_exception(hass, hmip_config_entry, simple_mock_home):
+async def test_hap_create_exception(hass, hmip_config_entry):
"""Mock AsyncHome to execute get_hap."""
hass.config.components.add(HMIPC_DOMAIN)
+
hap = HomematicipHAP(hass, hmip_config_entry)
assert hap
- with patch.object(hap, "get_hap", side_effect=HmipConnectionError), pytest.raises(
- HmipConnectionError
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state",
+ side_effect=Exception,
):
- await hap.async_setup()
+ assert not await hap.async_setup()
- simple_mock_home.init.side_effect = HmipConnectionError
with patch(
- "homeassistant.components.homematicip_cloud.hap.AsyncHome",
- return_value=simple_mock_home,
+ "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state",
+ side_effect=HmipConnectionError,
), pytest.raises(ConfigEntryNotReady):
await hap.async_setup()
async def test_auth_create(hass, simple_mock_auth):
"""Mock AsyncAuth to execute get_auth."""
- config = {
- const.HMIPC_HAPID: HAPID,
- const.HMIPC_PIN: HAPPIN,
- const.HMIPC_NAME: "hmip",
- }
+ config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"}
hmip_auth = HomematicipAuth(hass, config)
assert hmip_auth
@@ -204,11 +169,7 @@ async def test_auth_create(hass, simple_mock_auth):
async def test_auth_create_exception(hass, simple_mock_auth):
"""Mock AsyncAuth to execute get_auth."""
- config = {
- const.HMIPC_HAPID: HAPID,
- const.HMIPC_PIN: HAPPIN,
- const.HMIPC_NAME: "hmip",
- }
+ config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"}
hmip_auth = HomematicipAuth(hass, config)
simple_mock_auth.connectionRequest.side_effect = HmipConnectionError
assert hmip_auth
diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py
index eb51c3ece386ff..ef7f5fa24aebcf 100644
--- a/tests/components/homematicip_cloud/test_init.py
+++ b/tests/components/homematicip_cloud/test_init.py
@@ -1,155 +1,147 @@
"""Test HomematicIP Cloud setup process."""
-from unittest.mock import patch
-
-from homeassistant.components import homematicip_cloud as hmipc
+from asynctest import CoroutineMock, Mock, patch
+from homematicip.base.base_connection import HmipConnectionError
+
+from homeassistant.components.homematicip_cloud.const import (
+ CONF_ACCESSPOINT,
+ CONF_AUTHTOKEN,
+ DOMAIN as HMIPC_DOMAIN,
+ HMIPC_AUTHTOKEN,
+ HMIPC_HAPID,
+ HMIPC_NAME,
+)
+from homeassistant.components.homematicip_cloud.hap import HomematicipHAP
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_ERROR,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import CONF_NAME
from homeassistant.setup import async_setup_component
-from tests.common import Mock, MockConfigEntry, mock_coro
+from tests.common import MockConfigEntry
async def test_config_with_accesspoint_passed_to_config_entry(hass):
"""Test that config for a accesspoint are loaded via config entry."""
- with patch.object(hass, "config_entries") as mock_config_entries, patch.object(
- hmipc, "configured_haps", return_value=[]
- ):
- assert (
- await async_setup_component(
- hass,
- hmipc.DOMAIN,
- {
- hmipc.DOMAIN: {
- hmipc.CONF_ACCESSPOINT: "ABC123",
- hmipc.CONF_AUTHTOKEN: "123",
- hmipc.CONF_NAME: "name",
- }
- },
- )
- is True
- )
- # Flow started for the access point
- assert len(mock_config_entries.flow.mock_calls) >= 2
+ entry_config = {
+ CONF_ACCESSPOINT: "ABC123",
+ CONF_AUTHTOKEN: "123",
+ CONF_NAME: "name",
+ }
+ # no config_entry exists
+ assert len(hass.config_entries.async_entries(HMIPC_DOMAIN)) == 0
+ # no acccesspoint exists
+ assert not hass.data.get(HMIPC_DOMAIN)
+
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {HMIPC_DOMAIN: entry_config})
+
+ # config_entry created for access point
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 1
+ assert config_entries[0].data == {
+ "authtoken": "123",
+ "hapid": "ABC123",
+ "name": "name",
+ }
+ # defined access_point created for config_entry
+ assert isinstance(hass.data[HMIPC_DOMAIN]["ABC123"], HomematicipHAP)
async def test_config_already_registered_not_passed_to_config_entry(hass):
"""Test that an already registered accesspoint does not get imported."""
- with patch.object(hass, "config_entries") as mock_config_entries, patch.object(
- hmipc, "configured_haps", return_value=["ABC123"]
- ):
- assert (
- await async_setup_component(
- hass,
- hmipc.DOMAIN,
- {
- hmipc.DOMAIN: {
- hmipc.CONF_ACCESSPOINT: "ABC123",
- hmipc.CONF_AUTHTOKEN: "123",
- hmipc.CONF_NAME: "name",
- }
- },
- )
- is True
- )
- # No flow started
- assert not mock_config_entries.flow.mock_calls
-
-
-async def test_setup_entry_successful(hass):
- """Test setup entry is successful."""
- entry = MockConfigEntry(
- domain=hmipc.DOMAIN,
- data={
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- },
- )
- entry.add_to_hass(hass)
- with patch.object(hmipc, "HomematicipHAP") as mock_hap:
- instance = mock_hap.return_value
- instance.async_setup.return_value = mock_coro(True)
- instance.home.id = "1"
- instance.home.modelType = "mock-type"
- instance.home.name = "mock-name"
- instance.home.currentAPVersion = "mock-ap-version"
+ mock_config = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC123", HMIPC_NAME: "name"}
+ MockConfigEntry(domain=HMIPC_DOMAIN, data=mock_config).add_to_hass(hass)
- assert (
- await async_setup_component(
- hass,
- hmipc.DOMAIN,
- {
- hmipc.DOMAIN: {
- hmipc.CONF_ACCESSPOINT: "ABC123",
- hmipc.CONF_AUTHTOKEN: "123",
- hmipc.CONF_NAME: "hmip",
- }
- },
- )
- is True
- )
+ # one config_entry exists
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 1
+ assert config_entries[0].data == {
+ "authtoken": "123",
+ "hapid": "ABC123",
+ "name": "name",
+ }
+ # config_enty has no unique_id
+ assert not config_entries[0].unique_id
+
+ entry_config = {
+ CONF_ACCESSPOINT: "ABC123",
+ CONF_AUTHTOKEN: "123",
+ CONF_NAME: "name",
+ }
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {HMIPC_DOMAIN: entry_config})
+
+ # no new config_entry created / still one config_entry
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 1
+ assert config_entries[0].data == {
+ "authtoken": "123",
+ "hapid": "ABC123",
+ "name": "name",
+ }
+ # config_enty updated with unique_id
+ assert config_entries[0].unique_id == "ABC123"
- assert len(mock_hap.mock_calls) >= 2
+async def test_load_entry_fails_due_to_connection_error(hass, hmip_config_entry):
+ """Test load entry fails due to connection error."""
+ hmip_config_entry.add_to_hass(hass)
-async def test_setup_defined_accesspoint(hass):
- """Test we initiate config entry for the accesspoint."""
- with patch.object(hass, "config_entries") as mock_config_entries, patch.object(
- hmipc, "configured_haps", return_value=[]
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state",
+ side_effect=HmipConnectionError,
):
- mock_config_entries.flow.async_init.return_value = mock_coro()
- assert (
- await async_setup_component(
- hass,
- hmipc.DOMAIN,
- {
- hmipc.DOMAIN: {
- hmipc.CONF_ACCESSPOINT: "ABC123",
- hmipc.CONF_AUTHTOKEN: "123",
- hmipc.CONF_NAME: "hmip",
- }
- },
- )
- is True
- )
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {})
+
+ assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id]
+ assert hmip_config_entry.state == ENTRY_STATE_SETUP_RETRY
- assert len(mock_config_entries.flow.mock_calls) == 1
- assert mock_config_entries.flow.mock_calls[0][2]["data"] == {
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- }
+
+async def test_load_entry_fails_due_to_generic_exception(hass, hmip_config_entry):
+ """Test load entry fails due to generic exception."""
+ hmip_config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state",
+ side_effect=Exception,
+ ):
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {})
+
+ assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id]
+ assert hmip_config_entry.state == ENTRY_STATE_SETUP_ERROR
async def test_unload_entry(hass):
"""Test being able to unload an entry."""
- entry = MockConfigEntry(
- domain=hmipc.DOMAIN,
- data={
- hmipc.HMIPC_HAPID: "ABC123",
- hmipc.HMIPC_AUTHTOKEN: "123",
- hmipc.HMIPC_NAME: "hmip",
- },
- )
- entry.add_to_hass(hass)
-
- with patch.object(hmipc, "HomematicipHAP") as mock_hap:
+ mock_config = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC123", HMIPC_NAME: "name"}
+ MockConfigEntry(domain=HMIPC_DOMAIN, data=mock_config).add_to_hass(hass)
+
+ with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap:
instance = mock_hap.return_value
- instance.async_setup.return_value = mock_coro(True)
+ instance.async_setup = CoroutineMock(return_value=True)
instance.home.id = "1"
instance.home.modelType = "mock-type"
instance.home.name = "mock-name"
instance.home.currentAPVersion = "mock-ap-version"
+ instance.async_reset = CoroutineMock(return_value=True)
- assert await async_setup_component(hass, hmipc.DOMAIN, {}) is True
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {})
- assert len(mock_hap.return_value.mock_calls) >= 1
+ assert mock_hap.return_value.mock_calls[0][0] == "async_setup"
- mock_hap.return_value.async_reset.return_value = mock_coro(True)
- assert await hmipc.async_unload_entry(hass, entry)
- assert len(mock_hap.return_value.async_reset.mock_calls) == 1
- assert hass.data[hmipc.DOMAIN] == {}
+ assert hass.data[HMIPC_DOMAIN]["ABC123"]
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 1
+ assert config_entries[0].state == ENTRY_STATE_LOADED
+ await hass.config_entries.async_unload(config_entries[0].entry_id)
+ assert config_entries[0].state == ENTRY_STATE_NOT_LOADED
+ assert mock_hap.return_value.mock_calls[3][0] == "async_reset"
+ # entry is unloaded
+ assert hass.data[HMIPC_DOMAIN] == {}
async def test_hmip_dump_hap_config_services(hass, mock_hap_with_service):
@@ -163,3 +155,71 @@ async def test_hmip_dump_hap_config_services(hass, mock_hap_with_service):
assert home.mock_calls[-1][0] == "download_configuration"
assert home.mock_calls
assert write_mock.mock_calls
+
+
+async def test_setup_services_and_unload_services(hass):
+ """Test setup services and unload services."""
+ mock_config = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC123", HMIPC_NAME: "name"}
+ MockConfigEntry(domain=HMIPC_DOMAIN, data=mock_config).add_to_hass(hass)
+
+ with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap:
+ instance = mock_hap.return_value
+ instance.async_setup = CoroutineMock(return_value=True)
+ instance.home.id = "1"
+ instance.home.modelType = "mock-type"
+ instance.home.name = "mock-name"
+ instance.home.currentAPVersion = "mock-ap-version"
+ instance.async_reset = CoroutineMock(return_value=True)
+
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {})
+
+ # Check services are created
+ hmipc_services = hass.services.async_services()[HMIPC_DOMAIN]
+ assert len(hmipc_services) == 8
+
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 1
+
+ await hass.config_entries.async_unload(config_entries[0].entry_id)
+ # Check services are removed
+ assert not hass.services.async_services().get(HMIPC_DOMAIN)
+
+
+async def test_setup_two_haps_unload_one_by_one(hass):
+ """Test setup two access points and unload one by one and check services."""
+
+ # Setup AP1
+ mock_config = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC123", HMIPC_NAME: "name"}
+ MockConfigEntry(domain=HMIPC_DOMAIN, data=mock_config).add_to_hass(hass)
+ # Setup AP2
+ mock_config2 = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC1234", HMIPC_NAME: "name2"}
+ MockConfigEntry(domain=HMIPC_DOMAIN, data=mock_config2).add_to_hass(hass)
+
+ with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap:
+ instance = mock_hap.return_value
+ instance.async_setup = CoroutineMock(return_value=True)
+ instance.home.id = "1"
+ instance.home.modelType = "mock-type"
+ instance.home.name = "mock-name"
+ instance.home.currentAPVersion = "mock-ap-version"
+ instance.async_reset = CoroutineMock(return_value=True)
+
+ assert await async_setup_component(hass, HMIPC_DOMAIN, {})
+
+ hmipc_services = hass.services.async_services()[HMIPC_DOMAIN]
+ assert len(hmipc_services) == 8
+
+ config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN)
+ assert len(config_entries) == 2
+ # unload the first AP
+ await hass.config_entries.async_unload(config_entries[0].entry_id)
+
+ # services still exists
+ hmipc_services = hass.services.async_services()[HMIPC_DOMAIN]
+ assert len(hmipc_services) == 8
+
+ # unload the second AP
+ await hass.config_entries.async_unload(config_entries[1].entry_id)
+
+ # Check services are removed
+ assert not hass.services.async_services().get(HMIPC_DOMAIN)
diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py
index 632a6aac449e4d..8909e469ee9b25 100644
--- a/tests/components/homematicip_cloud/test_light.py
+++ b/tests/components/homematicip_cloud/test_light.py
@@ -19,23 +19,23 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": HMIPC_DOMAIN}}
- )
- is True
+ assert await async_setup_component(
+ hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": HMIPC_DOMAIN}}
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_light(hass, default_mock_hap):
+async def test_hmip_light(hass, default_mock_hap_factory):
"""Test HomematicipLight."""
entity_id = "light.treppe"
entity_name = "Treppe"
device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -64,14 +64,17 @@ async def test_hmip_light(hass, default_mock_hap):
assert ha_state.state == STATE_ON
-async def test_hmip_notification_light(hass, default_mock_hap):
+async def test_hmip_notification_light(hass, default_mock_hap_factory):
"""Test HomematicipNotificationLight."""
entity_id = "light.treppe_top_notification"
entity_name = "Treppe Top Notification"
device_model = "HmIP-BSL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Treppe"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -152,14 +155,17 @@ async def test_hmip_notification_light(hass, default_mock_hap):
assert not ha_state.attributes.get(ATTR_BRIGHTNESS)
-async def test_hmip_dimmer(hass, default_mock_hap):
+async def test_hmip_dimmer(hass, default_mock_hap_factory):
"""Test HomematicipDimmer."""
entity_id = "light.schlafzimmerlicht"
entity_name = "Schlafzimmerlicht"
device_model = "HmIP-BDT"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
@@ -201,14 +207,17 @@ async def test_hmip_dimmer(hass, default_mock_hap):
assert not ha_state.attributes.get(ATTR_BRIGHTNESS)
-async def test_hmip_light_measuring(hass, default_mock_hap):
+async def test_hmip_light_measuring(hass, default_mock_hap_factory):
"""Test HomematicipLightMeasuring."""
entity_id = "light.flur_oben"
entity_name = "Flur oben"
device_model = "HmIP-BSM"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py
index f0a81c69074e2f..c5dbef1a49995b 100644
--- a/tests/components/homematicip_cloud/test_sensor.py
+++ b/tests/components/homematicip_cloud/test_sensor.py
@@ -22,7 +22,12 @@
ATTR_WIND_DIRECTION_VARIATION,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, POWER_WATT, TEMP_CELSIUS
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT,
+ POWER_WATT,
+ SPEED_KILOMETERS_PER_HOUR,
+ TEMP_CELSIUS,
+)
from homeassistant.setup import async_setup_component
from .helper import async_manipulate_test_data, get_and_check_entity_basics
@@ -30,23 +35,23 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}}
- )
- is True
+ assert await async_setup_component(
+ hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}}
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_accesspoint_status(hass, default_mock_hap):
+async def test_hmip_accesspoint_status(hass, default_mock_hap_factory):
"""Test HomematicipSwitch."""
entity_id = "sensor.access_point"
entity_name = "Access Point"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert hmip_device
assert ha_state.state == "8.0"
@@ -58,14 +63,17 @@ async def test_hmip_accesspoint_status(hass, default_mock_hap):
assert ha_state.state == "17.3"
-async def test_hmip_heating_thermostat(hass, default_mock_hap):
+async def test_hmip_heating_thermostat(hass, default_mock_hap_factory):
"""Test HomematicipHeatingThermostat."""
entity_id = "sensor.heizkorperthermostat_heating"
entity_name = "Heizkörperthermostat Heating"
device_model = "HMIP-eTRV"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Heizkörperthermostat"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "0"
@@ -89,14 +97,17 @@ async def test_hmip_heating_thermostat(hass, default_mock_hap):
assert ha_state.attributes["icon"] == "mdi:battery-outline"
-async def test_hmip_humidity_sensor(hass, default_mock_hap):
+async def test_hmip_humidity_sensor(hass, default_mock_hap_factory):
"""Test HomematicipHumiditySensor."""
entity_id = "sensor.bwth_1_humidity"
entity_name = "BWTH 1 Humidity"
device_model = "HmIP-BWTH"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["BWTH 1"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "40"
@@ -109,14 +120,17 @@ async def test_hmip_humidity_sensor(hass, default_mock_hap):
assert ha_state.attributes[ATTR_RSSI_PEER] == -77
-async def test_hmip_temperature_sensor1(hass, default_mock_hap):
+async def test_hmip_temperature_sensor1(hass, default_mock_hap_factory):
"""Test HomematicipTemperatureSensor."""
entity_id = "sensor.bwth_1_temperature"
entity_name = "BWTH 1 Temperature"
device_model = "HmIP-BWTH"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["BWTH 1"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "21.0"
@@ -131,14 +145,17 @@ async def test_hmip_temperature_sensor1(hass, default_mock_hap):
assert ha_state.attributes[ATTR_TEMPERATURE_OFFSET] == 10
-async def test_hmip_temperature_sensor2(hass, default_mock_hap):
+async def test_hmip_temperature_sensor2(hass, default_mock_hap_factory):
"""Test HomematicipTemperatureSensor."""
entity_id = "sensor.heizkorperthermostat_temperature"
entity_name = "Heizkörperthermostat Temperature"
device_model = "HMIP-eTRV"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Heizkörperthermostat"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "20.0"
@@ -153,14 +170,42 @@ async def test_hmip_temperature_sensor2(hass, default_mock_hap):
assert ha_state.attributes[ATTR_TEMPERATURE_OFFSET] == 10
-async def test_hmip_power_sensor(hass, default_mock_hap):
+async def test_hmip_temperature_sensor3(hass, default_mock_hap_factory):
+ """Test HomematicipTemperatureSensor."""
+ entity_id = "sensor.raumbediengerat_analog_temperature"
+ entity_name = "Raumbediengerät Analog Temperature"
+ device_model = "ALPHA-IP-RBGa"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Raumbediengerät Analog"]
+ )
+
+ ha_state, hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
+
+ assert ha_state.state == "23.3"
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS
+ await async_manipulate_test_data(hass, hmip_device, "actualTemperature", 23.5)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == "23.5"
+
+ assert not ha_state.attributes.get(ATTR_TEMPERATURE_OFFSET)
+ await async_manipulate_test_data(hass, hmip_device, "temperatureOffset", 10)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.attributes[ATTR_TEMPERATURE_OFFSET] == 10
+
+
+async def test_hmip_power_sensor(hass, default_mock_hap_factory):
"""Test HomematicipPowerSensor."""
entity_id = "sensor.flur_oben_power"
entity_name = "Flur oben Power"
device_model = "HmIP-BSM"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Flur oben"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "0.0"
@@ -187,14 +232,17 @@ async def test_hmip_power_sensor(hass, default_mock_hap):
assert ha_state.attributes[ATTR_CONFIG_PENDING]
-async def test_hmip_illuminance_sensor1(hass, default_mock_hap):
+async def test_hmip_illuminance_sensor1(hass, default_mock_hap_factory):
"""Test HomematicipIlluminanceSensor."""
entity_id = "sensor.wettersensor_illuminance"
entity_name = "Wettersensor Illuminance"
device_model = "HmIP-SWO-B"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wettersensor"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "4890.0"
@@ -204,14 +252,17 @@ async def test_hmip_illuminance_sensor1(hass, default_mock_hap):
assert ha_state.state == "231"
-async def test_hmip_illuminance_sensor2(hass, default_mock_hap):
+async def test_hmip_illuminance_sensor2(hass, default_mock_hap_factory):
"""Test HomematicipIlluminanceSensor."""
entity_id = "sensor.lichtsensor_nord_illuminance"
entity_name = "Lichtsensor Nord Illuminance"
device_model = "HmIP-SLO"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Lichtsensor Nord"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "807.3"
@@ -224,18 +275,21 @@ async def test_hmip_illuminance_sensor2(hass, default_mock_hap):
assert ha_state.attributes[ATTR_LOWEST_ILLUMINATION] == 785.2
-async def test_hmip_windspeed_sensor(hass, default_mock_hap):
+async def test_hmip_windspeed_sensor(hass, default_mock_hap_factory):
"""Test HomematicipWindspeedSensor."""
entity_id = "sensor.wettersensor_pro_windspeed"
entity_name = "Wettersensor - pro Windspeed"
device_model = "HmIP-SWO-PR"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Wettersensor - pro"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "2.6"
- assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "km/h"
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SPEED_KILOMETERS_PER_HOUR
await async_manipulate_test_data(hass, hmip_device, "windSpeed", 9.4)
ha_state = hass.states.get(entity_id)
assert ha_state.state == "9.4"
@@ -268,14 +322,17 @@ async def test_hmip_windspeed_sensor(hass, default_mock_hap):
assert ha_state.attributes[ATTR_WIND_DIRECTION] == txt
-async def test_hmip_today_rain_sensor(hass, default_mock_hap):
+async def test_hmip_today_rain_sensor(hass, default_mock_hap_factory):
"""Test HomematicipTodayRainSensor."""
entity_id = "sensor.weather_sensor_plus_today_rain"
entity_name = "Weather Sensor – plus Today Rain"
device_model = "HmIP-SWO-PL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=["Weather Sensor – plus"]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "3.9"
@@ -285,14 +342,17 @@ async def test_hmip_today_rain_sensor(hass, default_mock_hap):
assert ha_state.state == "14.2"
-async def test_hmip_passage_detector_delta_counter(hass, default_mock_hap):
+async def test_hmip_passage_detector_delta_counter(hass, default_mock_hap_factory):
"""Test HomematicipPassageDetectorDeltaCounter."""
entity_id = "sensor.spdr_1"
entity_name = "SPDR_1"
device_model = "HmIP-SPDR"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "164"
diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py
index b8ca7b4b67e69f..0cd01154753bef 100644
--- a/tests/components/homematicip_cloud/test_switch.py
+++ b/tests/components/homematicip_cloud/test_switch.py
@@ -16,23 +16,23 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": HMIPC_DOMAIN}}
- )
- is True
+ assert await async_setup_component(
+ hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": HMIPC_DOMAIN}}
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_switch(hass, default_mock_hap):
+async def test_hmip_switch(hass, default_mock_hap_factory):
"""Test HomematicipSwitch."""
entity_id = "switch.schrank"
entity_name = "Schrank"
device_model = "HMIP-PS"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -59,14 +59,17 @@ async def test_hmip_switch(hass, default_mock_hap):
assert ha_state.state == STATE_ON
-async def test_hmip_switch_measuring(hass, default_mock_hap):
+async def test_hmip_switch_measuring(hass, default_mock_hap_factory):
"""Test HomematicipSwitchMeasuring."""
entity_id = "switch.pc"
entity_name = "Pc"
device_model = "HMIP-PSM"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -100,14 +103,15 @@ async def test_hmip_switch_measuring(hass, default_mock_hap):
assert not ha_state.attributes.get(ATTR_TODAY_ENERGY_KWH)
-async def test_hmip_group_switch(hass, default_mock_hap):
+async def test_hmip_group_switch(hass, default_mock_hap_factory):
"""Test HomematicipGroupSwitch."""
entity_id = "switch.strom_group"
entity_name = "Strom Group"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Strom"])
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_ON
@@ -139,14 +143,22 @@ async def test_hmip_group_switch(hass, default_mock_hap):
assert ha_state.attributes[ATTR_GROUP_MEMBER_UNREACHABLE]
-async def test_hmip_multi_switch(hass, default_mock_hap):
+async def test_hmip_multi_switch(hass, default_mock_hap_factory):
"""Test HomematicipMultiSwitch."""
entity_id = "switch.jalousien_1_kizi_2_schlazi_channel1"
entity_name = "Jalousien - 1 KiZi, 2 SchlaZi Channel1"
device_model = "HmIP-PCBS2"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[
+ "Jalousien - 1 KiZi, 2 SchlaZi",
+ "Multi IO Box",
+ "Heizungsaktor",
+ "ioBroker",
+ ]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == STATE_OFF
diff --git a/tests/components/homematicip_cloud/test_weather.py b/tests/components/homematicip_cloud/test_weather.py
index 9427a2d05bf1f4..e3370e77ffea3c 100644
--- a/tests/components/homematicip_cloud/test_weather.py
+++ b/tests/components/homematicip_cloud/test_weather.py
@@ -15,23 +15,23 @@
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
- assert (
- await async_setup_component(
- hass, WEATHER_DOMAIN, {WEATHER_DOMAIN: {"platform": HMIPC_DOMAIN}}
- )
- is True
+ assert await async_setup_component(
+ hass, WEATHER_DOMAIN, {WEATHER_DOMAIN: {"platform": HMIPC_DOMAIN}}
)
assert not hass.data.get(HMIPC_DOMAIN)
-async def test_hmip_weather_sensor(hass, default_mock_hap):
+async def test_hmip_weather_sensor(hass, default_mock_hap_factory):
"""Test HomematicipWeatherSensor."""
entity_id = "weather.weather_sensor_plus"
entity_name = "Weather Sensor – plus"
device_model = "HmIP-SWO-PL"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == ""
@@ -45,14 +45,17 @@ async def test_hmip_weather_sensor(hass, default_mock_hap):
assert ha_state.attributes[ATTR_WEATHER_TEMPERATURE] == 12.1
-async def test_hmip_weather_sensor_pro(hass, default_mock_hap):
+async def test_hmip_weather_sensor_pro(hass, default_mock_hap_factory):
"""Test HomematicipWeatherSensorPro."""
entity_id = "weather.wettersensor_pro"
entity_name = "Wettersensor - pro"
device_model = "HmIP-SWO-PR"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert ha_state.state == "sunny"
@@ -67,14 +70,15 @@ async def test_hmip_weather_sensor_pro(hass, default_mock_hap):
assert ha_state.attributes[ATTR_WEATHER_TEMPERATURE] == 12.1
-async def test_hmip_home_weather(hass, default_mock_hap):
+async def test_hmip_home_weather(hass, default_mock_hap_factory):
"""Test HomematicipHomeWeather."""
entity_id = "weather.weather_1010_wien_osterreich"
entity_name = "Weather 1010 Wien, Österreich"
device_model = None
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap()
ha_state, hmip_device = get_and_check_entity_basics(
- hass, default_mock_hap, entity_id, entity_name, device_model
+ hass, mock_hap, entity_id, entity_name, device_model
)
assert hmip_device
assert ha_state.state == "partlycloudy"
@@ -85,11 +89,7 @@ async def test_hmip_home_weather(hass, default_mock_hap):
assert ha_state.attributes[ATTR_WEATHER_ATTRIBUTION] == "Powered by Homematic IP"
await async_manipulate_test_data(
- hass,
- default_mock_hap.home.weather,
- "temperature",
- 28.3,
- fire_device=default_mock_hap.home,
+ hass, mock_hap.home.weather, "temperature", 28.3, fire_device=mock_hap.home
)
ha_state = hass.states.get(entity_id)
diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py
index 43a39302f4fb38..58e6d8824dda73 100644
--- a/tests/components/http/test_init.py
+++ b/tests/components/http/test_init.py
@@ -1,4 +1,5 @@
"""The tests for the Home Assistant HTTP component."""
+from ipaddress import ip_network
import logging
import unittest
from unittest.mock import patch
@@ -244,12 +245,16 @@ async def test_cors_defaults(hass):
async def test_storing_config(hass, aiohttp_client, aiohttp_unused_port):
"""Test that we store last working config."""
- config = {http.CONF_SERVER_PORT: aiohttp_unused_port()}
+ config = {
+ http.CONF_SERVER_PORT: aiohttp_unused_port(),
+ "use_x_forwarded_for": True,
+ "trusted_proxies": ["192.168.1.100"],
+ }
- await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config})
+ assert await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config})
await hass.async_start()
+ restored = await hass.components.http.async_get_last_config()
+ restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0])
- assert await hass.components.http.async_get_last_config() == http.HTTP_SCHEMA(
- config
- )
+ assert restored == http.HTTP_SCHEMA(config)
diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py
index 29127ed964bdb4..86de1ad8bd1872 100644
--- a/tests/components/huawei_lte/test_config_flow.py
+++ b/tests/components/huawei_lte/test_config_flow.py
@@ -6,11 +6,16 @@
from requests.exceptions import ConnectionError
from requests_mock import ANY
-from homeassistant import data_entry_flow
+from homeassistant import config_entries, data_entry_flow
from homeassistant.components import ssdp
-from homeassistant.components.huawei_lte.config_flow import ConfigFlowHandler
from homeassistant.components.huawei_lte.const import DOMAIN
-from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
+from homeassistant.const import (
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_RECIPIENT,
+ CONF_URL,
+ CONF_USERNAME,
+)
from tests.common import MockConfigEntry
@@ -20,59 +25,62 @@
CONF_PASSWORD: "secret",
}
-
-@pytest.fixture
-def flow(hass):
- """Get flow to test."""
- flow = ConfigFlowHandler()
- flow.hass = hass
- flow.context = {}
- return flow
+FIXTURE_USER_INPUT_OPTIONS = {
+ CONF_NAME: DOMAIN,
+ CONF_RECIPIENT: "+15555551234",
+}
-async def test_show_set_form(flow):
+async def test_show_set_form(hass):
"""Test that the setup form is served."""
- result = await flow.async_step_user(user_input=None)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
-async def test_urlize_plain_host(flow, requests_mock):
+async def test_urlize_plain_host(hass, requests_mock):
"""Test that plain host or IP gets converted to a URL."""
requests_mock.request(ANY, ANY, exc=ConnectionError())
host = "192.168.100.1"
user_input = {**FIXTURE_USER_INPUT, CONF_URL: host}
- result = await flow.async_step_user(user_input=user_input)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}, data=user_input
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert user_input[CONF_URL] == f"http://{host}/"
-async def test_already_configured(flow):
+async def test_already_configured(hass):
"""Test we reject already configured devices."""
MockConfigEntry(
domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Already configured"
- ).add_to_hass(flow.hass)
+ ).add_to_hass(hass)
- # Tweak URL a bit to check that doesn't fail duplicate detection
- result = await flow.async_step_user(
- user_input={
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={
**FIXTURE_USER_INPUT,
+ # Tweak URL a bit to check that doesn't fail duplicate detection
CONF_URL: FIXTURE_USER_INPUT[CONF_URL].replace("http", "HTTP"),
- }
+ },
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
-async def test_connection_error(flow, requests_mock):
+async def test_connection_error(hass, requests_mock):
"""Test we show user form on connection error."""
-
requests_mock.request(ANY, ANY, exc=ConnectionError())
- result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
@@ -109,28 +117,32 @@ def login_requests_mock(requests_mock):
(ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, {"base": "response_error"}),
),
)
-async def test_login_error(flow, login_requests_mock, code, errors):
+async def test_login_error(hass, login_requests_mock, code, errors):
"""Test we show user form with appropriate error on response failure."""
login_requests_mock.request(
ANY,
f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login",
text=f"{code}
",
)
- result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == errors
-async def test_success(flow, login_requests_mock):
+async def test_success(hass, login_requests_mock):
"""Test successful flow provides entry creation data."""
login_requests_mock.request(
ANY,
f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login",
text=f"OK",
)
- result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL]
@@ -138,11 +150,14 @@ async def test_success(flow, login_requests_mock):
assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD]
-async def test_ssdp(flow):
+async def test_ssdp(hass):
"""Test SSDP discovery initiates config properly."""
url = "http://192.168.100.1/"
- result = await flow.async_step_ssdp(
- discovery_info={
+ context = {"source": config_entries.SOURCE_SSDP}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context=context,
+ data={
ssdp.ATTR_SSDP_LOCATION: "http://192.168.100.1:60957/rootDesc.xml",
ssdp.ATTR_SSDP_ST: "upnp:rootdevice",
ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1",
@@ -154,9 +169,29 @@ async def test_ssdp(flow):
ssdp.ATTR_UPNP_PRESENTATION_URL: url,
ssdp.ATTR_UPNP_SERIAL: "00000000",
ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
- }
+ },
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
- assert flow.context[CONF_URL] == url
+ assert context[CONF_URL] == url
+
+
+async def test_options(hass):
+ """Test options produce expected data."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=FIXTURE_USER_INPUT, options=FIXTURE_USER_INPUT_OPTIONS
+ )
+ config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ recipient = "+15555550000"
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_RECIPIENT: recipient}
+ )
+ assert result["data"][CONF_NAME] == DOMAIN
+ assert result["data"][CONF_RECIPIENT] == [recipient]
diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py
new file mode 100644
index 00000000000000..49cd953a69796f
--- /dev/null
+++ b/tests/components/hue/conftest.py
@@ -0,0 +1,11 @@
+"""Test helpers for Hue."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def no_request_delay():
+ """Make the request refresh delay 0 for instant tests."""
+ with patch("homeassistant.components.hue.light.REQUEST_REFRESH_DELAY", 0):
+ yield
diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py
index a5bf143775ade4..1ca2eca664ee4d 100644
--- a/tests/components/hue/test_config_flow.py
+++ b/tests/components/hue/test_config_flow.py
@@ -2,7 +2,9 @@
import asyncio
from unittest.mock import Mock
+from aiohttp import client_exceptions
import aiohue
+from aiohue.discovery import URL_NUPNP
from asynctest import CoroutineMock, patch
import pytest
import voluptuous as vol
@@ -77,6 +79,7 @@ async def test_flow_works(hass):
assert result["data"] == {
"host": "1.2.3.4",
"username": "home-assistant#test-home",
+ "allow_hue_groups": False,
}
assert len(mock_bridge.initialize.mock_calls) == 1
@@ -84,7 +87,7 @@ async def test_flow_works(hass):
async def test_flow_no_discovered_bridges(hass, aioclient_mock):
"""Test config flow discovers no bridges."""
- aioclient_mock.get(const.API_NUPNP, json=[])
+ aioclient_mock.get(URL_NUPNP, json=[])
result = await hass.config_entries.flow.async_init(
const.DOMAIN, context={"source": "user"}
@@ -95,9 +98,7 @@ async def test_flow_no_discovered_bridges(hass, aioclient_mock):
async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock):
"""Test config flow discovers only already configured bridges."""
- aioclient_mock.get(
- const.API_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]
- )
+ aioclient_mock.get(URL_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}])
MockConfigEntry(
domain="hue", unique_id="bla", data={"host": "1.2.3.4"}
).add_to_hass(hass)
@@ -111,9 +112,7 @@ async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock):
async def test_flow_one_bridge_discovered(hass, aioclient_mock):
"""Test config flow discovers one bridge."""
- aioclient_mock.get(
- const.API_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]
- )
+ aioclient_mock.get(URL_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}])
result = await hass.config_entries.flow.async_init(
const.DOMAIN, context={"source": "user"}
@@ -130,7 +129,7 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock):
).add_to_hass(hass)
aioclient_mock.get(
- const.API_NUPNP,
+ URL_NUPNP,
json=[
{"internalipaddress": "1.2.3.4", "id": "bla"},
{"internalipaddress": "5.6.7.8", "id": "beer"},
@@ -153,7 +152,7 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock):
async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock):
"""Test config flow discovers two bridges."""
aioclient_mock.get(
- const.API_NUPNP,
+ URL_NUPNP,
json=[
{"internalipaddress": "1.2.3.4", "id": "bla"},
{"internalipaddress": "5.6.7.8", "id": "beer"},
@@ -214,6 +213,26 @@ async def test_flow_link_timeout(hass):
assert result["errors"] == {"base": "linking"}
+async def test_flow_link_unknown_error(hass):
+ """Test if a unknown error happened during the linking processes."""
+ mock_bridge = get_mock_bridge(mock_create_user=CoroutineMock(side_effect=OSError),)
+ with patch(
+ "homeassistant.components.hue.config_flow.discover_nupnp",
+ return_value=[mock_bridge],
+ ):
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN, context={"source": "user"}
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "link"
+ assert result["errors"] == {"base": "linking"}
+
+
async def test_flow_link_button_not_pressed(hass):
"""Test config flow ."""
mock_bridge = get_mock_bridge(
@@ -239,7 +258,7 @@ async def test_flow_link_button_not_pressed(hass):
async def test_flow_link_unknown_host(hass):
"""Test config flow ."""
mock_bridge = get_mock_bridge(
- mock_create_user=CoroutineMock(side_effect=aiohue.RequestError),
+ mock_create_user=CoroutineMock(side_effect=client_exceptions.ClientOSError),
)
with patch(
"homeassistant.components.hue.config_flow.discover_nupnp",
@@ -303,6 +322,36 @@ async def test_bridge_ssdp_emulated_hue(hass):
assert result["reason"] == "not_hue_bridge"
+async def test_bridge_ssdp_missing_location(hass):
+ """Test if discovery info is missing a location attribute."""
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_SERIAL: "1234",
+ },
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_hue_bridge"
+
+
+async def test_bridge_ssdp_missing_serial(hass):
+ """Test if discovery info is a serial attribute."""
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ ssdp.ATTR_SSDP_LOCATION: "http://0.0.0.0/",
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL,
+ },
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_hue_bridge"
+
+
async def test_bridge_ssdp_espalexa(hass):
"""Test if discovery info is from an Espalexa based device."""
result = await hass.config_entries.flow.async_init(
@@ -392,6 +441,7 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
assert result["data"] == {
"host": "2.2.2.2",
"username": "username-abc",
+ "allow_hue_groups": False,
}
entries = hass.config_entries.async_entries("hue")
assert len(entries) == 2
@@ -417,6 +467,22 @@ async def test_bridge_homekit(hass):
assert result["step_id"] == "link"
+async def test_bridge_import_already_configured(hass):
+ """Test if a import flow aborts if host is already configured."""
+ MockConfigEntry(
+ domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"}
+ ).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN,
+ context={"source": "import"},
+ data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+
async def test_bridge_homekit_already_configured(hass):
"""Test if a HomeKit discovered bridge has already been configured."""
MockConfigEntry(
@@ -431,3 +497,43 @@ async def test_bridge_homekit_already_configured(hass):
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
+
+
+async def test_ssdp_discovery_update_configuration(hass):
+ """Test if a discovered bridge is configured and updated with new host."""
+ entry = MockConfigEntry(
+ domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"}
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ ssdp.ATTR_SSDP_LOCATION: "http://1.1.1.1/",
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.HUE_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_SERIAL: "aabbccddeeff",
+ },
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+ assert entry.data["host"] == "1.1.1.1"
+
+
+async def test_homekit_discovery_update_configuration(hass):
+ """Test if a discovered bridge is configured and updated with new host."""
+ entry = MockConfigEntry(
+ domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"}
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN,
+ context={"source": "homekit"},
+ data={"host": "1.1.1.1", "properties": {"id": "aa:bb:cc:dd:ee:ff"}},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+ assert entry.data["host"] == "1.1.1.1"
diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py
index 35e1ba689b40ce..d9131dad226a95 100644
--- a/tests/components/hue/test_init.py
+++ b/tests/components/hue/test_init.py
@@ -1,5 +1,7 @@
"""Test Hue setup process."""
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
+
+from asynctest import CoroutineMock, patch
from homeassistant.components import hue
from homeassistant.setup import async_setup_component
@@ -35,7 +37,7 @@ async def test_setup_defined_hosts_known_auth(hass):
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
},
- {hue.CONF_HOST: "1.1.1.1", "filename": "bla"},
+ {hue.CONF_HOST: "1.1.1.1"},
]
}
},
@@ -57,7 +59,6 @@ async def test_setup_defined_hosts_known_auth(hass):
hue.CONF_HOST: "1.1.1.1",
hue.CONF_ALLOW_HUE_GROUPS: True,
hue.CONF_ALLOW_UNREACHABLE: False,
- "filename": "bla",
},
}
@@ -184,3 +185,33 @@ async def test_setting_unique_id(hass):
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
assert entry.unique_id == "mock-id"
+
+
+async def test_security_vuln_check(hass):
+ """Test that we report security vulnerabilities."""
+ assert await async_setup_component(hass, "persistent_notification", {})
+ entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"})
+ entry.add_to_hass(hass)
+
+ with patch.object(
+ hue,
+ "HueBridge",
+ Mock(
+ return_value=Mock(
+ async_setup=CoroutineMock(return_value=True),
+ api=Mock(
+ config=Mock(
+ bridgeid="", mac="", modelid="BSB002", swversion="1935144020"
+ )
+ ),
+ )
+ ),
+ ):
+
+ assert await async_setup_component(hass, "hue", {})
+
+ await hass.async_block_till_done()
+
+ state = hass.states.get("persistent_notification.hue_hub_firmware")
+ assert state is not None
+ assert "CVE-2020-6007" in state.attributes["message"]
diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py
index 0f3e197b979391..72546891a631e4 100644
--- a/tests/components/hue/test_light.py
+++ b/tests/components/hue/test_light.py
@@ -179,11 +179,13 @@
def mock_bridge(hass):
"""Mock a Hue bridge."""
bridge = Mock(
+ hass=hass,
available=True,
authorized=True,
allow_unreachable=False,
allow_groups=False,
api=Mock(),
+ reset_jobs=[],
spec=hue.HueBridge,
)
bridge.mock_requests = []
@@ -204,8 +206,8 @@ async def mock_request(method, path, **kwargs):
return bridge.mock_group_responses.popleft()
return None
- async def async_request_call(coro):
- await coro
+ async def async_request_call(task):
+ await task()
bridge.async_request_call = async_request_call
bridge.api.config.apiversion = "9.9.9"
@@ -218,7 +220,6 @@ async def async_request_call(coro):
async def setup_bridge(hass, mock_bridge):
"""Load the Hue light platform with the provided bridge."""
hass.config.components.add(hue.DOMAIN)
- hass.data[hue.DOMAIN] = {"mock-host": mock_bridge}
config_entry = config_entries.ConfigEntry(
1,
hue.DOMAIN,
@@ -228,6 +229,8 @@ async def setup_bridge(hass, mock_bridge):
config_entries.CONN_CLASS_LOCAL_POLL,
system_options={},
)
+ mock_bridge.config_entry = config_entry
+ hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge}
await hass.config_entries.async_forward_entry_setup(config_entry, "light")
# To flush out the service call to update the group
await hass.async_block_till_done()
@@ -363,8 +366,8 @@ async def test_new_group_discovered(hass, mock_bridge):
await hass.services.async_call(
"light", "turn_on", {"entity_id": "light.group_1"}, blocking=True
)
- # 2x group update, 2x light update, 1 turn on request
- assert len(mock_bridge.mock_requests) == 5
+ # 2x group update, 1x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 4
assert len(hass.states.async_all()) == 3
new_group = hass.states.get("light.group_3")
@@ -443,8 +446,8 @@ async def test_group_removed(hass, mock_bridge):
"light", "turn_on", {"entity_id": "light.group_1"}, blocking=True
)
- # 2x group update, 2x light update, 1 turn on request
- assert len(mock_bridge.mock_requests) == 5
+ # 2x group update, 1x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 4
assert len(hass.states.async_all()) == 1
group = hass.states.get("light.group_1")
@@ -524,8 +527,8 @@ async def test_other_group_update(hass, mock_bridge):
await hass.services.async_call(
"light", "turn_on", {"entity_id": "light.group_1"}, blocking=True
)
- # 2x group update, 2x light update, 1 turn on request
- assert len(mock_bridge.mock_requests) == 5
+ # 2x group update, 1x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 4
assert len(hass.states.async_all()) == 2
group_2 = hass.states.get("light.group_2")
@@ -599,7 +602,6 @@ async def test_update_timeout(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 0
assert len(hass.states.async_all()) == 0
- assert mock_bridge.available is False
async def test_update_unauthorized(hass, mock_bridge):
@@ -701,9 +703,10 @@ def test_available():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
- request_bridge_update=None,
+ coordinator=Mock(last_update_success=True),
bridge=Mock(allow_unreachable=False),
is_group=False,
+ supported_features=hue_light.SUPPORT_HUE_EXTENDED,
)
assert light.available is False
@@ -715,9 +718,10 @@ def test_available():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
- request_bridge_update=None,
+ coordinator=Mock(last_update_success=True),
bridge=Mock(allow_unreachable=True),
is_group=False,
+ supported_features=hue_light.SUPPORT_HUE_EXTENDED,
)
assert light.available is True
@@ -729,9 +733,10 @@ def test_available():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
- request_bridge_update=None,
+ coordinator=Mock(last_update_success=True),
bridge=Mock(allow_unreachable=False),
is_group=True,
+ supported_features=hue_light.SUPPORT_HUE_EXTENDED,
)
assert light.available is True
@@ -746,9 +751,10 @@ def test_hs_color():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
- request_bridge_update=None,
+ coordinator=Mock(last_update_success=True),
bridge=Mock(),
is_group=False,
+ supported_features=hue_light.SUPPORT_HUE_EXTENDED,
)
assert light.hs_color is None
@@ -760,9 +766,10 @@ def test_hs_color():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
- request_bridge_update=None,
+ coordinator=Mock(last_update_success=True),
bridge=Mock(),
is_group=False,
+ supported_features=hue_light.SUPPORT_HUE_EXTENDED,
)
assert light.hs_color is None
@@ -774,9 +781,192 @@ def test_hs_color():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
- request_bridge_update=None,
+ coordinator=Mock(last_update_success=True),
bridge=Mock(),
is_group=False,
+ supported_features=hue_light.SUPPORT_HUE_EXTENDED,
)
assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT)
+
+
+async def test_group_features(hass, mock_bridge):
+ """Test group features."""
+
+ color_temp_type = "Color temperature light"
+ extended_color_type = "Extended color light"
+
+ group_response = {
+ "1": {
+ "name": "Group 1",
+ "lights": ["1", "2"],
+ "type": "Room",
+ "action": {
+ "on": True,
+ "bri": 254,
+ "hue": 10000,
+ "sat": 254,
+ "effect": "none",
+ "xy": [0.5, 0.5],
+ "ct": 250,
+ "alert": "select",
+ "colormode": "ct",
+ },
+ "state": {"any_on": True, "all_on": False},
+ },
+ "2": {
+ "name": "Group 2",
+ "lights": ["3", "4"],
+ "type": "Room",
+ "action": {
+ "on": True,
+ "bri": 153,
+ "hue": 4345,
+ "sat": 254,
+ "effect": "none",
+ "xy": [0.5, 0.5],
+ "ct": 250,
+ "alert": "select",
+ "colormode": "ct",
+ },
+ "state": {"any_on": True, "all_on": False},
+ },
+ "3": {
+ "name": "Group 3",
+ "lights": ["1", "3"],
+ "type": "Room",
+ "action": {
+ "on": True,
+ "bri": 153,
+ "hue": 4345,
+ "sat": 254,
+ "effect": "none",
+ "xy": [0.5, 0.5],
+ "ct": 250,
+ "alert": "select",
+ "colormode": "ct",
+ },
+ "state": {"any_on": True, "all_on": False},
+ },
+ }
+
+ light_1 = {
+ "state": {
+ "on": True,
+ "bri": 144,
+ "ct": 467,
+ "alert": "none",
+ "effect": "none",
+ "reachable": True,
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [[0.704, 0.296], [0.2151, 0.7106], [0.138, 0.08]],
+ }
+ },
+ "type": color_temp_type,
+ "name": "Hue Lamp 1",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "456",
+ }
+ light_2 = {
+ "state": {
+ "on": False,
+ "bri": 0,
+ "ct": 0,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "xy",
+ "reachable": True,
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [[0.704, 0.296], [0.2151, 0.7106], [0.138, 0.08]],
+ }
+ },
+ "type": color_temp_type,
+ "name": "Hue Lamp 2",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "456",
+ }
+ light_3 = {
+ "state": {
+ "on": False,
+ "bri": 0,
+ "hue": 0,
+ "sat": 0,
+ "xy": [0, 0],
+ "ct": 0,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "hs",
+ "reachable": True,
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [[0.704, 0.296], [0.2151, 0.7106], [0.138, 0.08]],
+ }
+ },
+ "type": extended_color_type,
+ "name": "Hue Lamp 3",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "123",
+ }
+ light_4 = {
+ "state": {
+ "on": True,
+ "bri": 100,
+ "hue": 13088,
+ "sat": 210,
+ "xy": [0.5, 0.4],
+ "ct": 420,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "hs",
+ "reachable": True,
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [[0.704, 0.296], [0.2151, 0.7106], [0.138, 0.08]],
+ }
+ },
+ "type": extended_color_type,
+ "name": "Hue Lamp 4",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "123",
+ }
+ light_response = {
+ "1": light_1,
+ "2": light_2,
+ "3": light_3,
+ "4": light_4,
+ }
+
+ mock_bridge.allow_groups = True
+ mock_bridge.mock_light_responses.append(light_response)
+ mock_bridge.mock_group_responses.append(group_response)
+ await setup_bridge(hass, mock_bridge)
+
+ color_temp_feature = hue_light.SUPPORT_HUE["Color temperature light"]
+ extended_color_feature = hue_light.SUPPORT_HUE["Extended color light"]
+
+ group_1 = hass.states.get("light.group_1")
+ assert group_1.attributes["supported_features"] == color_temp_feature
+
+ group_2 = hass.states.get("light.group_2")
+ assert group_2.attributes["supported_features"] == extended_color_feature
+
+ group_3 = hass.states.get("light.group_3")
+ assert group_3.attributes["supported_features"] == extended_color_feature
diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py
index ad927767c3075b..ca83da725fab2f 100644
--- a/tests/components/hue/test_sensor_base.py
+++ b/tests/components/hue/test_sensor_base.py
@@ -1,7 +1,6 @@
"""Philips Hue sensors platform tests."""
import asyncio
from collections import deque
-import datetime
import logging
from unittest.mock import Mock
@@ -252,16 +251,19 @@
}
-def create_mock_bridge():
+def create_mock_bridge(hass):
"""Create a mock Hue bridge."""
bridge = Mock(
+ hass=hass,
available=True,
authorized=True,
allow_unreachable=False,
allow_groups=False,
api=Mock(),
+ reset_jobs=[],
spec=hue.HueBridge,
)
+ bridge.sensor_manager = hue_sensor_base.SensorManager(bridge)
bridge.mock_requests = []
# We're using a deque so we can schedule multiple responses
# and also means that `popleft()` will blow up if we get more updates
@@ -277,8 +279,8 @@ async def mock_request(method, path, **kwargs):
return bridge.mock_sensor_responses.popleft()
return None
- async def async_request_call(coro):
- await coro
+ async def async_request_call(task):
+ await task()
bridge.async_request_call = async_request_call
bridge.api.config.apiversion = "9.9.9"
@@ -289,13 +291,7 @@ async def async_request_call(coro):
@pytest.fixture
def mock_bridge(hass):
"""Mock a Hue bridge."""
- return create_mock_bridge()
-
-
-@pytest.fixture
-def increase_scan_interval(hass):
- """Increase the SCAN_INTERVAL to prevent unexpected scans during tests."""
- hue_sensor_base.SensorManager.SCAN_INTERVAL = datetime.timedelta(days=365)
+ return create_mock_bridge(hass)
async def setup_bridge(hass, mock_bridge, hostname=None):
@@ -303,7 +299,6 @@ async def setup_bridge(hass, mock_bridge, hostname=None):
if hostname is None:
hostname = "mock-host"
hass.config.components.add(hue.DOMAIN)
- hass.data[hue.DOMAIN] = {hostname: mock_bridge}
config_entry = config_entries.ConfigEntry(
1,
hue.DOMAIN,
@@ -313,6 +308,8 @@ async def setup_bridge(hass, mock_bridge, hostname=None):
config_entries.CONN_CLASS_LOCAL_POLL,
system_options={},
)
+ mock_bridge.config_entry = config_entry
+ hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge}
await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor")
await hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
# and make sure it completes before going further
@@ -330,7 +327,7 @@ async def test_no_sensors(hass, mock_bridge):
async def test_sensors_with_multiple_bridges(hass, mock_bridge):
"""Test the update_items function with some sensors."""
- mock_bridge_2 = create_mock_bridge()
+ mock_bridge_2 = create_mock_bridge(hass)
mock_bridge_2.mock_sensor_responses.append(
{
"1": PRESENCE_SENSOR_3_PRESENT,
@@ -412,11 +409,7 @@ async def test_new_sensor_discovered(hass, mock_bridge):
mock_bridge.mock_sensor_responses.append(new_sensor_response)
# Force updates to run again
- sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host")
- sm = hass.data[hue.DOMAIN][sm_key]
- await sm.async_update_items()
-
- # To flush out the service call to update the group
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
await hass.async_block_till_done()
assert len(mock_bridge.mock_requests) == 2
@@ -443,9 +436,7 @@ async def test_sensor_removed(hass, mock_bridge):
mock_bridge.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys})
# Force updates to run again
- sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host")
- sm = hass.data[hue.DOMAIN][sm_key]
- await sm.async_update_items()
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
# To flush out the service call to update the group
await hass.async_block_till_done()
@@ -466,7 +457,6 @@ async def test_update_timeout(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 0
assert len(hass.states.async_all()) == 0
- assert mock_bridge.available is False
async def test_update_unauthorized(hass, mock_bridge):
diff --git a/tests/components/icloud/test_config_flow.py b/tests/components/icloud/test_config_flow.py
index 747af7c940acce..6091d1cf1da55b 100644
--- a/tests/components/icloud/test_config_flow.py
+++ b/tests/components/icloud/test_config_flow.py
@@ -39,7 +39,7 @@ def mock_controller_service():
with patch(
"homeassistant.components.icloud.config_flow.PyiCloudService"
) as service_mock:
- service_mock.return_value.requires_2fa = True
+ service_mock.return_value.requires_2sa = True
service_mock.return_value.trusted_devices = TRUSTED_DEVICES
service_mock.return_value.send_verification_code = Mock(return_value=True)
service_mock.return_value.validate_verification_code = Mock(return_value=True)
@@ -52,7 +52,7 @@ def mock_controller_service_with_cookie():
with patch(
"homeassistant.components.icloud.config_flow.PyiCloudService"
) as service_mock:
- service_mock.return_value.requires_2fa = False
+ service_mock.return_value.requires_2sa = False
service_mock.return_value.trusted_devices = TRUSTED_DEVICES
service_mock.return_value.send_verification_code = Mock(return_value=True)
service_mock.return_value.validate_verification_code = Mock(return_value=True)
@@ -65,7 +65,7 @@ def mock_controller_service_send_verification_code_failed():
with patch(
"homeassistant.components.icloud.config_flow.PyiCloudService"
) as service_mock:
- service_mock.return_value.requires_2fa = True
+ service_mock.return_value.requires_2sa = True
service_mock.return_value.trusted_devices = TRUSTED_DEVICES
service_mock.return_value.send_verification_code = Mock(return_value=False)
yield service_mock
@@ -77,7 +77,7 @@ def mock_controller_service_validate_verification_code_failed():
with patch(
"homeassistant.components.icloud.config_flow.PyiCloudService"
) as service_mock:
- service_mock.return_value.requires_2fa = True
+ service_mock.return_value.requires_2sa = True
service_mock.return_value.trusted_devices = TRUSTED_DEVICES
service_mock.return_value.send_verification_code = Mock(return_value=True)
service_mock.return_value.validate_verification_code = Mock(return_value=False)
@@ -324,7 +324,7 @@ async def test_verification_code_success(hass: HomeAssistantType, service: Magic
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_TRUSTED_DEVICE: 0}
)
- service.return_value.requires_2fa = False
+ service.return_value.requires_2sa = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_VERIFICATION_CODE: "0"}
diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py
index 67a23a61d8ba68..fa67bb2f8bf312 100644
--- a/tests/components/input_datetime/test_init.py
+++ b/tests/components/input_datetime/test_init.py
@@ -307,7 +307,7 @@ async def test_restore_state(hass):
async def test_default_value(hass):
- """Test default value if none has been set via inital or restore state."""
+ """Test default value if none has been set via initial or restore state."""
await async_setup_component(
hass,
DOMAIN,
diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py
index 8331e1374c865f..28b9d27d23fd05 100644
--- a/tests/components/input_number/test_init.py
+++ b/tests/components/input_number/test_init.py
@@ -3,6 +3,7 @@
from unittest.mock import patch
import pytest
+import voluptuous as vol
from homeassistant.components.input_number import (
ATTR_VALUE,
@@ -21,7 +22,6 @@
from homeassistant.core import Context, CoreState, State
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import entity_registry
-from homeassistant.loader import bind_hass
from homeassistant.setup import async_setup_component
from tests.common import mock_restore_cache
@@ -63,38 +63,36 @@ async def _storage(items=None, config=None):
return _storage
-@bind_hass
-def set_value(hass, entity_id, value):
+async def set_value(hass, entity_id, value):
"""Set input_number to value.
This is a legacy helper method. Do not use it for new tests.
"""
- hass.async_create_task(
- hass.services.async_call(
- DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}
- )
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_VALUE,
+ {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value},
+ blocking=True,
)
-@bind_hass
-def increment(hass, entity_id):
+async def increment(hass, entity_id):
"""Increment value of entity.
This is a legacy helper method. Do not use it for new tests.
"""
- hass.async_create_task(
- hass.services.async_call(DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})
+ await hass.services.async_call(
+ DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
-@bind_hass
-def decrement(hass, entity_id):
+async def decrement(hass, entity_id):
"""Decrement value of entity.
This is a legacy helper method. Do not use it for new tests.
"""
- hass.async_create_task(
- hass.services.async_call(DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})
+ await hass.services.async_call(
+ DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
@@ -110,7 +108,7 @@ async def test_config(hass):
assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
-async def test_set_value(hass):
+async def test_set_value(hass, caplog):
"""Test set_value method."""
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {"test_1": {"initial": 50, "min": 0, "max": 100}}}
@@ -120,20 +118,22 @@ async def test_set_value(hass):
state = hass.states.get(entity_id)
assert 50 == float(state.state)
- set_value(hass, entity_id, "30.4")
- await hass.async_block_till_done()
+ await set_value(hass, entity_id, "30.4")
state = hass.states.get(entity_id)
assert 30.4 == float(state.state)
- set_value(hass, entity_id, "70")
- await hass.async_block_till_done()
+ await set_value(hass, entity_id, "70")
state = hass.states.get(entity_id)
assert 70 == float(state.state)
- set_value(hass, entity_id, "110")
- await hass.async_block_till_done()
+ with pytest.raises(vol.Invalid) as excinfo:
+ await set_value(hass, entity_id, "110")
+
+ assert "Invalid value for input_number.test_1: 110.0 (range 0.0 - 100.0)" in str(
+ excinfo.value
+ )
state = hass.states.get(entity_id)
assert 70 == float(state.state)
@@ -149,13 +149,13 @@ async def test_increment(hass):
state = hass.states.get(entity_id)
assert 50 == float(state.state)
- increment(hass, entity_id)
+ await increment(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert 51 == float(state.state)
- increment(hass, entity_id)
+ await increment(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
@@ -172,13 +172,13 @@ async def test_decrement(hass):
state = hass.states.get(entity_id)
assert 50 == float(state.state)
- decrement(hass, entity_id)
+ await decrement(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert 49 == float(state.state)
- decrement(hass, entity_id)
+ await decrement(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py
index c65ca72023549b..b598d7ddbc2936 100644
--- a/tests/components/integration/test_sensor.py
+++ b/tests/components/integration/test_sensor.py
@@ -2,6 +2,7 @@
from datetime import timedelta
from unittest.mock import patch
+from homeassistant.const import TIME_SECONDS
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -181,7 +182,7 @@ async def test_suffix(hass):
"source": "sensor.bytes_per_second",
"round": 2,
"unit_prefix": "k",
- "unit_time": "s",
+ "unit_time": TIME_SECONDS,
}
}
diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py
index 56344b6affe5a1..723736f35bc844 100644
--- a/tests/components/intent/test_init.py
+++ b/tests/components/intent/test_init.py
@@ -2,6 +2,7 @@
import pytest
from homeassistant.components.cover import SERVICE_OPEN_COVER
+from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.helpers import intent
from homeassistant.setup import async_setup_component
@@ -74,3 +75,96 @@ async def test_cover_intents_loading(hass):
assert call.domain == "cover"
assert call.service == "open_cover"
assert call.data == {"entity_id": "cover.garage_door"}
+
+
+async def test_turn_on_intent(hass):
+ """Test HassTurnOn intent."""
+ result = await async_setup_component(hass, "homeassistant", {})
+ result = await async_setup_component(hass, "intent", {})
+ assert result
+
+ hass.states.async_set("light.test_light", "off")
+ calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
+
+ response = await intent.async_handle(
+ hass, "test", "HassTurnOn", {"name": {"value": "test light"}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech["plain"]["speech"] == "Turned test light on"
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == "light"
+ assert call.service == "turn_on"
+ assert call.data == {"entity_id": ["light.test_light"]}
+
+
+async def test_turn_off_intent(hass):
+ """Test HassTurnOff intent."""
+ result = await async_setup_component(hass, "homeassistant", {})
+ result = await async_setup_component(hass, "intent", {})
+ assert result
+
+ hass.states.async_set("light.test_light", "on")
+ calls = async_mock_service(hass, "light", SERVICE_TURN_OFF)
+
+ response = await intent.async_handle(
+ hass, "test", "HassTurnOff", {"name": {"value": "test light"}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech["plain"]["speech"] == "Turned test light off"
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == "light"
+ assert call.service == "turn_off"
+ assert call.data == {"entity_id": ["light.test_light"]}
+
+
+async def test_toggle_intent(hass):
+ """Test HassToggle intent."""
+ result = await async_setup_component(hass, "homeassistant", {})
+ result = await async_setup_component(hass, "intent", {})
+ assert result
+
+ hass.states.async_set("light.test_light", "off")
+ calls = async_mock_service(hass, "light", SERVICE_TOGGLE)
+
+ response = await intent.async_handle(
+ hass, "test", "HassToggle", {"name": {"value": "test light"}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech["plain"]["speech"] == "Toggled test light"
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == "light"
+ assert call.service == "toggle"
+ assert call.data == {"entity_id": ["light.test_light"]}
+
+
+async def test_turn_on_multiple_intent(hass):
+ """Test HassTurnOn intent with multiple similar entities.
+
+ This tests that matching finds the proper entity among similar names.
+ """
+ result = await async_setup_component(hass, "homeassistant", {})
+ result = await async_setup_component(hass, "intent", {})
+ assert result
+
+ hass.states.async_set("light.test_light", "off")
+ hass.states.async_set("light.test_lights_2", "off")
+ hass.states.async_set("light.test_lighter", "off")
+ calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
+
+ response = await intent.async_handle(
+ hass, "test", "HassTurnOn", {"name": {"value": "test lights"}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech["plain"]["speech"] == "Turned test lights 2 on"
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == "light"
+ assert call.service == "turn_on"
+ assert call.data == {"entity_id": ["light.test_lights_2"]}
diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py
index ead4654cba2043..7a6e1160f24801 100644
--- a/tests/components/ipma/test_weather.py
+++ b/tests/components/ipma/test_weather.py
@@ -4,6 +4,14 @@
from homeassistant.components import weather
from homeassistant.components.weather import (
+ ATTR_FORECAST,
+ ATTR_FORECAST_CONDITION,
+ ATTR_FORECAST_PRECIPITATION,
+ ATTR_FORECAST_TEMP,
+ ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME,
+ ATTR_FORECAST_WIND_BEARING,
+ ATTR_FORECAST_WIND_SPEED,
ATTR_WEATHER_HUMIDITY,
ATTR_WEATHER_PRESSURE,
ATTR_WEATHER_TEMPERATURE,
@@ -12,6 +20,7 @@
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import now
from tests.common import MockConfigEntry, mock_coro
@@ -71,16 +80,16 @@ async def forecast(self, api):
"2020-01-15T07:51:00",
9,
"S",
- None,
+ "10",
),
Forecast(
"7.7",
- "2020-01-15T02:00:00",
+ now().utcnow().strftime("%Y-%m-%dT%H:%M:%S"),
1,
"86.9",
None,
None,
- "-99.0",
+ "80.0",
10.6,
"2020-01-15T07:51:00",
10,
@@ -122,7 +131,9 @@ async def test_setup_configuration(hass):
return_value=mock_coro(MockLocation()),
):
assert await async_setup_component(
- hass, weather.DOMAIN, {"weather": {"name": "HomeTown", "platform": "ipma"}}
+ hass,
+ weather.DOMAIN,
+ {"weather": {"name": "HomeTown", "platform": "ipma", "mode": "hourly"}},
)
await hass.async_block_till_done()
@@ -158,3 +169,53 @@ async def test_setup_config_flow(hass):
assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94
assert data.get(ATTR_WEATHER_WIND_BEARING) == "NW"
assert state.attributes.get("friendly_name") == "HomeTown"
+
+
+async def test_daily_forecast(hass):
+ """Test for successfully getting daily forecast."""
+ with patch(
+ "homeassistant.components.ipma.weather.async_get_location",
+ return_value=mock_coro(MockLocation()),
+ ):
+ assert await async_setup_component(
+ hass,
+ weather.DOMAIN,
+ {"weather": {"name": "HomeTown", "platform": "ipma", "mode": "daily"}},
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("weather.hometown")
+ assert state.state == "rainy"
+
+ forecast = state.attributes.get(ATTR_FORECAST)[0]
+ assert forecast.get(ATTR_FORECAST_TIME) == "2020-01-15T00:00:00"
+ assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy"
+ assert forecast.get(ATTR_FORECAST_TEMP) == 16.2
+ assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION) == "100.0"
+ assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "10"
+ assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S"
+
+
+async def test_hourly_forecast(hass):
+ """Test for successfully getting daily forecast."""
+ with patch(
+ "homeassistant.components.ipma.weather.async_get_location",
+ return_value=mock_coro(MockLocation()),
+ ):
+ assert await async_setup_component(
+ hass,
+ weather.DOMAIN,
+ {"weather": {"name": "HomeTown", "platform": "ipma", "mode": "hourly"}},
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("weather.hometown")
+ assert state.state == "rainy"
+
+ forecast = state.attributes.get(ATTR_FORECAST)[0]
+ assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy"
+ assert forecast.get(ATTR_FORECAST_TEMP) == 7.7
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION) == "80.0"
+ assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "32.7"
+ assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S"
diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py
index e630702c6b2f42..59b6dc01313576 100644
--- a/tests/components/jewish_calendar/test_sensor.py
+++ b/tests/components/jewish_calendar/test_sensor.py
@@ -568,3 +568,37 @@ async def test_omer_sensor(hass, test_time, result):
await hass.async_block_till_done()
assert hass.states.get("sensor.test_day_of_the_omer").state == result
+
+
+DAFYOMI_PARAMS = [
+ (dt(2014, 4, 28, 0), "Beitzah 29"),
+ (dt(2020, 1, 4, 0), "Niddah 73"),
+ (dt(2020, 1, 5, 0), "Berachos 2"),
+ (dt(2020, 3, 7, 0), "Berachos 64"),
+ (dt(2020, 3, 8, 0), "Shabbos 2"),
+]
+DAFYOMI_TEST_IDS = [
+ "randomly_picked_date",
+ "end_of_cycle13",
+ "start_of_cycle14",
+ "cycle14_end_of_berachos",
+ "cycle14_start_of_shabbos",
+]
+
+
+@pytest.mark.parametrize(["test_time", "result"], DAFYOMI_PARAMS, ids=DAFYOMI_TEST_IDS)
+async def test_dafyomi_sensor(hass, test_time, result):
+ """Test Daf Yomi sensor output."""
+ test_time = hass.config.time_zone.localize(test_time)
+
+ with alter_time(test_time):
+ assert await async_setup_component(
+ hass, jewish_calendar.DOMAIN, {"jewish_calendar": {"name": "test"}}
+ )
+ await hass.async_block_till_done()
+
+ future = dt_util.utcnow() + timedelta(seconds=30)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.test_daf_yomi").state == result
diff --git a/tests/components/konnected/__init__.py b/tests/components/konnected/__init__.py
new file mode 100644
index 00000000000000..c5de5224a5d610
--- /dev/null
+++ b/tests/components/konnected/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Konnected component."""
diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py
new file mode 100644
index 00000000000000..3638f40735be79
--- /dev/null
+++ b/tests/components/konnected/test_config_flow.py
@@ -0,0 +1,1150 @@
+"""Tests for Konnected Alarm Panel config flow."""
+from asynctest import patch
+import pytest
+
+from homeassistant.components import konnected
+from homeassistant.components.konnected import config_flow
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture(name="mock_panel")
+async def mock_panel_fixture():
+ """Mock a Konnected Panel bridge."""
+ with patch("konnected.Client", autospec=True) as konn_client:
+
+ def mock_constructor(host, port, websession):
+ """Fake the panel constructor."""
+ konn_client.host = host
+ konn_client.port = port
+ return konn_client
+
+ konn_client.side_effect = mock_constructor
+ konn_client.ClientError = config_flow.CannotConnect
+ yield konn_client
+
+
+async def test_flow_works(hass, mock_panel):
+ """Test config flow ."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected",
+ }
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"port": 1234, "host": "1.2.3.4"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ "model": "Konnected Alarm Panel",
+ "id": "112233445566",
+ "host": "1.2.3.4",
+ "port": 1234,
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"]["host"] == "1.2.3.4"
+ assert result["data"]["port"] == 1234
+ assert result["data"]["model"] == "Konnected"
+ assert len(result["data"]["access_token"]) == 20 # confirm generated token size
+ assert result["data"]["default_options"] == config_flow.OPTIONS_SCHEMA(
+ {config_flow.CONF_IO: {}}
+ )
+
+
+async def test_pro_flow_works(hass, mock_panel):
+ """Test config flow ."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"port": 1234, "host": "1.2.3.4"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ "model": "Konnected Alarm Panel Pro",
+ "id": "112233445566",
+ "host": "1.2.3.4",
+ "port": 1234,
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"]["host"] == "1.2.3.4"
+ assert result["data"]["port"] == 1234
+ assert result["data"]["model"] == "Konnected Pro"
+ assert len(result["data"]["access_token"]) == 20 # confirm generated token size
+ assert result["data"]["default_options"] == config_flow.OPTIONS_SCHEMA(
+ {config_flow.CONF_IO: {}}
+ )
+
+
+async def test_ssdp(hass, mock_panel):
+ """Test a panel being discovered."""
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ "ssdp_location": "http://1.2.3.4:1234/Device.xml",
+ "manufacturer": config_flow.KONN_MANUFACTURER,
+ "modelName": config_flow.KONN_MODEL,
+ },
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ "model": "Konnected Alarm Panel",
+ "id": "112233445566",
+ "host": "1.2.3.4",
+ "port": 1234,
+ }
+
+
+async def test_import_no_host_user_finish(hass, mock_panel):
+ """Test importing a panel with no host info."""
+ mock_panel.get_status.return_value = {
+ "mac": "aa:bb:cc:dd:ee:ff",
+ "model": "Konnected Pro",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "import"},
+ data={
+ "default_options": {
+ "blink": True,
+ "discovery": True,
+ "io": {
+ "1": "Disabled",
+ "10": "Disabled",
+ "11": "Disabled",
+ "12": "Disabled",
+ "2": "Disabled",
+ "3": "Disabled",
+ "4": "Disabled",
+ "5": "Disabled",
+ "6": "Disabled",
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "out": "Disabled",
+ "out1": "Disabled",
+ },
+ },
+ "id": "aabbccddeeff",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "import_confirm"
+ assert result["description_placeholders"]["id"] == "aabbccddeeff"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+
+ # confirm user is prompted to enter host
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"host": "1.1.1.1", "port": 1234}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ "model": "Konnected Alarm Panel Pro",
+ "id": "aabbccddeeff",
+ "host": "1.1.1.1",
+ "port": 1234,
+ }
+
+ # final confirmation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+
+
+async def test_import_ssdp_host_user_finish(hass, mock_panel):
+ """Test importing a panel with no host info which ssdp discovers."""
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "import"},
+ data={
+ "default_options": {
+ "blink": True,
+ "discovery": True,
+ "io": {
+ "1": "Disabled",
+ "10": "Disabled",
+ "11": "Disabled",
+ "12": "Disabled",
+ "2": "Disabled",
+ "3": "Disabled",
+ "4": "Disabled",
+ "5": "Disabled",
+ "6": "Disabled",
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "out": "Disabled",
+ "out1": "Disabled",
+ },
+ },
+ "id": "112233445566",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "import_confirm"
+ assert result["description_placeholders"]["id"] == "112233445566"
+
+ # discover the panel via ssdp
+ ssdp_result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ "ssdp_location": "http://0.0.0.0:1234/Device.xml",
+ "manufacturer": config_flow.KONN_MANUFACTURER,
+ "modelName": config_flow.KONN_MODEL_PRO,
+ },
+ )
+ assert ssdp_result["type"] == "abort"
+ assert ssdp_result["reason"] == "already_in_progress"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ "model": "Konnected Alarm Panel Pro",
+ "id": "112233445566",
+ "host": "0.0.0.0",
+ "port": 1234,
+ }
+
+ # final confirmation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+
+
+async def test_ssdp_already_configured(hass, mock_panel):
+ """Test if a discovered panel has already been configured."""
+ MockConfigEntry(
+ domain="konnected",
+ data={"host": "0.0.0.0", "port": 1234},
+ unique_id="112233445566",
+ ).add_to_hass(hass)
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ "ssdp_location": "http://0.0.0.0:1234/Device.xml",
+ "manufacturer": config_flow.KONN_MANUFACTURER,
+ "modelName": config_flow.KONN_MODEL_PRO,
+ },
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+
+async def test_ssdp_host_update(hass, mock_panel):
+ """Test if a discovered panel has already been configured but changed host."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "2": "Binary Sensor",
+ "6": "Binary Sensor",
+ "10": "Binary Sensor",
+ "3": "Digital Sensor",
+ "7": "Digital Sensor",
+ "11": "Digital Sensor",
+ "4": "Switchable Output",
+ "out1": "Switchable Output",
+ "alarm1": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "2", "type": "door"},
+ {"zone": "6", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "10", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "3", "type": "dht"},
+ {"zone": "7", "type": "ds18b20", "name": "temper"},
+ {"zone": "11", "type": "dht"},
+ ],
+ "switches": [
+ {"zone": "4"},
+ {
+ "zone": "8",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "out1"},
+ {"zone": "alarm1"},
+ ],
+ }
+ )
+
+ MockConfigEntry(
+ domain="konnected",
+ data=device_config,
+ options=device_options,
+ unique_id="112233445566",
+ ).add_to_hass(hass)
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "ssdp"},
+ data={
+ "ssdp_location": "http://1.1.1.1:1234/Device.xml",
+ "manufacturer": config_flow.KONN_MANUFACTURER,
+ "modelName": config_flow.KONN_MODEL_PRO,
+ },
+ )
+ assert result["type"] == "abort"
+
+ # confirm the host value was updated
+ entry = hass.config_entries.async_entries(config_flow.DOMAIN)[0]
+ assert entry.data["host"] == "1.1.1.1"
+ assert entry.data["port"] == 1234
+
+
+async def test_import_existing_config(hass, mock_panel):
+ """Test importing a host with an existing config file."""
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "import"},
+ data=konnected.DEVICE_SCHEMA_YAML(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "binary_sensors": [
+ {"zone": "2", "type": "door"},
+ {"zone": 6, "type": "window", "name": "winder", "inverse": True},
+ {"zone": "10", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "3", "type": "dht"},
+ {"zone": 7, "type": "ds18b20", "name": "temper"},
+ {"zone": "11", "type": "dht"},
+ ],
+ "switches": [
+ {"zone": "4"},
+ {
+ "zone": 8,
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "out1"},
+ {"zone": "alarm1"},
+ ],
+ }
+ ),
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": result["data"]["access_token"],
+ "default_options": {
+ "io": {
+ "1": "Disabled",
+ "5": "Disabled",
+ "9": "Disabled",
+ "12": "Disabled",
+ "out": "Disabled",
+ "alarm2_out2": "Disabled",
+ "2": "Binary Sensor",
+ "6": "Binary Sensor",
+ "10": "Binary Sensor",
+ "3": "Digital Sensor",
+ "7": "Digital Sensor",
+ "11": "Digital Sensor",
+ "4": "Switchable Output",
+ "8": "Switchable Output",
+ "out1": "Switchable Output",
+ "alarm1": "Switchable Output",
+ },
+ "blink": True,
+ "discovery": True,
+ "binary_sensors": [
+ {"zone": "2", "type": "door", "inverse": False},
+ {"zone": "6", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "10", "type": "door", "inverse": False},
+ ],
+ "sensors": [
+ {"zone": "3", "type": "dht", "poll_interval": 3},
+ {"zone": "7", "type": "ds18b20", "name": "temper", "poll_interval": 3},
+ {"zone": "11", "type": "dht", "poll_interval": 3},
+ ],
+ "switches": [
+ {"activation": "high", "zone": "4"},
+ {
+ "zone": "8",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"activation": "high", "zone": "out1"},
+ {"activation": "high", "zone": "alarm1"},
+ ],
+ },
+ }
+
+
+async def test_import_existing_config_entry(hass, mock_panel):
+ """Test importing a host that has an existing config entry."""
+ MockConfigEntry(
+ domain="konnected",
+ data={
+ "host": "0.0.0.0",
+ "port": 1111,
+ "id": "112233445566",
+ "extra": "something",
+ },
+ unique_id="112233445566",
+ ).add_to_hass(hass)
+
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+
+ # utilize a global access token this time
+ hass.data[config_flow.DOMAIN] = {"access_token": "SUPERSECRETTOKEN"}
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "import"},
+ data={
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "default_options": {
+ "blink": True,
+ "discovery": True,
+ "io": {
+ "1": "Disabled",
+ "10": "Binary Sensor",
+ "11": "Disabled",
+ "12": "Disabled",
+ "2": "Binary Sensor",
+ "3": "Disabled",
+ "4": "Disabled",
+ "5": "Disabled",
+ "6": "Binary Sensor",
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "out": "Disabled",
+ "out1": "Disabled",
+ },
+ "binary_sensors": [
+ {"inverse": False, "type": "door", "zone": "2"},
+ {"inverse": True, "type": "Window", "name": "winder", "zone": "6"},
+ {"inverse": False, "type": "door", "zone": "10"},
+ ],
+ },
+ },
+ )
+
+ assert result["type"] == "abort"
+
+ # We should have updated the entry
+ assert len(hass.config_entries.async_entries("konnected")) == 1
+ assert hass.config_entries.async_entries("konnected")[0].data == {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "SUPERSECRETTOKEN",
+ "extra": "something",
+ }
+
+
+async def test_import_pin_config(hass, mock_panel):
+ """Test importing a host with an existing config file that specifies pin configs."""
+ mock_panel.get_status.return_value = {
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": "import"},
+ data=konnected.DEVICE_SCHEMA_YAML(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "binary_sensors": [
+ {"pin": 1, "type": "door"},
+ {"pin": "2", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "3", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": 4, "type": "dht"},
+ {"pin": "7", "type": "ds18b20", "name": "temper"},
+ ],
+ "switches": [
+ {
+ "pin": "8",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "6"},
+ ],
+ }
+ ),
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": result["data"]["access_token"],
+ "default_options": {
+ "io": {
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "10": "Disabled",
+ "11": "Disabled",
+ "12": "Disabled",
+ "out1": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "1": "Binary Sensor",
+ "2": "Binary Sensor",
+ "3": "Binary Sensor",
+ "4": "Digital Sensor",
+ "5": "Digital Sensor",
+ "6": "Switchable Output",
+ "out": "Switchable Output",
+ },
+ "blink": True,
+ "discovery": True,
+ "binary_sensors": [
+ {"zone": "1", "type": "door", "inverse": False},
+ {"zone": "2", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "3", "type": "door", "inverse": False},
+ ],
+ "sensors": [
+ {"zone": "4", "type": "dht", "poll_interval": 3},
+ {"zone": "5", "type": "ds18b20", "name": "temper", "poll_interval": 3},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"activation": "high", "zone": "6"},
+ ],
+ },
+ }
+
+
+async def test_option_flow(hass, mock_panel):
+ """Test config flow options."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA({"io": {}})
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ data=device_config,
+ options=device_options,
+ unique_id="112233445566",
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, context={"source": "test"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_io"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "1": "Disabled",
+ "2": "Binary Sensor",
+ "3": "Digital Sensor",
+ "4": "Switchable Output",
+ "5": "Disabled",
+ "6": "Binary Sensor",
+ "out": "Switchable Output",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+ assert result["description_placeholders"] == {
+ "zone": "Zone 2",
+ }
+
+ # zone 2
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "door"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+ assert result["description_placeholders"] == {
+ "zone": "Zone 6",
+ }
+
+ # zone 6
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={"type": "window", "name": "winder", "inverse": True},
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_digital"
+ assert result["description_placeholders"] == {
+ "zone": "Zone 3",
+ }
+
+ # zone 3
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "dht"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+ assert result["description_placeholders"] == {
+ "zone": "Zone 4",
+ }
+
+ # zone 4
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+ assert result["description_placeholders"] == {
+ "zone": "OUT",
+ }
+
+ # zone out
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_misc"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"blink": True},
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ "io": {
+ "2": "Binary Sensor",
+ "3": "Digital Sensor",
+ "4": "Switchable Output",
+ "6": "Binary Sensor",
+ "out": "Switchable Output",
+ },
+ "blink": True,
+ "binary_sensors": [
+ {"zone": "2", "type": "door", "inverse": False},
+ {"zone": "6", "type": "window", "name": "winder", "inverse": True},
+ ],
+ "sensors": [{"zone": "3", "type": "dht", "poll_interval": 3}],
+ "switches": [
+ {"activation": "high", "zone": "4"},
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ ],
+ }
+
+
+async def test_option_flow_pro(hass, mock_panel):
+ """Test config flow options for pro board."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA({"io": {}})
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ data=device_config,
+ options=device_options,
+ unique_id="112233445566",
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, context={"source": "test"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_io"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "1": "Disabled",
+ "2": "Binary Sensor",
+ "3": "Digital Sensor",
+ "4": "Switchable Output",
+ "5": "Disabled",
+ "6": "Binary Sensor",
+ "7": "Digital Sensor",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_io_ext"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "8": "Switchable Output",
+ "9": "Disabled",
+ "10": "Binary Sensor",
+ "11": "Digital Sensor",
+ "12": "Disabled",
+ "out1": "Switchable Output",
+ "alarm1": "Switchable Output",
+ "alarm2_out2": "Disabled",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+
+ # zone 2
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "door"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+
+ # zone 6
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={"type": "window", "name": "winder", "inverse": True},
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+
+ # zone 10
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "door"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_digital"
+
+ # zone 3
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "dht"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_digital"
+
+ # zone 7
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "ds18b20", "name": "temper"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_digital"
+
+ # zone 11
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "dht"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+
+ # zone 4
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+
+ # zone 8
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+
+ # zone out1
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+
+ # zone alarm1
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_misc"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"blink": True},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ "io": {
+ "10": "Binary Sensor",
+ "11": "Digital Sensor",
+ "2": "Binary Sensor",
+ "3": "Digital Sensor",
+ "4": "Switchable Output",
+ "6": "Binary Sensor",
+ "7": "Digital Sensor",
+ "8": "Switchable Output",
+ "alarm1": "Switchable Output",
+ "out1": "Switchable Output",
+ },
+ "blink": True,
+ "binary_sensors": [
+ {"zone": "2", "type": "door", "inverse": False},
+ {"zone": "6", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "10", "type": "door", "inverse": False},
+ ],
+ "sensors": [
+ {"zone": "3", "type": "dht", "poll_interval": 3},
+ {"zone": "7", "type": "ds18b20", "name": "temper", "poll_interval": 3},
+ {"zone": "11", "type": "dht", "poll_interval": 3},
+ ],
+ "switches": [
+ {"activation": "high", "zone": "4"},
+ {
+ "zone": "8",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"activation": "high", "zone": "out1"},
+ {"activation": "high", "zone": "alarm1"},
+ ],
+ }
+
+
+async def test_option_flow_import(hass, mock_panel):
+ """Test config flow options imported from configuration.yaml."""
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Digital Sensor",
+ "3": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "window", "name": "winder", "inverse": True},
+ ],
+ "sensors": [{"zone": "2", "type": "ds18b20", "name": "temper"}],
+ "switches": [
+ {
+ "zone": "3",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ ],
+ }
+ )
+
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": device_options,
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected", data=device_config, unique_id="112233445566"
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, context={"source": "test"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_io"
+
+ # confirm the defaults are set based on current config - we"ll spot check this throughout
+ schema = result["data_schema"]({})
+ assert schema["1"] == "Binary Sensor"
+ assert schema["2"] == "Digital Sensor"
+ assert schema["3"] == "Switchable Output"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "1": "Binary Sensor",
+ "2": "Digital Sensor",
+ "3": "Switchable Output",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_io_ext"
+ schema = result["data_schema"]({})
+ assert schema["8"] == "Disabled"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={},
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+
+ # zone 1
+ schema = result["data_schema"]({})
+ assert schema["type"] == "window"
+ assert schema["name"] == "winder"
+ assert schema["inverse"] is True
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "door"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_digital"
+
+ # zone 2
+ schema = result["data_schema"]({})
+ assert schema["type"] == "ds18b20"
+ assert schema["name"] == "temper"
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "dht"},
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+
+ # zone 3
+ schema = result["data_schema"]({})
+ assert schema["name"] == "switcher"
+ assert schema["activation"] == "low"
+ assert schema["momentary"] == 50
+ assert schema["pause"] == 100
+ assert schema["repeat"] == 4
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"activation": "high"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_misc"
+
+ schema = result["data_schema"]({})
+ assert schema["blink"] is True
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"blink": False},
+ )
+
+ # verify the updated fields
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ "io": {"1": "Binary Sensor", "2": "Digital Sensor", "3": "Switchable Output"},
+ "blink": False,
+ "binary_sensors": [
+ {"zone": "1", "type": "door", "inverse": True, "name": "winder"},
+ ],
+ "sensors": [
+ {"zone": "2", "type": "dht", "poll_interval": 3, "name": "temper"},
+ ],
+ "switches": [
+ {
+ "zone": "3",
+ "name": "switcher",
+ "activation": "high",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ ],
+ }
+
+
+async def test_option_flow_existing(hass, mock_panel):
+ """Test config flow options with existing already in place."""
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Digital Sensor",
+ "3": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "window", "name": "winder", "inverse": True},
+ ],
+ "sensors": [{"zone": "2", "type": "ds18b20", "name": "temper"}],
+ "switches": [
+ {
+ "zone": "3",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ ],
+ }
+ )
+
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA({"io": {}}),
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ data=device_config,
+ options=device_options,
+ unique_id="112233445566",
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, context={"source": "test"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_io"
+
+ # confirm the defaults are pulled in from the existing options
+ schema = result["data_schema"]({})
+ assert schema["1"] == "Binary Sensor"
+ assert schema["2"] == "Digital Sensor"
+ assert schema["3"] == "Switchable Output"
diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py
new file mode 100644
index 00000000000000..907f83cd9815e9
--- /dev/null
+++ b/tests/components/konnected/test_init.py
@@ -0,0 +1,669 @@
+"""Test Konnected setup process."""
+from asynctest import patch
+import pytest
+
+from homeassistant.components import konnected
+from homeassistant.components.konnected import config_flow
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture(name="mock_panel")
+async def mock_panel_fixture():
+ """Mock a Konnected Panel bridge."""
+ with patch("konnected.Client", autospec=True) as konn_client:
+
+ def mock_constructor(host, port, websession):
+ """Fake the panel constructor."""
+ konn_client.host = host
+ konn_client.port = port
+ return konn_client
+
+ konn_client.side_effect = mock_constructor
+ konn_client.ClientError = config_flow.CannotConnect
+ konn_client.get_status.return_value = {
+ "hwVersion": "2.3.0",
+ "swVersion": "2.3.1",
+ "heap": 10000,
+ "uptime": 12222,
+ "ip": "192.168.1.90",
+ "port": 9123,
+ "sensors": [],
+ "actuators": [],
+ "dht_sensors": [],
+ "ds18b20_sensors": [],
+ "mac": "11:22:33:44:55:66",
+ "settings": {},
+ }
+ yield konn_client
+
+
+async def test_config_schema(hass):
+ """Test that config schema is imported properly."""
+ config = {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "abcdefgh",
+ konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
+ }
+ }
+ assert konnected.CONFIG_SCHEMA(config) == {
+ "konnected": {
+ "access_token": "abcdefgh",
+ "devices": [
+ {
+ "default_options": {
+ "blink": True,
+ "discovery": True,
+ "io": {
+ "1": "Disabled",
+ "10": "Disabled",
+ "11": "Disabled",
+ "12": "Disabled",
+ "2": "Disabled",
+ "3": "Disabled",
+ "4": "Disabled",
+ "5": "Disabled",
+ "6": "Disabled",
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "out": "Disabled",
+ "out1": "Disabled",
+ },
+ },
+ "id": "aabbccddeeff",
+ }
+ ],
+ }
+ }
+
+ # check with host info
+ config = {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "abcdefgh",
+ konnected.CONF_DEVICES: [
+ {konnected.CONF_ID: "aabbccddeeff", "host": "192.168.1.1", "port": 1234}
+ ],
+ }
+ }
+ assert konnected.CONFIG_SCHEMA(config) == {
+ "konnected": {
+ "access_token": "abcdefgh",
+ "devices": [
+ {
+ "default_options": {
+ "blink": True,
+ "discovery": True,
+ "io": {
+ "1": "Disabled",
+ "10": "Disabled",
+ "11": "Disabled",
+ "12": "Disabled",
+ "2": "Disabled",
+ "3": "Disabled",
+ "4": "Disabled",
+ "5": "Disabled",
+ "6": "Disabled",
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "out": "Disabled",
+ "out1": "Disabled",
+ },
+ },
+ "id": "aabbccddeeff",
+ "host": "192.168.1.1",
+ "port": 1234,
+ }
+ ],
+ }
+ }
+
+ # check pin to zone
+ config = {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "abcdefgh",
+ konnected.CONF_DEVICES: [
+ {
+ konnected.CONF_ID: "aabbccddeeff",
+ "binary_sensors": [
+ {"pin": 2, "type": "door"},
+ {"zone": 1, "type": "door"},
+ ],
+ }
+ ],
+ }
+ }
+ assert konnected.CONFIG_SCHEMA(config) == {
+ "konnected": {
+ "access_token": "abcdefgh",
+ "devices": [
+ {
+ "default_options": {
+ "blink": True,
+ "discovery": True,
+ "io": {
+ "1": "Binary Sensor",
+ "10": "Disabled",
+ "11": "Disabled",
+ "12": "Disabled",
+ "2": "Binary Sensor",
+ "3": "Disabled",
+ "4": "Disabled",
+ "5": "Disabled",
+ "6": "Disabled",
+ "7": "Disabled",
+ "8": "Disabled",
+ "9": "Disabled",
+ "alarm1": "Disabled",
+ "alarm2_out2": "Disabled",
+ "out": "Disabled",
+ "out1": "Disabled",
+ },
+ "binary_sensors": [
+ {"inverse": False, "type": "door", "zone": "2"},
+ {"inverse": False, "type": "door", "zone": "1"},
+ ],
+ },
+ "id": "aabbccddeeff",
+ }
+ ],
+ }
+ }
+
+
+async def test_setup_with_no_config(hass):
+ """Test that we do not discover anything or try to set up a Konnected panel."""
+ assert await async_setup_component(hass, konnected.DOMAIN, {})
+
+ # No flows started
+ assert len(hass.config_entries.flow.async_progress()) == 0
+
+ # Nothing saved from configuration.yaml
+ assert hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] is None
+ assert hass.data[konnected.DOMAIN][konnected.CONF_API_HOST] is None
+ assert konnected.YAML_CONFIGS not in hass.data[konnected.DOMAIN]
+
+
+async def test_setup_defined_hosts_known_auth(hass):
+ """Test we don't initiate a config entry if configured panel is known."""
+ MockConfigEntry(
+ domain="konnected",
+ unique_id="112233445566",
+ data={"host": "0.0.0.0", "id": "112233445566"},
+ ).add_to_hass(hass)
+ MockConfigEntry(
+ domain="konnected",
+ unique_id="aabbccddeeff",
+ data={"host": "1.2.3.4", "id": "aabbccddeeff"},
+ ).add_to_hass(hass)
+
+ assert (
+ await async_setup_component(
+ hass,
+ konnected.DOMAIN,
+ {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "abcdefgh",
+ konnected.CONF_DEVICES: [
+ {
+ config_flow.CONF_ID: "aabbccddeeff",
+ config_flow.CONF_HOST: "0.0.0.0",
+ config_flow.CONF_PORT: 1234,
+ },
+ ],
+ }
+ },
+ )
+ is True
+ )
+
+ assert hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] == "abcdefgh"
+ assert konnected.YAML_CONFIGS not in hass.data[konnected.DOMAIN]
+
+ # Flow aborted
+ assert len(hass.config_entries.flow.async_progress()) == 0
+
+
+async def test_setup_defined_hosts_no_known_auth(hass):
+ """Test we initiate config entry if config panel is not known."""
+ assert (
+ await async_setup_component(
+ hass,
+ konnected.DOMAIN,
+ {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "abcdefgh",
+ konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
+ }
+ },
+ )
+ is True
+ )
+
+ # Flow started for discovered bridge
+ assert len(hass.config_entries.flow.async_progress()) == 1
+
+
+async def test_setup_multiple(hass):
+ """Test we initiate config entry for multiple panels."""
+ assert (
+ await async_setup_component(
+ hass,
+ konnected.DOMAIN,
+ {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "arandomstringvalue",
+ konnected.CONF_API_HOST: "http://192.168.86.32:8123",
+ konnected.CONF_DEVICES: [
+ {
+ konnected.CONF_ID: "aabbccddeeff",
+ "binary_sensors": [
+ {
+ "zone": 4,
+ "type": "motion",
+ "name": "Hallway Motion",
+ },
+ {
+ "zone": 5,
+ "type": "window",
+ "name": "Master Bedroom Window",
+ },
+ {
+ "zone": 6,
+ "type": "window",
+ "name": "Downstairs Windows",
+ },
+ ],
+ "switches": [{"zone": "out", "name": "siren"}],
+ },
+ {
+ konnected.CONF_ID: "445566778899",
+ "binary_sensors": [
+ {"zone": 1, "type": "motion", "name": "Front"},
+ {"zone": 2, "type": "window", "name": "Back"},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "Buzzer",
+ "momentary": 65,
+ "pause": 55,
+ "repeat": 4,
+ },
+ ],
+ },
+ ],
+ }
+ },
+ )
+ is True
+ )
+
+ # Flow started for discovered bridge
+ assert len(hass.config_entries.flow.async_progress()) == 2
+
+ # Globals saved
+ assert (
+ hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] == "arandomstringvalue"
+ )
+ assert (
+ hass.data[konnected.DOMAIN][konnected.CONF_API_HOST]
+ == "http://192.168.86.32:8123"
+ )
+
+
+async def test_config_passed_to_config_entry(hass):
+ """Test that configured options for a host are loaded via config entry."""
+ entry = MockConfigEntry(
+ domain=konnected.DOMAIN,
+ data={config_flow.CONF_ID: "aabbccddeeff", config_flow.CONF_HOST: "0.0.0.0"},
+ )
+ entry.add_to_hass(hass)
+ with patch.object(konnected, "AlarmPanel", autospec=True) as mock_int:
+ assert (
+ await async_setup_component(
+ hass,
+ konnected.DOMAIN,
+ {
+ konnected.DOMAIN: {
+ konnected.CONF_ACCESS_TOKEN: "abcdefgh",
+ konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
+ }
+ },
+ )
+ is True
+ )
+
+ assert len(mock_int.mock_calls) == 3
+ p_hass, p_entry = mock_int.mock_calls[0][1]
+
+ assert p_hass is hass
+ assert p_entry is entry
+
+
+async def test_unload_entry(hass, mock_panel):
+ """Test being able to unload an entry."""
+ entry = MockConfigEntry(
+ domain=konnected.DOMAIN, data={konnected.CONF_ID: "aabbccddeeff"}
+ )
+ entry.add_to_hass(hass)
+
+ assert await async_setup_component(hass, konnected.DOMAIN, {}) is True
+ assert hass.data[konnected.DOMAIN]["devices"].get("aabbccddeeff") is not None
+ assert await konnected.async_unload_entry(hass, entry)
+ assert hass.data[konnected.DOMAIN]["devices"] == {}
+
+
+async def test_api(hass, aiohttp_client, mock_panel):
+ """Test callback view."""
+ await async_setup_component(hass, "http", {"http": {}})
+
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "abcdefgh",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Binary Sensor",
+ "3": "Binary Sensor",
+ "4": "Digital Sensor",
+ "5": "Digital Sensor",
+ "6": "Switchable Output",
+ "out": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "door"},
+ {"zone": "2", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "3", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "4", "type": "dht"},
+ {"zone": "5", "type": "ds18b20", "name": "temper"},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "6"},
+ ],
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Alarm Panel",
+ data=device_config,
+ options=device_options,
+ )
+ entry.add_to_hass(hass)
+
+ assert (
+ await async_setup_component(
+ hass,
+ konnected.DOMAIN,
+ {konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "globaltoken"}},
+ )
+ is True
+ )
+
+ client = await aiohttp_client(hass.http.app)
+
+ # Test the get endpoint for switch status polling
+ resp = await client.get("/api/konnected")
+ assert resp.status == 404 # no device provided
+
+ resp = await client.get("/api/konnected/223344556677")
+ assert resp.status == 404 # unknown device provided
+
+ resp = await client.get("/api/konnected/device/112233445566")
+ assert resp.status == 404 # no zone provided
+ result = await resp.json()
+ assert result == {"message": "Switch on zone or pin unknown not configured"}
+
+ resp = await client.get("/api/konnected/device/112233445566?zone=8")
+ assert resp.status == 404 # invalid zone
+ result = await resp.json()
+ assert result == {"message": "Switch on zone or pin 8 not configured"}
+
+ resp = await client.get("/api/konnected/device/112233445566?pin=12")
+ assert resp.status == 404 # invalid pin
+ result = await resp.json()
+ assert result == {"message": "Switch on zone or pin 12 not configured"}
+
+ resp = await client.get("/api/konnected/device/112233445566?zone=out")
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"state": 1, "zone": "out"}
+
+ resp = await client.get("/api/konnected/device/112233445566?pin=8")
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"state": 1, "pin": "8"}
+
+ # Test the post endpoint for sensor updates
+ resp = await client.post("/api/konnected/device", json={"zone": "1", "state": 1})
+ assert resp.status == 404
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566", json={"zone": "1", "state": 1}
+ )
+ assert resp.status == 401
+ result = await resp.json()
+ assert result == {"message": "unauthorized"}
+
+ resp = await client.post(
+ "/api/konnected/device/223344556677",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "1", "state": 1},
+ )
+ assert resp.status == 400
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "15", "state": 1},
+ )
+ assert resp.status == 400
+ result = await resp.json()
+ assert result == {"message": "unregistered sensor/actuator"}
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "1", "state": 1},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer globaltoken"},
+ json={"zone": "1", "state": 1},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "4", "temp": 22, "humi": 20},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+
+ # Test the put endpoint for sensor updates
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "1", "state": 1},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+
+
+async def test_state_updates(hass, aiohttp_client, mock_panel):
+ """Test callback view."""
+ await async_setup_component(hass, "http", {"http": {}})
+
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "abcdefgh",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Binary Sensor",
+ "3": "Binary Sensor",
+ "4": "Digital Sensor",
+ "5": "Digital Sensor",
+ "6": "Switchable Output",
+ "out": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "door"},
+ {"zone": "2", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "3", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "4", "type": "dht"},
+ {"zone": "5", "type": "ds18b20", "name": "temper"},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "6"},
+ ],
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Alarm Panel",
+ data=device_config,
+ options=device_options,
+ )
+ entry.add_to_hass(hass)
+
+ assert (
+ await async_setup_component(
+ hass,
+ konnected.DOMAIN,
+ {konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "1122334455"}},
+ )
+ is True
+ )
+
+ client = await aiohttp_client(hass.http.app)
+
+ # Test updating a binary sensor
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "1", "state": 0},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+ await hass.async_block_till_done()
+ assert hass.states.get("binary_sensor.konnected_445566_zone_1").state == "off"
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "1", "state": 1},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+ await hass.async_block_till_done()
+ assert hass.states.get("binary_sensor.konnected_445566_zone_1").state == "on"
+
+ # Test updating sht sensor
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "4", "temp": 22, "humi": 20},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+ await hass.async_block_till_done()
+ assert hass.states.get("sensor.konnected_445566_sensor_4_humidity").state == "20"
+ assert (
+ hass.states.get("sensor.konnected_445566_sensor_4_temperature").state == "22.0"
+ )
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "4", "temp": 25, "humi": 23},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+ await hass.async_block_till_done()
+ assert hass.states.get("sensor.konnected_445566_sensor_4_humidity").state == "23"
+ assert (
+ hass.states.get("sensor.konnected_445566_sensor_4_temperature").state == "25.0"
+ )
+
+ # Test updating ds sensor
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "5", "temp": 32, "addr": 1},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+ await hass.async_block_till_done()
+ assert hass.states.get("sensor.temper_temperature").state == "32.0"
+
+ resp = await client.post(
+ "/api/konnected/device/112233445566",
+ headers={"Authorization": "Bearer abcdefgh"},
+ json={"zone": "5", "temp": 42, "addr": 1},
+ )
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {"message": "ok"}
+ await hass.async_block_till_done()
+ assert hass.states.get("sensor.temper_temperature").state == "42.0"
diff --git a/tests/components/konnected/test_panel.py b/tests/components/konnected/test_panel.py
new file mode 100644
index 00000000000000..f1ae8a4357c002
--- /dev/null
+++ b/tests/components/konnected/test_panel.py
@@ -0,0 +1,553 @@
+"""Test Konnected setup process."""
+from asynctest import patch
+import pytest
+
+from homeassistant.components.konnected import config_flow, panel
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture(name="mock_panel")
+async def mock_panel_fixture():
+ """Mock a Konnected Panel bridge."""
+ with patch("konnected.Client", autospec=True) as konn_client:
+
+ def mock_constructor(host, port, websession):
+ """Fake the panel constructor."""
+ konn_client.host = host
+ konn_client.port = port
+ return konn_client
+
+ konn_client.side_effect = mock_constructor
+ konn_client.ClientError = config_flow.CannotConnect
+ konn_client.get_status.return_value = {
+ "hwVersion": "2.3.0",
+ "swVersion": "2.3.1",
+ "heap": 10000,
+ "uptime": 12222,
+ "ip": "192.168.1.90",
+ "port": 9123,
+ "sensors": [],
+ "actuators": [],
+ "dht_sensors": [],
+ "ds18b20_sensors": [],
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro", # `model` field only included in pro
+ "settings": {},
+ }
+ yield konn_client
+
+
+async def test_create_and_setup(hass, mock_panel):
+ """Test that we create a Konnected Panel and save the data."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Binary Sensor",
+ "3": "Binary Sensor",
+ "4": "Digital Sensor",
+ "5": "Digital Sensor",
+ "6": "Switchable Output",
+ "out": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "door"},
+ {"zone": "2", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "3", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "4", "type": "dht"},
+ {"zone": "5", "type": "ds18b20", "name": "temper"},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "6"},
+ ],
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Alarm Panel",
+ data=device_config,
+ options=device_options,
+ )
+ entry.add_to_hass(hass)
+
+ # override get_status to reflect non-pro board
+ mock_panel.get_status.return_value = {
+ "hwVersion": "2.3.0",
+ "swVersion": "2.3.1",
+ "heap": 10000,
+ "uptime": 12222,
+ "ip": "192.168.1.90",
+ "port": 9123,
+ "sensors": [],
+ "actuators": [],
+ "dht_sensors": [],
+ "ds18b20_sensors": [],
+ "mac": "11:22:33:44:55:66",
+ "settings": {},
+ }
+
+ # setup the integration and inspect panel behavior
+ assert (
+ await async_setup_component(
+ hass,
+ panel.DOMAIN,
+ {
+ panel.DOMAIN: {
+ panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
+ panel.CONF_API_HOST: "http://192.168.1.1:8123",
+ }
+ },
+ )
+ is True
+ )
+
+ # confirm panel instance was created and configured
+ # hass.data is the only mechanism to get a reference to the created panel instance
+ device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"]
+ await device.update_switch("1", 0)
+
+ # confirm the correct api is used
+ # pylint: disable=no-member
+ assert mock_panel.put_device.call_count == 1
+ assert mock_panel.put_zone.call_count == 0
+
+ # confirm the settings are sent to the panel
+ # pylint: disable=no-member
+ assert mock_panel.put_settings.call_args_list[0][1] == {
+ "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}],
+ "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}],
+ "dht_sensors": [{"poll_interval": 3, "pin": "6"}],
+ "ds18b20_sensors": [{"pin": "7"}],
+ "auth_token": "11223344556677889900",
+ "blink": True,
+ "discovery": True,
+ "endpoint": "http://192.168.1.1:8123/api/konnected",
+ }
+
+ # confirm the device settings are saved in hass.data
+ assert device.stored_configuration == {
+ "binary_sensors": {
+ "1": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 1",
+ "state": None,
+ "type": "door",
+ },
+ "2": {"inverse": True, "name": "winder", "state": None, "type": "window"},
+ "3": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 3",
+ "state": None,
+ "type": "door",
+ },
+ },
+ "blink": True,
+ "panel": device,
+ "discovery": True,
+ "host": "1.2.3.4",
+ "port": 1234,
+ "sensors": [
+ {
+ "name": "Konnected 445566 Sensor 4",
+ "poll_interval": 3,
+ "type": "dht",
+ "zone": "4",
+ },
+ {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"},
+ ],
+ "switches": [
+ {
+ "activation": "low",
+ "momentary": 50,
+ "name": "switcher",
+ "pause": 100,
+ "repeat": 4,
+ "state": None,
+ "zone": "out",
+ },
+ {
+ "activation": "high",
+ "momentary": None,
+ "name": "Konnected 445566 Actuator 6",
+ "pause": None,
+ "repeat": None,
+ "state": None,
+ "zone": "6",
+ },
+ ],
+ }
+
+
+async def test_create_and_setup_pro(hass, mock_panel):
+ """Test that we create a Konnected Pro Panel and save the data."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
+ }
+ )
+
+ device_options = config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "2": "Binary Sensor",
+ "6": "Binary Sensor",
+ "10": "Binary Sensor",
+ "3": "Digital Sensor",
+ "7": "Digital Sensor",
+ "11": "Digital Sensor",
+ "4": "Switchable Output",
+ "8": "Switchable Output",
+ "out1": "Switchable Output",
+ "alarm1": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "2", "type": "door"},
+ {"zone": "6", "type": "window", "name": "winder", "inverse": True},
+ {"zone": "10", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "3", "type": "dht"},
+ {"zone": "7", "type": "ds18b20", "name": "temper"},
+ {"zone": "11", "type": "dht", "poll_interval": 5},
+ ],
+ "switches": [
+ {"zone": "4"},
+ {
+ "zone": "8",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "out1"},
+ {"zone": "alarm1"},
+ ],
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Pro Alarm Panel",
+ data=device_config,
+ options=device_options,
+ )
+ entry.add_to_hass(hass)
+
+ # setup the integration and inspect panel behavior
+ assert (
+ await async_setup_component(
+ hass,
+ panel.DOMAIN,
+ {
+ panel.DOMAIN: {
+ panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
+ panel.CONF_API_HOST: "http://192.168.1.1:8123",
+ }
+ },
+ )
+ is True
+ )
+
+ # confirm panel instance was created and configured
+ # hass.data is the only mechanism to get a reference to the created panel instance
+ device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"]
+ await device.update_switch("2", 1)
+
+ # confirm the correct api is used
+ # pylint: disable=no-member
+ assert mock_panel.put_device.call_count == 0
+ assert mock_panel.put_zone.call_count == 1
+
+ # confirm the settings are sent to the panel
+ # pylint: disable=no-member
+ assert mock_panel.put_settings.call_args_list[0][1] == {
+ "sensors": [{"zone": "2"}, {"zone": "6"}, {"zone": "10"}],
+ "actuators": [
+ {"trigger": 1, "zone": "4"},
+ {"trigger": 0, "zone": "8"},
+ {"trigger": 1, "zone": "out1"},
+ {"trigger": 1, "zone": "alarm1"},
+ ],
+ "dht_sensors": [
+ {"poll_interval": 3, "zone": "3"},
+ {"poll_interval": 5, "zone": "11"},
+ ],
+ "ds18b20_sensors": [{"zone": "7"}],
+ "auth_token": "11223344556677889900",
+ "blink": True,
+ "discovery": True,
+ "endpoint": "http://192.168.1.1:8123/api/konnected",
+ }
+
+ # confirm the device settings are saved in hass.data
+ assert device.stored_configuration == {
+ "binary_sensors": {
+ "10": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 10",
+ "state": None,
+ "type": "door",
+ },
+ "2": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 2",
+ "state": None,
+ "type": "door",
+ },
+ "6": {"inverse": True, "name": "winder", "state": None, "type": "window"},
+ },
+ "blink": True,
+ "panel": device,
+ "discovery": True,
+ "host": "1.2.3.4",
+ "port": 1234,
+ "sensors": [
+ {
+ "name": "Konnected 445566 Sensor 3",
+ "poll_interval": 3,
+ "type": "dht",
+ "zone": "3",
+ },
+ {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "7"},
+ {
+ "name": "Konnected 445566 Sensor 11",
+ "poll_interval": 5,
+ "type": "dht",
+ "zone": "11",
+ },
+ ],
+ "switches": [
+ {
+ "activation": "high",
+ "momentary": None,
+ "name": "Konnected 445566 Actuator 4",
+ "pause": None,
+ "repeat": None,
+ "state": None,
+ "zone": "4",
+ },
+ {
+ "activation": "low",
+ "momentary": 50,
+ "name": "switcher",
+ "pause": 100,
+ "repeat": 4,
+ "state": None,
+ "zone": "8",
+ },
+ {
+ "activation": "high",
+ "momentary": None,
+ "name": "Konnected 445566 Actuator out1",
+ "pause": None,
+ "repeat": None,
+ "state": None,
+ "zone": "out1",
+ },
+ {
+ "activation": "high",
+ "momentary": None,
+ "name": "Konnected 445566 Actuator alarm1",
+ "pause": None,
+ "repeat": None,
+ "state": None,
+ "zone": "alarm1",
+ },
+ ],
+ }
+
+
+async def test_default_options(hass, mock_panel):
+ """Test that we create a Konnected Panel and save the data."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Binary Sensor",
+ "3": "Binary Sensor",
+ "4": "Digital Sensor",
+ "5": "Digital Sensor",
+ "6": "Switchable Output",
+ "out": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "door"},
+ {
+ "zone": "2",
+ "type": "window",
+ "name": "winder",
+ "inverse": True,
+ },
+ {"zone": "3", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "4", "type": "dht"},
+ {"zone": "5", "type": "ds18b20", "name": "temper"},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "6"},
+ ],
+ }
+ ),
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Alarm Panel",
+ data=device_config,
+ options={},
+ )
+ entry.add_to_hass(hass)
+
+ # override get_status to reflect non-pro board
+ mock_panel.get_status.return_value = {
+ "hwVersion": "2.3.0",
+ "swVersion": "2.3.1",
+ "heap": 10000,
+ "uptime": 12222,
+ "ip": "192.168.1.90",
+ "port": 9123,
+ "sensors": [],
+ "actuators": [],
+ "dht_sensors": [],
+ "ds18b20_sensors": [],
+ "mac": "11:22:33:44:55:66",
+ "settings": {},
+ }
+
+ # setup the integration and inspect panel behavior
+ assert (
+ await async_setup_component(
+ hass,
+ panel.DOMAIN,
+ {
+ panel.DOMAIN: {
+ panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
+ panel.CONF_API_HOST: "http://192.168.1.1:8123",
+ }
+ },
+ )
+ is True
+ )
+
+ # confirm panel instance was created and configured.
+ # hass.data is the only mechanism to get a reference to the created panel instance
+ device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"]
+ await device.update_switch("1", 0)
+
+ # confirm the correct api is used
+ # pylint: disable=no-member
+ assert mock_panel.put_device.call_count == 1
+ assert mock_panel.put_zone.call_count == 0
+
+ # confirm the settings are sent to the panel
+ # pylint: disable=no-member
+ assert mock_panel.put_settings.call_args_list[0][1] == {
+ "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}],
+ "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}],
+ "dht_sensors": [{"poll_interval": 3, "pin": "6"}],
+ "ds18b20_sensors": [{"pin": "7"}],
+ "auth_token": "11223344556677889900",
+ "blink": True,
+ "discovery": True,
+ "endpoint": "http://192.168.1.1:8123/api/konnected",
+ }
+
+ # confirm the device settings are saved in hass.data
+ assert device.stored_configuration == {
+ "binary_sensors": {
+ "1": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 1",
+ "state": None,
+ "type": "door",
+ },
+ "2": {"inverse": True, "name": "winder", "state": None, "type": "window"},
+ "3": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 3",
+ "state": None,
+ "type": "door",
+ },
+ },
+ "blink": True,
+ "panel": device,
+ "discovery": True,
+ "host": "1.2.3.4",
+ "port": 1234,
+ "sensors": [
+ {
+ "name": "Konnected 445566 Sensor 4",
+ "poll_interval": 3,
+ "type": "dht",
+ "zone": "4",
+ },
+ {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"},
+ ],
+ "switches": [
+ {
+ "activation": "low",
+ "momentary": 50,
+ "name": "switcher",
+ "pause": 100,
+ "repeat": 4,
+ "state": None,
+ "zone": "out",
+ },
+ {
+ "activation": "high",
+ "momentary": None,
+ "name": "Konnected 445566 Actuator 6",
+ "pause": None,
+ "repeat": None,
+ "state": None,
+ "zone": "6",
+ },
+ ],
+ }
diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py
index a3cf57a7dbe96c..3ac8171ce7da3e 100644
--- a/tests/components/light/test_device_action.py
+++ b/tests/components/light/test_device_action.py
@@ -2,7 +2,7 @@
import pytest
import homeassistant.components.automation as automation
-from homeassistant.components.light import DOMAIN
+from homeassistant.components.light import DOMAIN, SUPPORT_BRIGHTNESS
from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
@@ -30,7 +30,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
@@ -42,7 +42,13 @@ async def test_get_actions(hass, device_reg, entity_reg):
config_entry_id=config_entry.entry_id,
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
- entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ entity_reg.async_get_or_create(
+ DOMAIN,
+ "test",
+ "5678",
+ device_id=device_entry.id,
+ supported_features=SUPPORT_BRIGHTNESS,
+ )
expected_actions = [
{
"domain": DOMAIN,
@@ -62,6 +68,18 @@ async def test_get_actions(hass, device_reg, entity_reg):
"device_id": device_entry.id,
"entity_id": f"{DOMAIN}.test_5678",
},
+ {
+ "domain": DOMAIN,
+ "type": "brightness_increase",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "domain": DOMAIN,
+ "type": "brightness_decrease",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
]
actions = await async_get_device_automations(hass, "action", device_entry.id)
assert actions == expected_actions
@@ -108,6 +126,30 @@ async def test_action(hass, calls):
"type": "toggle",
},
},
+ {
+ "trigger": {
+ "platform": "event",
+ "event_type": "test_brightness_increase",
+ },
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "brightness_increase",
+ },
+ },
+ {
+ "trigger": {
+ "platform": "event",
+ "event_type": "test_brightness_decrease",
+ },
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "brightness_decrease",
+ },
+ },
]
},
)
@@ -138,3 +180,19 @@ async def test_action(hass, calls):
hass.bus.async_fire("test_event3")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_ON
+
+ turn_on_calls = async_mock_service(hass, DOMAIN, "turn_on")
+
+ hass.bus.async_fire("test_brightness_increase")
+ await hass.async_block_till_done()
+
+ assert len(turn_on_calls) == 1
+ assert turn_on_calls[0].data["entity_id"] == ent1.entity_id
+ assert turn_on_calls[0].data["brightness_step_pct"] == 10
+
+ hass.bus.async_fire("test_brightness_decrease")
+ await hass.async_block_till_done()
+
+ assert len(turn_on_calls) == 2
+ assert turn_on_calls[1].data["entity_id"] == ent1.entity_id
+ assert turn_on_calls[1].data["brightness_step_pct"] == -10
diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py
index 7a560dd781d7f6..24645a32611732 100644
--- a/tests/components/light/test_device_condition.py
+++ b/tests/components/light/test_device_condition.py
@@ -35,7 +35,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py
index dd8320c166e4a7..969b4278aebc1b 100644
--- a/tests/components/light/test_device_trigger.py
+++ b/tests/components/light/test_device_trigger.py
@@ -35,7 +35,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py
index 676fa4ec8498c6..49bc626a957428 100644
--- a/tests/components/light/test_init.py
+++ b/tests/components/light/test_init.py
@@ -462,3 +462,37 @@ async def test_light_turn_on_auth(hass, hass_admin_user):
True,
core.Context(user_id=hass_admin_user.id),
)
+
+
+async def test_light_brightness_step(hass):
+ """Test that light context works."""
+ platform = getattr(hass.components, "test.light")
+ platform.init()
+ entity = platform.ENTITIES[0]
+ entity.supported_features = light.SUPPORT_BRIGHTNESS
+ entity.brightness = 100
+ assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
+
+ state = hass.states.get(entity.entity_id)
+ assert state is not None
+ assert state.attributes["brightness"] == 100
+
+ await hass.services.async_call(
+ "light",
+ "turn_on",
+ {"entity_id": entity.entity_id, "brightness_step": -10},
+ True,
+ )
+
+ _, data = entity.last_call("turn_on")
+ assert data["brightness"] == 90, data
+
+ await hass.services.async_call(
+ "light",
+ "turn_on",
+ {"entity_id": entity.entity_id, "brightness_step_pct": 10},
+ True,
+ )
+
+ _, data = entity.last_call("turn_on")
+ assert data["brightness"] == 125, data
diff --git a/tests/components/linky/conftest.py b/tests/components/linky/conftest.py
new file mode 100644
index 00000000000000..f77f01a4ae7cb6
--- /dev/null
+++ b/tests/components/linky/conftest.py
@@ -0,0 +1,11 @@
+"""Linky generic test utils."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def patch_fakeuseragent():
+ """Stub out fake useragent dep that makes requests."""
+ with patch("pylinky.client.UserAgent", return_value="Test Browser"):
+ yield
diff --git a/tests/components/linky/test_config_flow.py b/tests/components/linky/test_config_flow.py
index 2b90c778a8f488..8278a77d4d0be5 100644
--- a/tests/components/linky/test_config_flow.py
+++ b/tests/components/linky/test_config_flow.py
@@ -1,5 +1,5 @@
"""Tests for the Linky config flow."""
-from unittest.mock import patch
+from unittest.mock import Mock, patch
from pylinky.exceptions import (
PyLinkyAccessException,
@@ -10,13 +10,15 @@
import pytest
from homeassistant import data_entry_flow
-from homeassistant.components.linky import config_flow
from homeassistant.components.linky.const import DEFAULT_TIMEOUT, DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
+from homeassistant.helpers.typing import HomeAssistantType
from tests.common import MockConfigEntry
-USERNAME = "username"
+USERNAME = "username@hotmail.fr"
+USERNAME_2 = "username@free.fr"
PASSWORD = "password"
TIMEOUT = 20
@@ -24,145 +26,158 @@
@pytest.fixture(name="login")
def mock_controller_login():
"""Mock a successful login."""
- with patch("pylinky.client.LinkyClient.login", return_value=True):
- yield
+ with patch(
+ "homeassistant.components.linky.config_flow.LinkyClient"
+ ) as service_mock:
+ service_mock.return_value.login = Mock(return_value=True)
+ service_mock.return_value.close_session = Mock(return_value=None)
+ yield service_mock
@pytest.fixture(name="fetch_data")
def mock_controller_fetch_data():
"""Mock a successful get data."""
- with patch("pylinky.client.LinkyClient.fetch_data", return_value={}):
- yield
-
-
-@pytest.fixture(name="close_session")
-def mock_controller_close_session():
- """Mock a successful closing session."""
- with patch("pylinky.client.LinkyClient.close_session", return_value=None):
- yield
-
-
-def init_config_flow(hass):
- """Init a configuration flow."""
- flow = config_flow.LinkyFlowHandler()
- flow.hass = hass
- return flow
+ with patch(
+ "homeassistant.components.linky.config_flow.LinkyClient"
+ ) as service_mock:
+ service_mock.return_value.fetch_data = Mock(return_value={})
+ service_mock.return_value.close_session = Mock(return_value=None)
+ yield service_mock
-async def test_user(hass, login, fetch_data, close_session):
+async def test_user(hass: HomeAssistantType, login, fetch_data):
"""Test user config."""
- flow = init_config_flow(hass)
-
- result = await flow.async_step_user()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=None
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
# test with all provided
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["result"].unique_id == USERNAME
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT
-async def test_import(hass, login, fetch_data, close_session):
+async def test_import(hass: HomeAssistantType, login, fetch_data):
"""Test import step."""
- flow = init_config_flow(hass)
-
# import with username and password
- result = await flow.async_step_import(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["result"].unique_id == USERNAME
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT
# import with all
- result = await flow.async_step_import(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_TIMEOUT: TIMEOUT}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_USERNAME: USERNAME_2,
+ CONF_PASSWORD: PASSWORD,
+ CONF_TIMEOUT: TIMEOUT,
+ },
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == USERNAME
- assert result["data"][CONF_USERNAME] == USERNAME
+ assert result["result"].unique_id == USERNAME_2
+ assert result["title"] == USERNAME_2
+ assert result["data"][CONF_USERNAME] == USERNAME_2
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_TIMEOUT] == TIMEOUT
-async def test_abort_if_already_setup(hass, login, fetch_data, close_session):
+async def test_abort_if_already_setup(hass: HomeAssistantType, login, fetch_data):
"""Test we abort if Linky is already setup."""
- flow = init_config_flow(hass)
MockConfigEntry(
- domain=DOMAIN, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
+ domain=DOMAIN,
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ unique_id=USERNAME,
).add_to_hass(hass)
# Should fail, same USERNAME (import)
- result = await flow.async_step_import(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "username_exists"
+ assert result["reason"] == "already_configured"
# Should fail, same USERNAME (flow)
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
)
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_USERNAME: "username_exists"}
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
-async def test_abort_on_login_failed(hass, close_session):
+async def test_login_failed(hass: HomeAssistantType, login):
"""Test when we have errors during login."""
- flow = init_config_flow(hass)
-
- with patch(
- "pylinky.client.LinkyClient.login", side_effect=PyLinkyAccessException()
- ):
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {"base": "access"}
-
- with patch(
- "pylinky.client.LinkyClient.login", side_effect=PyLinkyWrongLoginException()
- ):
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {"base": "wrong_login"}
+ login.return_value.login.side_effect = PyLinkyAccessException()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "access"}
+ hass.config_entries.flow.async_abort(result["flow_id"])
+
+ login.return_value.login.side_effect = PyLinkyWrongLoginException()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "wrong_login"}
+ hass.config_entries.flow.async_abort(result["flow_id"])
-async def test_abort_on_fetch_failed(hass, login, close_session):
+async def test_fetch_failed(hass: HomeAssistantType, login):
"""Test when we have errors during fetch."""
- flow = init_config_flow(hass)
-
- with patch(
- "pylinky.client.LinkyClient.fetch_data", side_effect=PyLinkyAccessException()
- ):
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {"base": "access"}
-
- with patch(
- "pylinky.client.LinkyClient.fetch_data", side_effect=PyLinkyEnedisException()
- ):
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {"base": "enedis"}
-
- with patch("pylinky.client.LinkyClient.fetch_data", side_effect=PyLinkyException()):
- result = await flow.async_step_user(
- {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {"base": "unknown"}
+ login.return_value.fetch_data.side_effect = PyLinkyAccessException()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "access"}
+ hass.config_entries.flow.async_abort(result["flow_id"])
+
+ login.return_value.fetch_data.side_effect = PyLinkyEnedisException()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "enedis"}
+ hass.config_entries.flow.async_abort(result["flow_id"])
+
+ login.return_value.fetch_data.side_effect = PyLinkyException()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "unknown"}
+ hass.config_entries.flow.async_abort(result["flow_id"])
diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py
index 638a7edf5d745d..c2db984f16f7b8 100644
--- a/tests/components/lock/test_device_condition.py
+++ b/tests/components/lock/test_device_condition.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py
index 781ed03307ba69..006df742c6d4f4 100644
--- a/tests/components/lock/test_device_trigger.py
+++ b/tests/components/lock/test_device_trigger.py
@@ -31,7 +31,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_dashboard.py
similarity index 58%
rename from tests/components/lovelace/test_init.py
rename to tests/components/lovelace/test_dashboard.py
index 9f1c62a8b13e52..9511e001197725 100644
--- a/tests/components/lovelace/test_init.py
+++ b/tests/components/lovelace/test_dashboard.py
@@ -1,7 +1,10 @@
"""Test the Lovelace initialization."""
from unittest.mock import patch
-from homeassistant.components import frontend, lovelace
+import pytest
+
+from homeassistant.components import frontend
+from homeassistant.components.lovelace import const, dashboard
from homeassistant.setup import async_setup_component
from tests.common import async_capture_events, get_system_health_info
@@ -21,14 +24,16 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage):
assert response["error"]["code"] == "config_not_found"
# Store new config
- events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED)
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
await client.send_json(
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
)
response = await client.receive_json()
assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": {"yo": "hello"}
+ }
assert len(events) == 1
# Load new config
@@ -38,6 +43,13 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage):
assert response["result"] == {"yo": "hello"}
+ # Test with safe mode
+ hass.config.safe_mode = True
+ await client.send_json({"id": 8, "type": "lovelace/config"})
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "config_not_found"
+
async def test_lovelace_from_storage_save_before_load(
hass, hass_ws_client, hass_storage
@@ -52,7 +64,9 @@ async def test_lovelace_from_storage_save_before_load(
)
response = await client.receive_json()
assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": {"yo": "hello"}
+ }
async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
@@ -66,13 +80,17 @@ async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
)
response = await client.receive_json()
assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": {"yo": "hello"}
+ }
# Delete config
await client.send_json({"id": 7, "type": "lovelace/config/delete"})
response = await client.receive_json()
assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": None}
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": None
+ }
# Fetch data
await client.send_json({"id": 8, "type": "lovelace/config"})
@@ -103,10 +121,11 @@ async def test_lovelace_from_yaml(hass, hass_ws_client):
assert not response["success"]
# Patch data
- events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED)
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
with patch(
- "homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo"}
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo"},
):
await client.send_json({"id": 7, "type": "lovelace/config"})
response = await client.receive_json()
@@ -118,7 +137,8 @@ async def test_lovelace_from_yaml(hass, hass_ws_client):
# Fake new data to see we fire event
with patch(
- "homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo2"}
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo2"},
):
await client.send_json({"id": 8, "type": "lovelace/config", "force": True})
response = await client.receive_json()
@@ -138,7 +158,7 @@ async def test_system_health_info_autogen(hass):
async def test_system_health_info_storage(hass, hass_storage):
"""Test system health info endpoint."""
- hass_storage[lovelace.STORAGE_KEY] = {
+ hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
"key": "lovelace",
"version": 1,
"data": {"config": {"resources": [], "views": []}},
@@ -152,7 +172,7 @@ async def test_system_health_info_yaml(hass):
"""Test system health info endpoint."""
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
with patch(
- "homeassistant.components.lovelace.load_yaml",
+ "homeassistant.components.lovelace.dashboard.load_yaml",
return_value={"views": [{"cards": []}]},
):
info = await get_system_health_info(hass, "lovelace")
@@ -167,3 +187,81 @@ async def test_system_health_info_yaml_not_found(hass):
"mode": "yaml",
"error": "{} not found".format(hass.config.path("ui-lovelace.yaml")),
}
+
+
+@pytest.mark.parametrize("url_path", ("test-panel", "test-panel-no-sidebar"))
+async def test_dashboard_from_yaml(hass, hass_ws_client, url_path):
+ """Test we load lovelace dashboard config from yaml."""
+ assert await async_setup_component(
+ hass,
+ "lovelace",
+ {
+ "lovelace": {
+ "dashboards": {
+ "test-panel": {
+ "mode": "yaml",
+ "filename": "bla.yaml",
+ "sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"},
+ },
+ "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"},
+ }
+ }
+ },
+ )
+ assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"}
+ assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == {
+ "mode": "yaml"
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path})
+ response = await client.receive_json()
+ assert not response["success"]
+
+ assert response["error"]["code"] == "config_not_found"
+
+ # Store new config not allowed
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "lovelace/config/save",
+ "config": {"yo": "hello"},
+ "url_path": url_path,
+ }
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+
+ # Patch data
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
+
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo"},
+ ):
+ await client.send_json(
+ {"id": 7, "type": "lovelace/config", "url_path": url_path}
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"hello": "yo"}
+
+ assert len(events) == 0
+
+ # Fake new data to see we fire event
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo2"},
+ ):
+ await client.send_json(
+ {"id": 8, "type": "lovelace/config", "force": True, "url_path": url_path}
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"hello": "yo2"}
+
+ assert len(events) == 1
diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py
new file mode 100644
index 00000000000000..89464d9535049c
--- /dev/null
+++ b/tests/components/lovelace/test_resources.py
@@ -0,0 +1,113 @@
+"""Test Lovelace resources."""
+import copy
+import uuid
+
+from asynctest import patch
+
+from homeassistant.components.lovelace import dashboard, resources
+from homeassistant.setup import async_setup_component
+
+RESOURCE_EXAMPLES = [
+ {"type": "js", "url": "/local/bla.js"},
+ {"type": "css", "url": "/local/bla.css"},
+]
+
+
+async def test_yaml_resources(hass, hass_ws_client):
+ """Test defining resources in configuration.yaml."""
+ assert await async_setup_component(
+ hass, "lovelace", {"lovelace": {"mode": "yaml", "resources": RESOURCE_EXAMPLES}}
+ )
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == RESOURCE_EXAMPLES
+
+
+async def test_yaml_resources_backwards(hass, hass_ws_client):
+ """Test defining resources in YAML ll config (legacy)."""
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"resources": RESOURCE_EXAMPLES},
+ ):
+ assert await async_setup_component(
+ hass, "lovelace", {"lovelace": {"mode": "yaml"}}
+ )
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == RESOURCE_EXAMPLES
+
+
+async def test_storage_resources(hass, hass_ws_client, hass_storage):
+ """Test defining resources in storage config."""
+ resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES]
+ hass_storage[resources.RESOURCE_STORAGE_KEY] = {
+ "key": resources.RESOURCE_STORAGE_KEY,
+ "version": 1,
+ "data": {"items": resource_config},
+ }
+ assert await async_setup_component(hass, "lovelace", {})
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == resource_config
+
+
+async def test_storage_resources_import(hass, hass_ws_client, hass_storage):
+ """Test importing resources from storage config."""
+ assert await async_setup_component(hass, "lovelace", {})
+ hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
+ "key": "lovelace",
+ "version": 1,
+ "data": {"config": {"resources": copy.deepcopy(RESOURCE_EXAMPLES)}},
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert (
+ response["result"]
+ == hass_storage[resources.RESOURCE_STORAGE_KEY]["data"]["items"]
+ )
+ assert (
+ "resources"
+ not in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"]
+ )
+
+
+async def test_storage_resources_import_invalid(hass, hass_ws_client, hass_storage):
+ """Test importing resources from storage config."""
+ assert await async_setup_component(hass, "lovelace", {})
+ hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
+ "key": "lovelace",
+ "version": 1,
+ "data": {"config": {"resources": [{"invalid": "resource"}]}},
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == []
+ assert (
+ "resources"
+ in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"]
+ )
diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py
index 810998ec0b8000..d8a96b2db52e66 100644
--- a/tests/components/marytts/test_tts.py
+++ b/tests/components/marytts/test_tts.py
@@ -66,7 +66,12 @@ def test_service_say(self):
with patch("http.client.HTTPConnection", return_value=conn):
self.hass.services.call(
- tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "marytts_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ },
)
self.hass.block_till_done()
@@ -93,7 +98,12 @@ def test_service_say_with_effect(self):
with patch("http.client.HTTPConnection", return_value=conn):
self.hass.services.call(
- tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "marytts_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ },
)
self.hass.block_till_done()
@@ -123,7 +133,12 @@ def test_service_say_http_error(self):
with patch("http.client.HTTPConnection", return_value=conn):
self.hass.services.call(
- tts.DOMAIN, "marytts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "marytts_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ },
)
self.hass.block_till_done()
diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py
index 333cc4a2b13b58..c52daa80320b61 100644
--- a/tests/components/media_player/test_device_condition.py
+++ b/tests/components/media_player/test_device_condition.py
@@ -37,7 +37,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/melcloud/__init__.py b/tests/components/melcloud/__init__.py
new file mode 100644
index 00000000000000..f20383660d4bd6
--- /dev/null
+++ b/tests/components/melcloud/__init__.py
@@ -0,0 +1 @@
+"""Tests for the MELCloud integration."""
diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py
new file mode 100644
index 00000000000000..90c766f0831acc
--- /dev/null
+++ b/tests/components/melcloud/test_config_flow.py
@@ -0,0 +1,171 @@
+"""Test the MELCloud config flow."""
+import asyncio
+
+from aiohttp import ClientError, ClientResponseError
+from asynctest import patch
+import pymelcloud
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.melcloud.const import DOMAIN
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_login():
+ """Mock pymelcloud login."""
+ with patch("pymelcloud.login") as mock:
+ mock.return_value = "test-token"
+ yield mock
+
+
+@pytest.fixture
+def mock_get_devices():
+ """Mock pymelcloud get_devices."""
+ with patch("pymelcloud.get_devices") as mock:
+ mock.return_value = {
+ pymelcloud.DEVICE_TYPE_ATA: [],
+ pymelcloud.DEVICE_TYPE_ATW: [],
+ }
+ yield mock
+
+
+@pytest.fixture
+def mock_request_info():
+ """Mock RequestInfo to create ClientResponseErrors."""
+ with patch("aiohttp.RequestInfo") as mock_ri:
+ mock_ri.return_value.real_url.return_value = ""
+ yield mock_ri
+
+
+async def test_form(hass, mock_login, mock_get_devices):
+ """Test we get the form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] is None
+
+ with patch(
+ "homeassistant.components.melcloud.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.melcloud.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"username": "test-email@test-domain.com", "password": "test-password"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "test-email@test-domain.com"
+ assert result2["data"] == {
+ "username": "test-email@test-domain.com",
+ "token": "test-token",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ "error,reason",
+ [(ClientError(), "cannot_connect"), (asyncio.TimeoutError(), "cannot_connect")],
+)
+async def test_form_errors(hass, mock_login, mock_get_devices, error, reason):
+ """Test we handle cannot connect error."""
+ mock_login.side_effect = error
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={"username": "test-email@test-domain.com", "password": "test-password"},
+ )
+
+ assert len(mock_login.mock_calls) == 1
+ assert result["type"] == "abort"
+ assert result["reason"] == reason
+
+
+@pytest.mark.parametrize(
+ "error,message",
+ [(401, "invalid_auth"), (403, "invalid_auth"), (500, "cannot_connect")],
+)
+async def test_form_response_errors(
+ hass, mock_login, mock_get_devices, mock_request_info, error, message
+):
+ """Test we handle response errors."""
+ mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={"username": "test-email@test-domain.com", "password": "test-password"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == message
+
+
+async def test_import_with_token(hass, mock_login, mock_get_devices):
+ """Test successful import."""
+ with patch(
+ "homeassistant.components.melcloud.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.melcloud.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"username": "test-email@test-domain.com", "token": "test-token"},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "test-email@test-domain.com"
+ assert result["data"] == {
+ "username": "test-email@test-domain.com",
+ "token": "test-token",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_token_refresh(hass, mock_login, mock_get_devices):
+ """Re-configuration with existing username should refresh token."""
+ mock_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ "username": "test-email@test-domain.com",
+ "token": "test-original-token",
+ },
+ unique_id="test-email@test-domain.com",
+ )
+ mock_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.melcloud.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.melcloud.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={
+ "username": "test-email@test-domain.com",
+ "password": "test-password",
+ },
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 0
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+
+ entry = entries[0]
+ assert entry.data["username"] == "test-email@test-domain.com"
+ assert entry.data["token"] == "test-token"
diff --git a/tests/components/meteo_france/__init__.py b/tests/components/meteo_france/__init__.py
new file mode 100644
index 00000000000000..c4d4c446574381
--- /dev/null
+++ b/tests/components/meteo_france/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Meteo-France component."""
diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py
new file mode 100644
index 00000000000000..088587ab2c2896
--- /dev/null
+++ b/tests/components/meteo_france/conftest.py
@@ -0,0 +1,16 @@
+"""Meteo-France generic test utils."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def patch_requests():
+ """Stub out services that makes requests."""
+ patch_client = patch("homeassistant.components.meteo_france.meteofranceClient")
+ patch_weather_alert = patch(
+ "homeassistant.components.meteo_france.VigilanceMeteoFranceProxy"
+ )
+
+ with patch_client, patch_weather_alert:
+ yield
diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py
new file mode 100644
index 00000000000000..f9ead2c1ef394f
--- /dev/null
+++ b/tests/components/meteo_france/test_config_flow.py
@@ -0,0 +1,128 @@
+"""Tests for the Meteo-France config flow."""
+from unittest.mock import patch
+
+from meteofrance.client import meteofranceError
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+
+from tests.common import MockConfigEntry
+
+CITY_1_POSTAL = "74220"
+CITY_1_NAME = "La Clusaz"
+CITY_2_POSTAL_DISTRICT_1 = "69001"
+CITY_2_POSTAL_DISTRICT_4 = "69004"
+CITY_2_NAME = "Lyon"
+
+
+@pytest.fixture(name="client_1")
+def mock_controller_client_1():
+ """Mock a successful client."""
+ with patch(
+ "homeassistant.components.meteo_france.config_flow.meteofranceClient",
+ update=False,
+ ) as service_mock:
+ service_mock.return_value.get_data.return_value = {"name": CITY_1_NAME}
+ yield service_mock
+
+
+@pytest.fixture(name="client_2")
+def mock_controller_client_2():
+ """Mock a successful client."""
+ with patch(
+ "homeassistant.components.meteo_france.config_flow.meteofranceClient",
+ update=False,
+ ) as service_mock:
+ service_mock.return_value.get_data.return_value = {"name": CITY_2_NAME}
+ yield service_mock
+
+
+async def test_user(hass, client_1):
+ """Test user config."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+ # test with all provided
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["result"].unique_id == CITY_1_NAME
+ assert result["title"] == CITY_1_NAME
+ assert result["data"][CONF_CITY] == CITY_1_POSTAL
+
+
+async def test_import(hass, client_1):
+ """Test import step."""
+ # import with all
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_1_POSTAL},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["result"].unique_id == CITY_1_NAME
+ assert result["title"] == CITY_1_NAME
+ assert result["data"][CONF_CITY] == CITY_1_POSTAL
+
+
+async def test_abort_if_already_setup(hass, client_1):
+ """Test we abort if already setup."""
+ MockConfigEntry(
+ domain=DOMAIN, data={CONF_CITY: CITY_1_POSTAL}, unique_id=CITY_1_NAME
+ ).add_to_hass(hass)
+
+ # Should fail, same CITY same postal code (import)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_1_POSTAL},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+ # Should fail, same CITY same postal code (flow)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_abort_if_already_setup_district(hass, client_2):
+ """Test we abort if already setup."""
+ MockConfigEntry(
+ domain=DOMAIN, data={CONF_CITY: CITY_2_POSTAL_DISTRICT_1}, unique_id=CITY_2_NAME
+ ).add_to_hass(hass)
+
+ # Should fail, same CITY different postal code (import)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+ # Should fail, same CITY different postal code (flow)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_client_failed(hass):
+ """Test when we have errors during client fetch."""
+ with patch(
+ "homeassistant.components.meteo_france.config_flow.meteofranceClient",
+ side_effect=meteofranceError(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "unknown"
diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py
index 5eab93a30ffad5..45413a10cda204 100644
--- a/tests/components/mhz19/test_sensor.py
+++ b/tests/components/mhz19/test_sensor.py
@@ -4,7 +4,7 @@
import homeassistant.components.mhz19.sensor as mhz19
from homeassistant.components.sensor import DOMAIN
-from homeassistant.const import TEMP_FAHRENHEIT
+from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, TEMP_FAHRENHEIT
from homeassistant.setup import setup_component
from tests.common import assert_setup_component, get_test_home_assistant
@@ -100,7 +100,7 @@ def test_co2_sensor(self, mock_function):
assert "name: CO2" == sensor.name
assert 1000 == sensor.state
- assert "ppm" == sensor.unit_of_measurement
+ assert CONCENTRATION_PARTS_PER_MILLION == sensor.unit_of_measurement
assert sensor.should_poll
assert {"temperature": 24} == sensor.device_state_attributes
diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py
new file mode 100644
index 00000000000000..ae8013eff4b58d
--- /dev/null
+++ b/tests/components/mikrotik/__init__.py
@@ -0,0 +1,133 @@
+"""Tests for the Mikrotik component."""
+from homeassistant.components import mikrotik
+
+MOCK_DATA = {
+ mikrotik.CONF_NAME: "Mikrotik",
+ mikrotik.CONF_HOST: "0.0.0.0",
+ mikrotik.CONF_USERNAME: "user",
+ mikrotik.CONF_PASSWORD: "pass",
+ mikrotik.CONF_PORT: 8278,
+ mikrotik.CONF_VERIFY_SSL: False,
+}
+
+MOCK_OPTIONS = {
+ mikrotik.CONF_ARP_PING: False,
+ mikrotik.const.CONF_FORCE_DHCP: False,
+ mikrotik.CONF_DETECTION_TIME: mikrotik.DEFAULT_DETECTION_TIME,
+}
+
+DEVICE_1_DHCP = {
+ ".id": "*1A",
+ "address": "0.0.0.1",
+ "mac-address": "00:00:00:00:00:01",
+ "active-address": "0.0.0.1",
+ "host-name": "Device_1",
+ "comment": "Mobile",
+}
+DEVICE_2_DHCP = {
+ ".id": "*1B",
+ "address": "0.0.0.2",
+ "mac-address": "00:00:00:00:00:02",
+ "active-address": "0.0.0.2",
+ "host-name": "Device_2",
+ "comment": "PC",
+}
+DEVICE_1_WIRELESS = {
+ ".id": "*264",
+ "interface": "wlan1",
+ "mac-address": "00:00:00:00:00:01",
+ "ap": False,
+ "wds": False,
+ "bridge": False,
+ "rx-rate": "72.2Mbps-20MHz/1S/SGI",
+ "tx-rate": "72.2Mbps-20MHz/1S/SGI",
+ "packets": "59542,17464",
+ "bytes": "17536671,2966351",
+ "frames": "59542,17472",
+ "frame-bytes": "17655785,2862445",
+ "hw-frames": "78935,38395",
+ "hw-frame-bytes": "25636019,4063445",
+ "tx-frames-timed-out": 0,
+ "uptime": "5h49m36s",
+ "last-activity": "170ms",
+ "signal-strength": "-62@1Mbps",
+ "signal-to-noise": 52,
+ "signal-strength-ch0": -63,
+ "signal-strength-ch1": -69,
+ "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms",
+ "tx-ccq": 93,
+ "p-throughput": 54928,
+ "last-ip": "0.0.0.1",
+ "802.1x-port-enabled": True,
+ "authentication-type": "wpa2-psk",
+ "encryption": "aes-ccm",
+ "group-encryption": "aes-ccm",
+ "management-protection": False,
+ "wmm-enabled": True,
+ "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7",
+}
+
+DEVICE_2_WIRELESS = {
+ ".id": "*265",
+ "interface": "wlan1",
+ "mac-address": "00:00:00:00:00:02",
+ "ap": False,
+ "wds": False,
+ "bridge": False,
+ "rx-rate": "72.2Mbps-20MHz/1S/SGI",
+ "tx-rate": "72.2Mbps-20MHz/1S/SGI",
+ "packets": "59542,17464",
+ "bytes": "17536671,2966351",
+ "frames": "59542,17472",
+ "frame-bytes": "17655785,2862445",
+ "hw-frames": "78935,38395",
+ "hw-frame-bytes": "25636019,4063445",
+ "tx-frames-timed-out": 0,
+ "uptime": "5h49m36s",
+ "last-activity": "170ms",
+ "signal-strength": "-62@1Mbps",
+ "signal-to-noise": 52,
+ "signal-strength-ch0": -63,
+ "signal-strength-ch1": -69,
+ "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms",
+ "tx-ccq": 93,
+ "p-throughput": 54928,
+ "last-ip": "0.0.0.2",
+ "802.1x-port-enabled": True,
+ "authentication-type": "wpa2-psk",
+ "encryption": "aes-ccm",
+ "group-encryption": "aes-ccm",
+ "management-protection": False,
+ "wmm-enabled": True,
+ "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7",
+}
+DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP]
+
+WIRELESS_DATA = [DEVICE_1_WIRELESS]
+
+ARP_DATA = [
+ {
+ ".id": "*1",
+ "address": "0.0.0.1",
+ "mac-address": "00:00:00:00:00:01",
+ "interface": "bridge",
+ "published": False,
+ "invalid": False,
+ "DHCP": True,
+ "dynamic": True,
+ "complete": True,
+ "disabled": False,
+ },
+ {
+ ".id": "*2",
+ "address": "0.0.0.2",
+ "mac-address": "00:00:00:00:00:02",
+ "interface": "bridge",
+ "published": False,
+ "invalid": False,
+ "DHCP": True,
+ "dynamic": True,
+ "complete": True,
+ "disabled": False,
+ },
+]
diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py
new file mode 100644
index 00000000000000..37dbfad4d35c14
--- /dev/null
+++ b/tests/components/mikrotik/test_config_flow.py
@@ -0,0 +1,208 @@
+"""Test Mikrotik setup process."""
+from datetime import timedelta
+from unittest.mock import patch
+
+import librouteros
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import mikrotik
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+
+from tests.common import MockConfigEntry
+
+DEMO_USER_INPUT = {
+ CONF_NAME: "Home router",
+ CONF_HOST: "0.0.0.0",
+ CONF_USERNAME: "username",
+ CONF_PASSWORD: "password",
+ CONF_PORT: 8278,
+ CONF_VERIFY_SSL: False,
+}
+
+DEMO_CONFIG = {
+ CONF_NAME: "Home router",
+ CONF_HOST: "0.0.0.0",
+ CONF_USERNAME: "username",
+ CONF_PASSWORD: "password",
+ CONF_PORT: 8278,
+ CONF_VERIFY_SSL: False,
+ mikrotik.const.CONF_FORCE_DHCP: False,
+ mikrotik.CONF_ARP_PING: False,
+ mikrotik.CONF_DETECTION_TIME: timedelta(seconds=30),
+}
+
+DEMO_CONFIG_ENTRY = {
+ CONF_NAME: "Home router",
+ CONF_HOST: "0.0.0.0",
+ CONF_USERNAME: "username",
+ CONF_PASSWORD: "password",
+ CONF_PORT: 8278,
+ CONF_VERIFY_SSL: False,
+ mikrotik.const.CONF_FORCE_DHCP: False,
+ mikrotik.CONF_ARP_PING: False,
+ mikrotik.CONF_DETECTION_TIME: 30,
+}
+
+
+@pytest.fixture(name="api")
+def mock_mikrotik_api():
+ """Mock an api."""
+ with patch("librouteros.connect"):
+ yield
+
+
+@pytest.fixture(name="auth_error")
+def mock_api_authentication_error():
+ """Mock an api."""
+ with patch(
+ "librouteros.connect",
+ side_effect=librouteros.exceptions.TrapError("invalid user name or password"),
+ ):
+ yield
+
+
+@pytest.fixture(name="conn_error")
+def mock_api_connection_error():
+ """Mock an api."""
+ with patch(
+ "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed
+ ):
+ yield
+
+
+async def test_import(hass, api):
+ """Test import step."""
+ result = await hass.config_entries.flow.async_init(
+ mikrotik.DOMAIN, context={"source": "import"}, data=DEMO_CONFIG
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "Home router"
+ assert result["data"][CONF_NAME] == "Home router"
+ assert result["data"][CONF_HOST] == "0.0.0.0"
+ assert result["data"][CONF_USERNAME] == "username"
+ assert result["data"][CONF_PASSWORD] == "password"
+ assert result["data"][CONF_PORT] == 8278
+ assert result["data"][CONF_VERIFY_SSL] is False
+
+
+async def test_flow_works(hass, api):
+ """Test config flow."""
+
+ result = await hass.config_entries.flow.async_init(
+ mikrotik.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "Home router"
+ assert result["data"][CONF_NAME] == "Home router"
+ assert result["data"][CONF_HOST] == "0.0.0.0"
+ assert result["data"][CONF_USERNAME] == "username"
+ assert result["data"][CONF_PASSWORD] == "password"
+ assert result["data"][CONF_PORT] == 8278
+
+
+async def test_options(hass):
+ """Test updating options."""
+ entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY)
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "device_tracker"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ mikrotik.CONF_DETECTION_TIME: 30,
+ mikrotik.CONF_ARP_PING: True,
+ mikrotik.const.CONF_FORCE_DHCP: False,
+ },
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"] == {
+ mikrotik.CONF_DETECTION_TIME: 30,
+ mikrotik.CONF_ARP_PING: True,
+ mikrotik.const.CONF_FORCE_DHCP: False,
+ }
+
+
+async def test_host_already_configured(hass, auth_error):
+ """Test host already configured."""
+
+ entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY)
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ mikrotik.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+
+async def test_name_exists(hass, api):
+ """Test name already configured."""
+
+ entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY)
+ entry.add_to_hass(hass)
+ user_input = DEMO_USER_INPUT.copy()
+ user_input[CONF_HOST] = "0.0.0.1"
+
+ result = await hass.config_entries.flow.async_init(
+ mikrotik.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=user_input
+ )
+
+ assert result["type"] == "form"
+ assert result["errors"] == {CONF_NAME: "name_exists"}
+
+
+async def test_connection_error(hass, conn_error):
+ """Test error when connection is unsuccessful."""
+
+ result = await hass.config_entries.flow.async_init(
+ mikrotik.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_wrong_credentials(hass, auth_error):
+ """Test error when credentials are wrong."""
+
+ result = await hass.config_entries.flow.async_init(
+ mikrotik.DOMAIN, context={"source": "user"}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=DEMO_USER_INPUT
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {
+ CONF_USERNAME: "wrong_credentials",
+ CONF_PASSWORD: "wrong_credentials",
+ }
diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py
new file mode 100644
index 00000000000000..643f94a5ad5d93
--- /dev/null
+++ b/tests/components/mikrotik/test_device_tracker.py
@@ -0,0 +1,118 @@
+"""The tests for the Mikrotik device tracker platform."""
+from datetime import timedelta
+
+from homeassistant.components import mikrotik
+import homeassistant.components.device_tracker as device_tracker
+from homeassistant.helpers import entity_registry
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA
+from .test_hub import setup_mikrotik_entry
+
+from tests.common import MockConfigEntry, patch
+
+DEFAULT_DETECTION_TIME = timedelta(seconds=300)
+
+
+def mock_command(self, cmd, params=None):
+ """Mock the Mikrotik command method."""
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]:
+ return True
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]:
+ return DHCP_DATA
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]:
+ return WIRELESS_DATA
+ return {}
+
+
+async def test_platform_manually_configured(hass):
+ """Test that nothing happens when configuring mikrotik through device tracker platform."""
+ assert (
+ await async_setup_component(
+ hass,
+ device_tracker.DOMAIN,
+ {device_tracker.DOMAIN: {"platform": "mikrotik"}},
+ )
+ is False
+ )
+ assert mikrotik.DOMAIN not in hass.data
+
+
+async def test_device_trackers(hass):
+ """Test device_trackers created by mikrotik."""
+
+ # test devices are added from wireless list only
+ hub = await setup_mikrotik_entry(hass)
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+ assert device_1.state == "home"
+ device_2 = hass.states.get("device_tracker.device_2")
+ assert device_2 is None
+
+ with patch.object(mikrotik.hub.MikrotikData, "command", new=mock_command):
+ # test device_2 is added after connecting to wireless network
+ WIRELESS_DATA.append(DEVICE_2_WIRELESS)
+
+ await hub.async_update()
+ await hass.async_block_till_done()
+
+ device_2 = hass.states.get("device_tracker.device_2")
+ assert device_2 is not None
+ assert device_2.state == "home"
+
+ # test state remains home if last_seen consider_home_interval
+ del WIRELESS_DATA[1] # device 2 is removed from wireless list
+ hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta(
+ minutes=4
+ )
+ await hub.async_update()
+ await hass.async_block_till_done()
+
+ device_2 = hass.states.get("device_tracker.device_2")
+ assert device_2.state != "not_home"
+
+ # test state changes to away if last_seen > consider_home_interval
+ hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta(
+ minutes=5
+ )
+ await hub.async_update()
+ await hass.async_block_till_done()
+
+ device_2 = hass.states.get("device_tracker.device_2")
+ assert device_2.state == "not_home"
+
+
+async def test_restoring_devices(hass):
+ """Test restoring existing device_tracker entities if not detected on startup."""
+ config_entry = MockConfigEntry(
+ domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS
+ )
+ config_entry.add_to_hass(hass)
+
+ registry = await entity_registry.async_get_registry(hass)
+ registry.async_get_or_create(
+ device_tracker.DOMAIN,
+ mikrotik.DOMAIN,
+ "00:00:00:00:00:01",
+ suggested_object_id="device_1",
+ config_entry=config_entry,
+ )
+ registry.async_get_or_create(
+ device_tracker.DOMAIN,
+ mikrotik.DOMAIN,
+ "00:00:00:00:00:02",
+ suggested_object_id="device_2",
+ config_entry=config_entry,
+ )
+
+ await setup_mikrotik_entry(hass)
+
+ # test device_2 which is not in wireless list is restored
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+ assert device_1.state == "home"
+ device_2 = hass.states.get("device_tracker.device_2")
+ assert device_2 is not None
+ assert device_2.state == "not_home"
diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py
new file mode 100644
index 00000000000000..fc37c9113aee16
--- /dev/null
+++ b/tests/components/mikrotik/test_hub.py
@@ -0,0 +1,179 @@
+"""Test Mikrotik hub."""
+from asynctest import patch
+import librouteros
+
+from homeassistant import config_entries
+from homeassistant.components import mikrotik
+
+from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA
+
+from tests.common import MockConfigEntry
+
+
+async def setup_mikrotik_entry(hass, **kwargs):
+ """Set up Mikrotik intergation successfully."""
+ support_wireless = kwargs.get("support_wireless", True)
+ dhcp_data = kwargs.get("dhcp_data", DHCP_DATA)
+ wireless_data = kwargs.get("wireless_data", WIRELESS_DATA)
+
+ def mock_command(self, cmd, params=None):
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]:
+ return support_wireless
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]:
+ return dhcp_data
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]:
+ return wireless_data
+ if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]:
+ return ARP_DATA
+ return {}
+
+ config_entry = MockConfigEntry(
+ domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS
+ )
+ config_entry.add_to_hass(hass)
+
+ if "force_dhcp" in kwargs:
+ config_entry.options["force_dhcp"] = True
+
+ if "arp_ping" in kwargs:
+ config_entry.options["arp_ping"] = True
+
+ with patch("librouteros.connect"), patch.object(
+ mikrotik.hub.MikrotikData, "command", new=mock_command
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ return hass.data[mikrotik.DOMAIN][config_entry.entry_id]
+
+
+async def test_hub_setup_successful(hass):
+ """Successful setup of Mikrotik hub."""
+ with patch(
+ "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup",
+ return_value=True,
+ ) as forward_entry_setup:
+ hub = await setup_mikrotik_entry(hass)
+
+ assert hub.config_entry.data == {
+ mikrotik.CONF_NAME: "Mikrotik",
+ mikrotik.CONF_HOST: "0.0.0.0",
+ mikrotik.CONF_USERNAME: "user",
+ mikrotik.CONF_PASSWORD: "pass",
+ mikrotik.CONF_PORT: 8278,
+ mikrotik.CONF_VERIFY_SSL: False,
+ }
+ assert hub.config_entry.options == {
+ mikrotik.hub.CONF_FORCE_DHCP: False,
+ mikrotik.CONF_ARP_PING: False,
+ mikrotik.CONF_DETECTION_TIME: 300,
+ }
+
+ assert hub.api.available is True
+ assert hub.signal_update == "mikrotik-update-0.0.0.0"
+ assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker")
+
+
+async def test_hub_setup_failed(hass):
+ """Failed setup of Mikrotik hub."""
+
+ config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA)
+ config_entry.add_to_hass(hass)
+ # error when connection fails
+ with patch(
+ "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed
+ ):
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
+
+ assert config_entry.state == config_entries.ENTRY_STATE_SETUP_RETRY
+
+ # error when username or password is invalid
+ config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA)
+ config_entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup"
+ ) as forward_entry_setup, patch(
+ "librouteros.connect",
+ side_effect=librouteros.exceptions.TrapError("invalid user name or password"),
+ ):
+
+ result = await hass.config_entries.async_setup(config_entry.entry_id)
+
+ assert result is False
+ assert len(forward_entry_setup.mock_calls) == 0
+
+
+async def test_update_failed(hass):
+ """Test failing to connect during update."""
+
+ hub = await setup_mikrotik_entry(hass)
+
+ with patch.object(
+ mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect
+ ):
+ await hub.async_update()
+
+ assert hub.api.available is False
+
+
+async def test_hub_not_support_wireless(hass):
+ """Test updating hub devices when hub doesn't support wireless interfaces."""
+
+ # test that the devices are constructed from dhcp data
+
+ hub = await setup_mikrotik_entry(hass, support_wireless=False)
+
+ assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0]
+ assert hub.api.devices["00:00:00:00:00:01"]._wireless_params is None
+ assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1]
+ assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None
+
+
+async def test_hub_support_wireless(hass):
+ """Test updating hub devices when hub support wireless interfaces."""
+
+ # test that the device list is from wireless data list
+
+ hub = await setup_mikrotik_entry(hass)
+
+ assert hub.api.support_wireless is True
+ assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0]
+ assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0]
+
+ # devices not in wireless list will not be added
+ assert "00:00:00:00:00:02" not in hub.api.devices
+
+
+async def test_force_dhcp(hass):
+ """Test updating hub devices with forced dhcp method."""
+
+ # test that the devices are constructed from dhcp data
+
+ hub = await setup_mikrotik_entry(hass, force_dhcp=True)
+
+ assert hub.api.support_wireless is True
+ assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0]
+ assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0]
+
+ # devices not in wireless list are added from dhcp
+ assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1]
+ assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None
+
+
+async def test_arp_ping(hass):
+ """Test arp ping devices to confirm they are connected."""
+
+ # test device show as home if arp ping returns value
+ with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=True):
+ hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True)
+
+ assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None
+ assert hub.api.devices["00:00:00:00:00:02"].last_seen is not None
+
+ # test device show as away if arp ping times out
+ with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=False):
+ hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True)
+
+ assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None
+ # this device is not wireless so it will show as away
+ assert hub.api.devices["00:00:00:00:00:02"].last_seen is None
diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py
new file mode 100644
index 00000000000000..ea7e22239b2321
--- /dev/null
+++ b/tests/components/mikrotik/test_init.py
@@ -0,0 +1,83 @@
+"""Test Mikrotik setup process."""
+from unittest.mock import Mock, patch
+
+from homeassistant.components import mikrotik
+from homeassistant.setup import async_setup_component
+
+from . import MOCK_DATA
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_setup_with_no_config(hass):
+ """Test that we do not discover anything or try to set up a hub."""
+ assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True
+ assert mikrotik.DOMAIN not in hass.data
+
+
+async def test_successful_config_entry(hass):
+ """Test config entry successful setup."""
+ entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,)
+ entry.add_to_hass(hass)
+ mock_registry = Mock()
+
+ with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch(
+ "homeassistant.helpers.device_registry.async_get_registry",
+ return_value=mock_coro(mock_registry),
+ ):
+ mock_hub.return_value.async_setup.return_value = mock_coro(True)
+ mock_hub.return_value.serial_num = "12345678"
+ mock_hub.return_value.model = "RB750"
+ mock_hub.return_value.hostname = "mikrotik"
+ mock_hub.return_value.firmware = "3.65"
+ assert await mikrotik.async_setup_entry(hass, entry) is True
+
+ assert len(mock_hub.mock_calls) == 2
+ p_hass, p_entry = mock_hub.mock_calls[0][1]
+
+ assert p_hass is hass
+ assert p_entry is entry
+
+ assert len(mock_registry.mock_calls) == 1
+ assert mock_registry.mock_calls[0][2] == {
+ "config_entry_id": entry.entry_id,
+ "connections": {("mikrotik", "12345678")},
+ "manufacturer": mikrotik.ATTR_MANUFACTURER,
+ "model": "RB750",
+ "name": "mikrotik",
+ "sw_version": "3.65",
+ }
+
+
+async def test_hub_fail_setup(hass):
+ """Test that a failed setup will not store the hub."""
+ entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,)
+ entry.add_to_hass(hass)
+
+ with patch.object(mikrotik, "MikrotikHub") as mock_hub:
+ mock_hub.return_value.async_setup.return_value = mock_coro(False)
+ assert await mikrotik.async_setup_entry(hass, entry) is False
+
+ assert mikrotik.DOMAIN not in hass.data
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,)
+ entry.add_to_hass(hass)
+
+ with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch(
+ "homeassistant.helpers.device_registry.async_get_registry",
+ return_value=mock_coro(Mock()),
+ ):
+ mock_hub.return_value.async_setup.return_value = mock_coro(True)
+ mock_hub.return_value.serial_num = "12345678"
+ mock_hub.return_value.model = "RB750"
+ mock_hub.return_value.hostname = "mikrotik"
+ mock_hub.return_value.firmware = "3.65"
+ assert await mikrotik.async_setup_entry(hass, entry) is True
+
+ assert len(mock_hub.return_value.mock_calls) == 1
+
+ assert await mikrotik.async_unload_entry(hass, entry)
+ assert entry.entry_id not in hass.data[mikrotik.DOMAIN]
diff --git a/tests/components/minecraft_server/__init__.py b/tests/components/minecraft_server/__init__.py
new file mode 100644
index 00000000000000..36a1bb3f69d2ed
--- /dev/null
+++ b/tests/components/minecraft_server/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Minecraft Server integration."""
diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py
new file mode 100644
index 00000000000000..30626fbdcb0280
--- /dev/null
+++ b/tests/components/minecraft_server/test_config_flow.py
@@ -0,0 +1,194 @@
+"""Test the Minecraft Server config flow."""
+
+from asynctest import patch
+from mcstatus.pinger import PingResponse
+
+from homeassistant.components.minecraft_server.const import (
+ DEFAULT_NAME,
+ DEFAULT_PORT,
+ DOMAIN,
+)
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.data_entry_flow import (
+ RESULT_TYPE_ABORT,
+ RESULT_TYPE_CREATE_ENTRY,
+ RESULT_TYPE_FORM,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.common import MockConfigEntry
+
+STATUS_RESPONSE_RAW = {
+ "description": {"text": "Dummy Description"},
+ "version": {"name": "Dummy Version", "protocol": 123},
+ "players": {
+ "online": 3,
+ "max": 10,
+ "sample": [
+ {"name": "Player 1", "id": "1"},
+ {"name": "Player 2", "id": "2"},
+ {"name": "Player 3", "id": "3"},
+ ],
+ },
+}
+
+USER_INPUT = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: "mc.dummyserver.com",
+ CONF_PORT: DEFAULT_PORT,
+}
+
+USER_INPUT_IPV4 = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: "1.1.1.1",
+ CONF_PORT: DEFAULT_PORT,
+}
+
+USER_INPUT_IPV6 = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: "::ffff:0101:0101",
+ CONF_PORT: DEFAULT_PORT,
+}
+
+USER_INPUT_PORT_TOO_SMALL = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: "mc.dummyserver.com",
+ CONF_PORT: 1023,
+}
+
+USER_INPUT_PORT_TOO_LARGE = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: "mc.dummyserver.com",
+ CONF_PORT: 65536,
+}
+
+
+async def test_show_config_form(hass: HomeAssistantType) -> None:
+ """Test if initial configuration form is shown."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+
+async def test_invalid_ip(hass: HomeAssistantType) -> None:
+ """Test error in case of an invalid IP address."""
+ with patch("getmac.get_mac_address", return_value=None):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "invalid_ip"}
+
+
+async def test_same_host(hass: HomeAssistantType) -> None:
+ """Test abort in case of same host name."""
+ unique_id = f"{USER_INPUT[CONF_HOST]}-{USER_INPUT[CONF_PORT]}"
+ mock_config_entry = MockConfigEntry(
+ domain=DOMAIN, unique_id=unique_id, data=USER_INPUT
+ )
+ mock_config_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_port_too_small(hass: HomeAssistantType) -> None:
+ """Test error in case of a too small port."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_SMALL
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "invalid_port"}
+
+
+async def test_port_too_large(hass: HomeAssistantType) -> None:
+ """Test error in case of a too large port."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_LARGE
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "invalid_port"}
+
+
+async def test_connection_failed(hass: HomeAssistantType) -> None:
+ """Test error in case of a failed connection."""
+ with patch("mcstatus.server.MinecraftServer.ping", side_effect=OSError):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_connection_succeeded_with_host(hass: HomeAssistantType) -> None:
+ """Test config entry in case of a successful connection with a host name."""
+ with patch("mcstatus.server.MinecraftServer.ping", return_value=50):
+ with patch(
+ "mcstatus.server.MinecraftServer.status",
+ return_value=PingResponse(STATUS_RESPONSE_RAW),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == f"{USER_INPUT[CONF_HOST]}:{USER_INPUT[CONF_PORT]}"
+ assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME]
+ assert result["data"][CONF_HOST] == USER_INPUT[CONF_HOST]
+ assert result["data"][CONF_PORT] == USER_INPUT[CONF_PORT]
+
+
+async def test_connection_succeeded_with_ip4(hass: HomeAssistantType) -> None:
+ """Test config entry in case of a successful connection with an IPv4 address."""
+ with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"):
+ with patch("mcstatus.server.MinecraftServer.ping", return_value=50):
+ with patch(
+ "mcstatus.server.MinecraftServer.status",
+ return_value=PingResponse(STATUS_RESPONSE_RAW),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert (
+ result["title"]
+ == f"{USER_INPUT_IPV4[CONF_HOST]}:{USER_INPUT_IPV4[CONF_PORT]}"
+ )
+ assert result["data"][CONF_NAME] == USER_INPUT_IPV4[CONF_NAME]
+ assert result["data"][CONF_HOST] == USER_INPUT_IPV4[CONF_HOST]
+ assert result["data"][CONF_PORT] == USER_INPUT_IPV4[CONF_PORT]
+
+
+async def test_connection_succeeded_with_ip6(hass: HomeAssistantType) -> None:
+ """Test config entry in case of a successful connection with an IPv6 address."""
+ with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"):
+ with patch("mcstatus.server.MinecraftServer.ping", return_value=50):
+ with patch(
+ "mcstatus.server.MinecraftServer.status",
+ return_value=PingResponse(STATUS_RESPONSE_RAW),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert (
+ result["title"]
+ == f"{USER_INPUT_IPV6[CONF_HOST]}:{USER_INPUT_IPV6[CONF_PORT]}"
+ )
+ assert result["data"][CONF_NAME] == USER_INPUT_IPV6[CONF_NAME]
+ assert result["data"][CONF_HOST] == USER_INPUT_IPV6[CONF_HOST]
+ assert result["data"][CONF_PORT] == USER_INPUT_IPV6[CONF_PORT]
diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py
index 0db9d42048fc82..65dc328186d828 100644
--- a/tests/components/mobile_app/test_entity.py
+++ b/tests/components/mobile_app/test_entity.py
@@ -35,7 +35,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
assert json == {"success": True}
await hass.async_block_till_done()
- entity = hass.states.get("sensor.battery_state")
+ entity = hass.states.get("sensor.test_1_battery_state")
assert entity is not None
assert entity.attributes["device_class"] == "battery"
@@ -43,7 +43,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
assert entity.attributes["unit_of_measurement"] == "%"
assert entity.attributes["foo"] == "bar"
assert entity.domain == "sensor"
- assert entity.name == "Battery State"
+ assert entity.name == "Test 1 Battery State"
assert entity.state == "100"
update_resp = await webhook_client.post(
@@ -63,7 +63,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
assert update_resp.status == 200
- updated_entity = hass.states.get("sensor.battery_state")
+ updated_entity = hass.states.get("sensor.test_1_battery_state")
assert updated_entity.state == "123"
dev_reg = await device_registry.async_get_registry(hass)
diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py
index 3df71c347812a9..39837543a47369 100644
--- a/tests/components/mobile_app/test_webhook.py
+++ b/tests/components/mobile_app/test_webhook.py
@@ -1,5 +1,4 @@
"""Webhook tests for mobile_app."""
-
import logging
import pytest
@@ -17,6 +16,53 @@
_LOGGER = logging.getLogger(__name__)
+def encrypt_payload(secret_key, payload):
+ """Return a encrypted payload given a key and dictionary of data."""
+ try:
+ from nacl.secret import SecretBox
+ from nacl.encoding import Base64Encoder
+ except (ImportError, OSError):
+ pytest.skip("libnacl/libsodium is not installed")
+ return
+
+ import json
+
+ keylen = SecretBox.KEY_SIZE
+ prepped_key = secret_key.encode("utf-8")
+ prepped_key = prepped_key[:keylen]
+ prepped_key = prepped_key.ljust(keylen, b"\0")
+
+ payload = json.dumps(payload).encode("utf-8")
+
+ return (
+ SecretBox(prepped_key).encrypt(payload, encoder=Base64Encoder).decode("utf-8")
+ )
+
+
+def decrypt_payload(secret_key, encrypted_data):
+ """Return a decrypted payload given a key and a string of encrypted data."""
+ try:
+ from nacl.secret import SecretBox
+ from nacl.encoding import Base64Encoder
+ except (ImportError, OSError):
+ pytest.skip("libnacl/libsodium is not installed")
+ return
+
+ import json
+
+ keylen = SecretBox.KEY_SIZE
+ prepped_key = secret_key.encode("utf-8")
+ prepped_key = prepped_key[:keylen]
+ prepped_key = prepped_key.ljust(keylen, b"\0")
+
+ decrypted_data = SecretBox(prepped_key).decrypt(
+ encrypted_data, encoder=Base64Encoder
+ )
+ decrypted_data = decrypted_data.decode("utf-8")
+
+ return json.loads(decrypted_data)
+
+
async def test_webhook_handle_render_template(create_registrations, webhook_client):
"""Test that we render templates properly."""
resp = await webhook_client.post(
@@ -166,23 +212,8 @@ async def test_webhook_returns_error_incorrect_json(
async def test_webhook_handle_decryption(webhook_client, create_registrations):
"""Test that we can encrypt/decrypt properly."""
- try:
- from nacl.secret import SecretBox
- from nacl.encoding import Base64Encoder
- except (ImportError, OSError):
- pytest.skip("libnacl/libsodium is not installed")
- return
-
- import json
-
- keylen = SecretBox.KEY_SIZE
- key = create_registrations[0]["secret"].encode("utf-8")
- key = key[:keylen]
- key = key.ljust(keylen, b"\0")
-
- payload = json.dumps(RENDER_TEMPLATE["data"]).encode("utf-8")
-
- data = SecretBox(key).encrypt(payload, encoder=Base64Encoder).decode("utf-8")
+ key = create_registrations[0]["secret"]
+ data = encrypt_payload(key, RENDER_TEMPLATE["data"])
container = {"type": "render_template", "encrypted": True, "encrypted_data": data}
@@ -195,12 +226,9 @@ async def test_webhook_handle_decryption(webhook_client, create_registrations):
webhook_json = await resp.json()
assert "encrypted_data" in webhook_json
- decrypted_data = SecretBox(key).decrypt(
- webhook_json["encrypted_data"], encoder=Base64Encoder
- )
- decrypted_data = decrypted_data.decode("utf-8")
+ decrypted_data = decrypt_payload(key, webhook_json["encrypted_data"])
- assert json.loads(decrypted_data) == {"one": "Hello world"}
+ assert decrypted_data == {"one": "Hello world"}
async def test_webhook_requires_encryption(webhook_client, create_registrations):
@@ -219,7 +247,7 @@ async def test_webhook_requires_encryption(webhook_client, create_registrations)
async def test_webhook_update_location(hass, webhook_client, create_registrations):
- """Test that encrypted registrations only accept encrypted data."""
+ """Test that location can be updated."""
resp = await webhook_client.post(
"/api/webhook/{}".format(create_registrations[1]["webhook_id"]),
json={
@@ -236,3 +264,52 @@ async def test_webhook_update_location(hass, webhook_client, create_registration
assert state.attributes["longitude"] == 2.0
assert state.attributes["gps_accuracy"] == 10
assert state.attributes["altitude"] == -10
+
+
+async def test_webhook_enable_encryption(hass, webhook_client, create_registrations):
+ """Test that encryption can be added to a reg initially created without."""
+ webhook_id = create_registrations[1]["webhook_id"]
+
+ enable_enc_resp = await webhook_client.post(
+ "/api/webhook/{}".format(webhook_id), json={"type": "enable_encryption"},
+ )
+
+ assert enable_enc_resp.status == 200
+
+ enable_enc_json = await enable_enc_resp.json()
+ assert len(enable_enc_json) == 1
+ assert CONF_SECRET in enable_enc_json
+
+ key = enable_enc_json["secret"]
+
+ enc_required_resp = await webhook_client.post(
+ "/api/webhook/{}".format(webhook_id), json=RENDER_TEMPLATE,
+ )
+
+ assert enc_required_resp.status == 400
+
+ enc_required_json = await enc_required_resp.json()
+ assert "error" in enc_required_json
+ assert enc_required_json["success"] is False
+ assert enc_required_json["error"]["code"] == "encryption_required"
+
+ enc_data = encrypt_payload(key, RENDER_TEMPLATE["data"])
+
+ container = {
+ "type": "render_template",
+ "encrypted": True,
+ "encrypted_data": enc_data,
+ }
+
+ enc_resp = await webhook_client.post(
+ "/api/webhook/{}".format(webhook_id), json=container
+ )
+
+ assert enc_resp.status == 200
+
+ enc_json = await enc_resp.json()
+ assert "encrypted_data" in enc_json
+
+ decrypted_data = decrypt_payload(key, enc_json["encrypted_data"])
+
+ assert decrypted_data == {"one": "Hello world"}
diff --git a/tests/components/modbus/test_modbus_sensor.py b/tests/components/modbus/test_modbus_sensor.py
index 9f13cba8907042..16d8f9a1936e77 100644
--- a/tests/components/modbus/test_modbus_sensor.py
+++ b/tests/components/modbus/test_modbus_sensor.py
@@ -17,8 +17,8 @@
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_UINT,
- REGISTER_TYPE_HOLDING,
- REGISTER_TYPE_INPUT,
+ DEFAULT_REGISTER_TYPE_HOLDING,
+ DEFAULT_REGISTER_TYPE_INPUT,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
@@ -72,7 +72,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
# Setup inputs for the sensor
read_result = ReadResult(register_words)
- if register_config.get(CONF_REGISTER_TYPE) == REGISTER_TYPE_INPUT:
+ if register_config.get(CONF_REGISTER_TYPE) == DEFAULT_REGISTER_TYPE_INPUT:
mock_hub.read_input_registers.return_value = read_result
else:
mock_hub.read_holding_registers.return_value = read_result
@@ -310,7 +310,7 @@ async def test_two_word_input_register(hass, mock_hub):
"""Test reaging of input register."""
register_config = {
CONF_COUNT: 2,
- CONF_REGISTER_TYPE: REGISTER_TYPE_INPUT,
+ CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_INPUT,
CONF_DATA_TYPE: DATA_TYPE_UINT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
@@ -329,7 +329,7 @@ async def test_two_word_holding_register(hass, mock_hub):
"""Test reaging of holding register."""
register_config = {
CONF_COUNT: 2,
- CONF_REGISTER_TYPE: REGISTER_TYPE_HOLDING,
+ CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING,
CONF_DATA_TYPE: DATA_TYPE_UINT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
@@ -348,7 +348,7 @@ async def test_float_data_type(hass, mock_hub):
"""Test floating point register data type."""
register_config = {
CONF_COUNT: 2,
- CONF_REGISTER_TYPE: REGISTER_TYPE_HOLDING,
+ CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING,
CONF_DATA_TYPE: DATA_TYPE_FLOAT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py
index 889410927e5d1a..aa72549152e68b 100644
--- a/tests/components/mqtt/test_config_flow.py
+++ b/tests/components/mqtt/test_config_flow.py
@@ -50,7 +50,7 @@ async def test_user_connection_works(hass, mock_try_connection, mock_finish_setu
async def test_user_connection_fails(hass, mock_try_connection, mock_finish_setup):
- """Test if connnection cannot be made."""
+ """Test if connection cannot be made."""
mock_try_connection.return_value = False
result = await hass.config_entries.flow.async_init(
diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py
index b15518961a4a98..128c18de8dfd41 100644
--- a/tests/components/mqtt/test_cover.py
+++ b/tests/components/mqtt/test_cover.py
@@ -19,7 +19,9 @@
SERVICE_TOGGLE,
SERVICE_TOGGLE_COVER_TILT,
STATE_CLOSED,
+ STATE_CLOSING,
STATE_OPEN,
+ STATE_OPENING,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
@@ -67,6 +69,93 @@ async def test_state_via_state_topic(hass, mqtt_mock):
assert state.state == STATE_OPEN
+async def test_opening_and_closing_state_via_custom_state_payload(hass, mqtt_mock):
+ """Test the controlling opening and closing state via a custom payload."""
+ assert await async_setup_component(
+ hass,
+ cover.DOMAIN,
+ {
+ cover.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "state-topic",
+ "command_topic": "command-topic",
+ "qos": 0,
+ "payload_open": "OPEN",
+ "payload_close": "CLOSE",
+ "payload_stop": "STOP",
+ "state_opening": "34",
+ "state_closing": "--43",
+ }
+ },
+ )
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_UNKNOWN
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, "state-topic", "34")
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_OPENING
+
+ async_fire_mqtt_message(hass, "state-topic", "--43")
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_CLOSING
+
+ async_fire_mqtt_message(hass, "state-topic", STATE_CLOSED)
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_CLOSED
+
+
+async def test_open_closed_state_from_position_optimistic(hass, mqtt_mock):
+ """Test the state after setting the position using optimistic mode."""
+ assert await async_setup_component(
+ hass,
+ cover.DOMAIN,
+ {
+ cover.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "position_topic": "position-topic",
+ "set_position_topic": "set-position-topic",
+ "qos": 0,
+ "payload_open": "OPEN",
+ "payload_close": "CLOSE",
+ "payload_stop": "STOP",
+ "optimistic": True,
+ }
+ },
+ )
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_UNKNOWN
+
+ await hass.services.async_call(
+ cover.DOMAIN,
+ SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 0},
+ blocking=True,
+ )
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_CLOSED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await hass.services.async_call(
+ cover.DOMAIN,
+ SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 100},
+ blocking=True,
+ )
+
+ state = hass.states.get("cover.test")
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+
async def test_position_via_position_topic(hass, mqtt_mock):
"""Test the controlling state via topic."""
assert await async_setup_component(
diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py
index f4324bd8634161..aa6d3efc828809 100644
--- a/tests/components/mqtt/test_device_tracker.py
+++ b/tests/components/mqtt/test_device_tracker.py
@@ -2,11 +2,7 @@
from asynctest import patch
import pytest
-from homeassistant.components import device_tracker
-from homeassistant.components.device_tracker.const import (
- ENTITY_ID_FORMAT,
- SOURCE_TYPE_BLUETOOTH,
-)
+from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH
from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME
from homeassistant.setup import async_setup_component
@@ -35,14 +31,7 @@ async def mock_setup_scanner(hass, config, see, discovery_info=None):
dev_id = "paulus"
topic = "/location/paulus"
assert await async_setup_component(
- hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: topic},
- }
- },
+ hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}}
)
assert mock_sp.call_count == 1
@@ -50,15 +39,13 @@ async def mock_setup_scanner(hass, config, see, discovery_info=None):
async def test_new_message(hass, mock_device_tracker_conf):
"""Test new message."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
location = "work"
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
- hass,
- device_tracker.DOMAIN,
- {device_tracker.DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}},
+ hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}}
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -68,7 +55,7 @@ async def test_new_message(hass, mock_device_tracker_conf):
async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
"""Test single level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/+/paulus"
topic = "/location/room/paulus"
location = "work"
@@ -76,13 +63,8 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -92,7 +74,7 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
"""Test multi level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/#"
topic = "/location/room/paulus"
location = "work"
@@ -100,13 +82,8 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -116,7 +93,7 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf):
"""Test not matching single level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/+/paulus"
topic = "/location/paulus"
location = "work"
@@ -124,13 +101,8 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -140,7 +112,7 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke
async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf):
"""Test not matching multi level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/#"
topic = "/somewhere/room/paulus"
location = "work"
@@ -148,13 +120,8 @@ async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -166,7 +133,7 @@ async def test_matching_custom_payload_for_home_and_not_home(
):
"""Test custom payload_home sets state to home and custom payload_not_home sets state to not_home."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
payload_home = "present"
payload_not_home = "not present"
@@ -174,9 +141,9 @@ async def test_matching_custom_payload_for_home_and_not_home(
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
+ DOMAIN,
{
- device_tracker.DOMAIN: {
+ DOMAIN: {
CONF_PLATFORM: "mqtt",
"devices": {dev_id: topic},
"payload_home": payload_home,
@@ -198,7 +165,7 @@ async def test_not_matching_custom_payload_for_home_and_not_home(
):
"""Test not matching payload does not set state to home or not_home."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
payload_home = "present"
payload_not_home = "not present"
@@ -207,9 +174,9 @@ async def test_not_matching_custom_payload_for_home_and_not_home(
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
+ DOMAIN,
{
- device_tracker.DOMAIN: {
+ DOMAIN: {
CONF_PLATFORM: "mqtt",
"devices": {dev_id: topic},
"payload_home": payload_home,
@@ -226,7 +193,7 @@ async def test_not_matching_custom_payload_for_home_and_not_home(
async def test_matching_source_type(hass, mock_device_tracker_conf):
"""Test setting source type."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
source_type = SOURCE_TYPE_BLUETOOTH
location = "work"
@@ -234,9 +201,9 @@ async def test_matching_source_type(hass, mock_device_tracker_conf):
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
+ DOMAIN,
{
- device_tracker.DOMAIN: {
+ DOMAIN: {
CONF_PLATFORM: "mqtt",
"devices": {dev_id: topic},
"source_type": source_type,
diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py
new file mode 100644
index 00000000000000..c9d9ec4ad08ede
--- /dev/null
+++ b/tests/components/mqtt/test_device_trigger.py
@@ -0,0 +1,833 @@
+"""The tests for MQTT device triggers."""
+import json
+
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.mqtt import DOMAIN
+from homeassistant.components.mqtt.device_trigger import async_attach_trigger
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry,
+ assert_lists_same,
+ async_fire_mqtt_message,
+ async_get_device_automations,
+ async_mock_service,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+async def test_get_triggers(hass, device_reg, entity_reg, mqtt_mock):
+ """Test we get the expected triggers from a discovered mqtt device."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ expected_triggers = [
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ ]
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, expected_triggers)
+
+
+async def test_get_unknown_triggers(hass, device_reg, entity_reg, mqtt_mock):
+ """Test we don't get unknown triggers."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ # Discover a sensor (without device triggers)
+ data1 = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, [])
+
+
+async def test_get_non_existing_triggers(hass, device_reg, entity_reg, mqtt_mock):
+ """Test getting non existing triggers."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ # Discover a sensor (without device triggers)
+ data1 = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, [])
+
+
+async def test_discover_bad_triggers(hass, device_reg, entity_reg, mqtt_mock):
+ """Test bad discovery message."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ # Test sending bad data
+ data0 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payloads": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data0)
+ await hass.async_block_till_done()
+ assert device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) is None
+
+ # Test sending correct data
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ expected_triggers = [
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ ]
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, expected_triggers)
+
+
+async def test_update_remove_triggers(hass, device_reg, entity_reg, mqtt_mock):
+ """Test triggers can be updated and removed."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ data2 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_2" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ expected_triggers1 = [
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ ]
+ expected_triggers2 = [dict(expected_triggers1[0])]
+ expected_triggers2[0]["subtype"] = "button_2"
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, expected_triggers1)
+
+ # Update trigger
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data2)
+ await hass.async_block_till_done()
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, expected_triggers2)
+
+ # Remove trigger
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", "")
+ await hass.async_block_till_done()
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, [])
+
+
+async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock):
+ """Test triggers firing."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ data2 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "long_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_long_press",'
+ ' "subtype": "button_2" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla2",
+ "type": "button_1",
+ "subtype": "button_long_press",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("long_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "short_press"
+
+ # Fake long press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "long_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data["some"] == "long_press"
+
+
+async def test_if_fires_on_mqtt_message_late_discover(
+ hass, device_reg, calls, mqtt_mock
+):
+ """Test triggers firing of MQTT device triggers discovered after setup."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data0 = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ data2 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "long_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_long_press",'
+ ' "subtype": "button_2" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla2",
+ "type": "button_1",
+ "subtype": "button_long_press",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("long_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2)
+ await hass.async_block_till_done()
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "short_press"
+
+ # Fake long press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "long_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data["some"] == "long_press"
+
+
+async def test_if_fires_on_mqtt_message_after_update(
+ hass, device_reg, calls, mqtt_mock
+):
+ """Test triggers firing after update."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ data2 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "topic": "foobar/triggers/buttonOne",'
+ ' "type": "button_long_press",'
+ ' "subtype": "button_2" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ # Update the trigger
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data2)
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+
+
+async def test_not_fires_on_mqtt_message_after_remove_by_mqtt(
+ hass, device_reg, calls, mqtt_mock
+):
+ """Test triggers not firing after removal."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ # Remove the trigger
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "")
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ # Rediscover the trigger
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+
+
+async def test_not_fires_on_mqtt_message_after_remove_from_registry(
+ hass, device_reg, calls, mqtt_mock
+):
+ """Test triggers not firing after removal."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ # Remove the device
+ device_reg.async_remove_device(device_entry.id)
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_attach_remove(hass, device_reg, mqtt_mock):
+ """Test attach and removal of trigger."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ calls = []
+
+ def callback(trigger):
+ calls.append(trigger["trigger"]["payload"])
+
+ remove = await async_attach_trigger(
+ hass,
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ callback,
+ None,
+ )
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0] == "short_press"
+
+ # Remove the trigger
+ remove()
+ await hass.async_block_till_done()
+
+ # Verify the triggers are no longer active
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_attach_remove_late(hass, device_reg, mqtt_mock):
+ """Test attach and removal of trigger ."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data0 = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ calls = []
+
+ def callback(trigger):
+ calls.append(trigger["trigger"]["payload"])
+
+ remove = await async_attach_trigger(
+ hass,
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ callback,
+ None,
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0] == "short_press"
+
+ # Remove the trigger
+ remove()
+ await hass.async_block_till_done()
+
+ # Verify the triggers are no longer active
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_attach_remove_late2(hass, device_reg, mqtt_mock):
+ """Test attach and removal of trigger ."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data0 = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "payload": "short_press",'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ calls = []
+
+ def callback(trigger):
+ calls.append(trigger["trigger"]["payload"])
+
+ remove = await async_attach_trigger(
+ hass,
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ callback,
+ None,
+ )
+
+ # Remove the trigger
+ remove()
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+
+ # Verify the triggers are no longer active
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT device registry integration."""
+ entry = MockConfigEntry(domain=DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(
+ {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {
+ "identifiers": ["helloworld"],
+ "connections": [["mac", "02:5b:26:a8:dc:12"]],
+ "manufacturer": "Whatever",
+ "name": "Beer",
+ "model": "Glass",
+ "sw_version": "0.1-beta",
+ },
+ }
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+ assert device.identifiers == {("mqtt", "helloworld")}
+ assert device.connections == {("mac", "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == "Whatever"
+ assert device.name == "Beer"
+ assert device.model == "Glass"
+ assert device.sw_version == "0.1-beta"
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {
+ "identifiers": ["helloworld"],
+ "connections": [["mac", "02:5b:26:a8:dc:12"]],
+ "manufacturer": "Whatever",
+ "name": "Beer",
+ "model": "Glass",
+ "sw_version": "0.1-beta",
+ },
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+ assert device.name == "Beer"
+
+ config["device"]["name"] = "Milk"
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+ assert device.name == "Milk"
diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py
index 6320be3b7728cb..4a28b95e32c584 100644
--- a/tests/components/mqtt/test_discovery.py
+++ b/tests/components/mqtt/test_discovery.py
@@ -3,6 +3,8 @@
import re
from unittest.mock import patch
+import pytest
+
from homeassistant.components import mqtt
from homeassistant.components.mqtt.abbreviations import (
ABBREVIATIONS,
@@ -11,7 +13,25 @@
from homeassistant.components.mqtt.discovery import ALREADY_DISCOVERED, async_start
from homeassistant.const import STATE_OFF, STATE_ON
-from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro
+from tests.common import (
+ MockConfigEntry,
+ async_fire_mqtt_message,
+ mock_coro,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
async def test_subscribing_config_topic(hass, mqtt_mock):
@@ -213,6 +233,114 @@ async def test_non_duplicate_discovery(hass, mqtt_mock, caplog):
assert "Component has already been discovered: binary_sensor bla" in caplog.text
+async def test_removal(hass, mqtt_mock, caplog):
+ """Test removal of component through empty discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is not None
+
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is None
+
+
+async def test_rediscover(hass, mqtt_mock, caplog):
+ """Test rediscover of removed component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is not None
+
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is None
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is not None
+
+
+async def test_duplicate_removal(hass, mqtt_mock, caplog):
+ """Test for a non duplicate component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+ assert "Component has already been discovered: binary_sensor bla" in caplog.text
+ caplog.clear()
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+
+ assert "Component has already been discovered: binary_sensor bla" not in caplog.text
+
+
+async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock):
+ """Test discvered device is cleaned up when removed from registry."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
+ await hass.async_block_till_done()
+
+ # Verify device and registry entries are created
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is not None
+ entity_entry = entity_reg.async_get("sensor.mqtt_sensor")
+ assert entity_entry is not None
+
+ state = hass.states.get("sensor.mqtt_sensor")
+ assert state is not None
+
+ device_reg.async_remove_device(device_entry.id)
+ await hass.async_block_till_done()
+
+ # Verify device and registry entries are cleared
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is None
+ entity_entry = entity_reg.async_get("sensor.mqtt_sensor")
+ assert entity_entry is None
+
+ # Verify state is removed
+ state = hass.states.get("sensor.mqtt_sensor")
+ assert state is None
+
+ # Verify retained discovery topic has been cleared
+ mqtt_mock.async_publish.assert_called_once_with(
+ "homeassistant/sensor/bla/config", "", 0, True
+ )
+
+
async def test_discovery_expansion(hass, mqtt_mock, caplog):
"""Test expansion of abbreviated discovery payload."""
entry = MockConfigEntry(domain=mqtt.DOMAIN)
@@ -229,7 +357,7 @@ async def test_discovery_expansion(hass, mqtt_mock, caplog):
' "name":"DiscoveryExpansionTest1 Device",'
' "mdl":"Generic",'
' "sw":"1.2.3.4",'
- ' "mf":"Noone"'
+ ' "mf":"None"'
" }"
"}"
)
@@ -250,7 +378,7 @@ async def test_discovery_expansion(hass, mqtt_mock, caplog):
ABBREVIATIONS_WHITE_LIST = [
- # MQTT client/server settings
+ # MQTT client/server/trigger settings
"CONF_BIRTH_MESSAGE",
"CONF_BROKER",
"CONF_CERTIFICATE",
@@ -258,6 +386,7 @@ async def test_discovery_expansion(hass, mqtt_mock, caplog):
"CONF_CLIENT_ID",
"CONF_CLIENT_KEY",
"CONF_DISCOVERY",
+ "CONF_DISCOVERY_ID",
"CONF_DISCOVERY_PREFIX",
"CONF_EMBEDDED",
"CONF_KEEPALIVE",
diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py
index 682aacdb746152..7d06c62b915a3a 100644
--- a/tests/components/mqtt/test_init.py
+++ b/tests/components/mqtt/test_init.py
@@ -1,4 +1,5 @@
"""The tests for the MQTT component."""
+from datetime import timedelta
import ssl
import unittest
from unittest import mock
@@ -6,7 +7,8 @@
import pytest
import voluptuous as vol
-from homeassistant.components import mqtt
+from homeassistant.components import mqtt, websocket_api
+from homeassistant.components.mqtt.discovery import async_start
from homeassistant.const import (
ATTR_DOMAIN,
ATTR_SERVICE,
@@ -15,20 +17,37 @@
)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
from tests.common import (
MockConfigEntry,
async_fire_mqtt_message,
+ async_fire_time_changed,
async_mock_mqtt_component,
fire_mqtt_message,
get_test_home_assistant,
mock_coro,
+ mock_device_registry,
mock_mqtt_component,
+ mock_registry,
threadsafe_coroutine_factory,
)
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
@pytest.fixture
def mock_MQTT():
"""Make sure connection is established."""
@@ -803,3 +822,115 @@ async def test_mqtt_ws_subscription(hass, hass_ws_client):
await client.send_json({"id": 8, "type": "unsubscribe_events", "subscription": 5})
response = await client.receive_json()
assert response["success"]
+
+
+async def test_dump_service(hass):
+ """Test that we can dump a topic."""
+ await async_mock_mqtt_component(hass)
+
+ mock_open = mock.mock_open()
+
+ await hass.services.async_call(
+ "mqtt", "dump", {"topic": "bla/#", "duration": 3}, blocking=True
+ )
+ async_fire_mqtt_message(hass, "bla/1", "test1")
+ async_fire_mqtt_message(hass, "bla/2", "test2")
+
+ with mock.patch("homeassistant.components.mqtt.open", mock_open):
+ async_fire_time_changed(hass, utcnow() + timedelta(seconds=3))
+ await hass.async_block_till_done()
+
+ writes = mock_open.return_value.write.mock_calls
+ assert len(writes) == 2
+ assert writes[0][1][0] == "bla/1,test1\n"
+ assert writes[1][1][0] == "bla/2,test2\n"
+
+
+async def test_mqtt_ws_remove_discovered_device(
+ hass, device_reg, entity_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device removal."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
+ await hass.async_block_till_done()
+
+ # Verify device entry is created
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is not None
+
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ # Verify device entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is None
+
+
+async def test_mqtt_ws_remove_discovered_device_twice(
+ hass, device_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device removal."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is not None
+
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ await client.send_json(
+ {"id": 6, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND
+
+
+async def test_mqtt_ws_remove_non_mqtt_device(
+ hass, device_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device removal of device belonging to other domain."""
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ assert device_entry is not None
+
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND
diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py
index 3627c95040e07c..95f31b6782637a 100644
--- a/tests/components/mqtt/test_server.py
+++ b/tests/components/mqtt/test_server.py
@@ -1,5 +1,7 @@
"""The tests for the MQTT component embedded server."""
-from unittest.mock import MagicMock, Mock, patch
+from unittest.mock import MagicMock, Mock
+
+from asynctest import CoroutineMock, patch
import homeassistant.components.mqtt as mqtt
from homeassistant.const import CONF_PASSWORD
@@ -21,7 +23,7 @@ def teardown_method(self, method):
@patch("passlib.apps.custom_app_context", Mock(return_value=""))
@patch("tempfile.NamedTemporaryFile", Mock(return_value=MagicMock()))
- @patch("hbmqtt.broker.Broker", Mock(return_value=MagicMock()))
+ @patch("hbmqtt.broker.Broker", Mock(return_value=MagicMock(start=CoroutineMock())))
@patch("hbmqtt.broker.Broker.start", Mock(return_value=mock_coro()))
@patch("homeassistant.components.mqtt.MQTT")
def test_creating_config_with_pass_and_no_http_pass(self, mock_mqtt):
@@ -43,7 +45,7 @@ def test_creating_config_with_pass_and_no_http_pass(self, mock_mqtt):
@patch("passlib.apps.custom_app_context", Mock(return_value=""))
@patch("tempfile.NamedTemporaryFile", Mock(return_value=MagicMock()))
- @patch("hbmqtt.broker.Broker", Mock(return_value=MagicMock()))
+ @patch("hbmqtt.broker.Broker", Mock(return_value=MagicMock(start=CoroutineMock())))
@patch("hbmqtt.broker.Broker.start", Mock(return_value=mock_coro()))
@patch("homeassistant.components.mqtt.MQTT")
def test_creating_config_with_pass_and_http_pass(self, mock_mqtt):
diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py
index 5af196c5bf2ba4..9efff135fe25ae 100644
--- a/tests/components/mqtt_json/test_device_tracker.py
+++ b/tests/components/mqtt_json/test_device_tracker.py
@@ -8,7 +8,6 @@
from homeassistant.components.device_tracker.legacy import (
DOMAIN as DT_DOMAIN,
- ENTITY_ID_FORMAT,
YAML_DEVICES,
)
from homeassistant.const import CONF_PLATFORM
@@ -161,7 +160,7 @@ async def test_multi_level_wildcard_topic(hass):
async def test_single_level_wildcard_topic_not_matching(hass):
"""Test not matching single level wildcard topic."""
dev_id = "zanzito"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DT_DOMAIN}.{dev_id}"
subscription = "location/+/zanzito"
topic = "location/zanzito"
location = json.dumps(LOCATION_MESSAGE)
@@ -179,7 +178,7 @@ async def test_single_level_wildcard_topic_not_matching(hass):
async def test_multi_level_wildcard_topic_not_matching(hass):
"""Test not matching multi level wildcard topic."""
dev_id = "zanzito"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DT_DOMAIN}.{dev_id}"
subscription = "location/#"
topic = "somewhere/zanzito"
location = json.dumps(LOCATION_MESSAGE)
diff --git a/tests/components/netatmo/__init__.py b/tests/components/netatmo/__init__.py
new file mode 100644
index 00000000000000..26920894756ea3
--- /dev/null
+++ b/tests/components/netatmo/__init__.py
@@ -0,0 +1 @@
+"""The tests for Netatmo platforms."""
diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py
index 24aac6dc878b09..c9a663991cb7f0 100644
--- a/tests/components/netatmo/test_config_flow.py
+++ b/tests/components/netatmo/test_config_flow.py
@@ -1,4 +1,6 @@
"""Test the Netatmo config flow."""
+from asynctest import patch
+
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.netatmo import config_flow
from homeassistant.components.netatmo.const import (
@@ -54,15 +56,15 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
scope = "+".join(
[
- "read_station",
- "read_camera",
"access_camera",
- "write_camera",
- "read_presence",
"access_presence",
+ "read_camera",
"read_homecoach",
+ "read_presence",
"read_smokedetector",
+ "read_station",
"read_thermostat",
+ "write_camera",
"write_thermostat",
]
)
@@ -88,6 +90,10 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
},
)
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ with patch(
+ "homeassistant.components.netatmo.async_setup_entry", return_value=True
+ ) as mock_setup:
+ await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert len(mock_setup.mock_calls) == 1
diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py
index f7651a570cffe7..60ca4c07fb5a01 100644
--- a/tests/components/notion/test_config_flow.py
+++ b/tests/components/notion/test_config_flow.py
@@ -6,6 +6,7 @@
from homeassistant import data_entry_flow
from homeassistant.components.notion import DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, mock_coro
@@ -29,12 +30,16 @@ async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.NotionFlowHandler()
- flow.hass = hass
+ MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass(
+ hass
+ )
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_USERNAME: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
@@ -46,6 +51,7 @@ async def test_invalid_credentials(hass, mock_aionotion):
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "invalid_credentials"}
@@ -55,6 +61,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -68,6 +75,7 @@ async def test_step_import(hass, mock_aionotion):
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_import(import_config=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -84,6 +92,7 @@ async def test_step_user(hass, mock_aionotion):
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/opnsense/__init__.py b/tests/components/opnsense/__init__.py
new file mode 100644
index 00000000000000..b3c8985caafd34
--- /dev/null
+++ b/tests/components/opnsense/__init__.py
@@ -0,0 +1 @@
+"""Tests for the opnsense component."""
diff --git a/tests/components/opnsense/test_device_tracker.py b/tests/components/opnsense/test_device_tracker.py
new file mode 100644
index 00000000000000..738847e1898c0c
--- /dev/null
+++ b/tests/components/opnsense/test_device_tracker.py
@@ -0,0 +1,64 @@
+"""The tests for the opnsense device tracker platform."""
+
+from unittest import mock
+
+import pytest
+
+from homeassistant.components import opnsense
+from homeassistant.components.opnsense import CONF_API_SECRET, DOMAIN
+from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture(name="mocked_opnsense")
+def mocked_opnsense():
+ """Mock for pyopnense.diagnostics."""
+ with mock.patch.object(opnsense, "diagnostics") as mocked_opn:
+ yield mocked_opn
+
+
+async def test_get_scanner(hass, mocked_opnsense, mock_device_tracker_conf):
+ """Test creating an opnsense scanner."""
+ interface_client = mock.MagicMock()
+ mocked_opnsense.InterfaceClient.return_value = interface_client
+ interface_client.get_arp.return_value = [
+ {
+ "hostname": "",
+ "intf": "igb1",
+ "intf_description": "LAN",
+ "ip": "192.168.0.123",
+ "mac": "ff:ff:ff:ff:ff:ff",
+ "manufacturer": "",
+ },
+ {
+ "hostname": "Desktop",
+ "intf": "igb1",
+ "intf_description": "LAN",
+ "ip": "192.168.0.167",
+ "mac": "ff:ff:ff:ff:ff:fe",
+ "manufacturer": "OEM",
+ },
+ ]
+ network_insight_client = mock.MagicMock()
+ mocked_opnsense.NetworkInsightClient.return_value = network_insight_client
+ network_insight_client.get_interfaces.return_value = {"igb0": "WAN", "igb1": "LAN"}
+
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_URL: "https://fake_host_fun/api",
+ CONF_API_KEY: "fake_key",
+ CONF_API_SECRET: "fake_secret",
+ CONF_VERIFY_SSL: False,
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ assert result
+ device_1 = hass.states.get("device_tracker.desktop")
+ assert device_1 is not None
+ assert device_1.state == "home"
+ device_2 = hass.states.get("device_tracker.ff_ff_ff_ff_ff_ff")
+ assert device_2.state == "home"
diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py
index e5a414d95ad7ce..763506199839e3 100644
--- a/tests/components/person/test_init.py
+++ b/tests/components/person/test_init.py
@@ -1,7 +1,7 @@
"""The tests for the person component."""
import logging
-from unittest.mock import patch
+from asynctest import patch
import pytest
from homeassistant.components import person
@@ -773,3 +773,15 @@ async def test_reload(hass, hass_admin_user):
assert state_2 is None
assert state_3 is not None
assert state_3.name == "Person 3"
+
+
+async def test_person_storage_fixing_device_trackers(storage_collection):
+ """Test None device trackers become lists."""
+ with patch.object(
+ storage_collection.store,
+ "async_load",
+ return_value={"items": [{"id": "bla", "name": "bla", "device_trackers": None}]},
+ ):
+ await storage_collection.async_load()
+
+ assert storage_collection.data["bla"]["device_trackers"] == []
diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py
index de6ffa51170589..6e61dfac3abadf 100644
--- a/tests/components/plex/mock_classes.py
+++ b/tests/components/plex/mock_classes.py
@@ -1,4 +1,6 @@
"""Mock classes used in tests."""
+import itertools
+
from homeassistant.components.plex.const import CONF_SERVER, CONF_SERVER_IDENTIFIER
from homeassistant.const import CONF_HOST, CONF_PORT
@@ -17,6 +19,12 @@
},
]
+MOCK_MONITORED_USERS = {
+ "a": {"enabled": True},
+ "b": {"enabled": False},
+ "c": {"enabled": True},
+}
+
class MockResource:
"""Mock a PlexAccount resource."""
@@ -53,10 +61,26 @@ def resources(self):
return self._resources
+class MockPlexSystemAccount:
+ """Mock a PlexSystemAccount instance."""
+
+ def __init__(self):
+ """Initialize the object."""
+ self.name = "Dummy"
+ self.accountID = 1
+
+
class MockPlexServer:
"""Mock a PlexServer instance."""
- def __init__(self, index=0, ssl=True):
+ def __init__(
+ self,
+ index=0,
+ ssl=True,
+ load_users=True,
+ num_users=len(MOCK_MONITORED_USERS),
+ ignore_new_users=False,
+ ):
"""Initialize the object."""
host = MOCK_SERVERS[index][CONF_HOST]
port = MOCK_SERVERS[index][CONF_PORT]
@@ -68,8 +92,52 @@ def __init__(self, index=0, ssl=True):
]
prefix = "https" if ssl else "http"
self._baseurl = f"{prefix}://{host}:{port}"
+ self._systemAccount = MockPlexSystemAccount()
+ self._ignore_new_users = ignore_new_users
+ self._load_users = load_users
+ self._num_users = num_users
+
+ def systemAccounts(self):
+ """Mock the systemAccounts lookup method."""
+ return [self._systemAccount]
+
+ @property
+ def accounts(self):
+ """Mock the accounts property."""
+ return set(["a", "b", "c"])
+
+ @property
+ def owner(self):
+ """Mock the owner property."""
+ return "a"
@property
def url_in_use(self):
"""Return URL used by PlexServer."""
return self._baseurl
+
+ @property
+ def version(self):
+ """Mock version of PlexServer."""
+ return "1.0"
+
+ @property
+ def option_monitored_users(self):
+ """Mock loaded config option for monitored users."""
+ userdict = dict(itertools.islice(MOCK_MONITORED_USERS.items(), self._num_users))
+ return userdict if self._load_users else {}
+
+ @property
+ def option_ignore_new_shared_users(self):
+ """Mock loaded config option for ignoring new users."""
+ return self._ignore_new_users
+
+ @property
+ def option_show_all_controls(self):
+ """Mock loaded config option for showing all controls."""
+ return False
+
+ @property
+ def option_use_episode_art(self):
+ """Mock loaded config option for using episode art."""
+ return False
diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py
index 8f9342c4f72c2c..b331444123af5a 100644
--- a/tests/components/plex/test_config_flow.py
+++ b/tests/components/plex/test_config_flow.py
@@ -1,4 +1,5 @@
"""Tests for Plex config flow."""
+import copy
from unittest.mock import patch
import asynctest
@@ -26,6 +27,7 @@
config_flow.MP_DOMAIN: {
config_flow.CONF_USE_EPISODE_ART: False,
config_flow.CONF_SHOW_ALL_CONTROLS: False,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: False,
}
}
@@ -457,9 +459,65 @@ async def test_all_available_servers_configured(hass):
async def test_option_flow(hass):
- """Test config flow selection of one of two bridges."""
+ """Test config options flow selection."""
- entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=DEFAULT_OPTIONS)
+ mock_plex_server = MockPlexServer(load_users=False)
+
+ MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER]
+ hass.data[config_flow.DOMAIN] = {
+ config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server}
+ }
+
+ entry = MockConfigEntry(
+ domain=config_flow.DOMAIN,
+ data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID},
+ options=DEFAULT_OPTIONS,
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, context={"source": "test"}, data=None
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "plex_mp_settings"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ config_flow.CONF_USE_EPISODE_ART: True,
+ config_flow.CONF_SHOW_ALL_CONTROLS: True,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
+ config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts),
+ },
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ config_flow.MP_DOMAIN: {
+ config_flow.CONF_USE_EPISODE_ART: True,
+ config_flow.CONF_SHOW_ALL_CONTROLS: True,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
+ config_flow.CONF_MONITORED_USERS: {
+ user: {"enabled": True} for user in mock_plex_server.accounts
+ },
+ }
+ }
+
+
+async def test_option_flow_loading_saved_users(hass):
+ """Test config options flow selection when loading existing user config."""
+
+ mock_plex_server = MockPlexServer(load_users=True)
+
+ MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER]
+ hass.data[config_flow.DOMAIN] = {
+ config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server}
+ }
+
+ entry = MockConfigEntry(
+ domain=config_flow.DOMAIN,
+ data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID},
+ options=DEFAULT_OPTIONS,
+ )
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(
@@ -473,6 +531,8 @@ async def test_option_flow(hass):
user_input={
config_flow.CONF_USE_EPISODE_ART: True,
config_flow.CONF_SHOW_ALL_CONTROLS: True,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
+ config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts),
},
)
assert result["type"] == "create_entry"
@@ -480,6 +540,60 @@ async def test_option_flow(hass):
config_flow.MP_DOMAIN: {
config_flow.CONF_USE_EPISODE_ART: True,
config_flow.CONF_SHOW_ALL_CONTROLS: True,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
+ config_flow.CONF_MONITORED_USERS: {
+ user: {"enabled": True} for user in mock_plex_server.accounts
+ },
+ }
+ }
+
+
+async def test_option_flow_new_users_available(hass):
+ """Test config options flow selection when new Plex accounts available."""
+
+ mock_plex_server = MockPlexServer(load_users=True, num_users=2)
+
+ MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER]
+ hass.data[config_flow.DOMAIN] = {
+ config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server}
+ }
+
+ OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS)
+ OPTIONS_WITH_USERS[config_flow.MP_DOMAIN][config_flow.CONF_MONITORED_USERS] = {
+ "a": {"enabled": True}
+ }
+
+ entry = MockConfigEntry(
+ domain=config_flow.DOMAIN,
+ data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID},
+ options=OPTIONS_WITH_USERS,
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, context={"source": "test"}, data=None
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "plex_mp_settings"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ config_flow.CONF_USE_EPISODE_ART: True,
+ config_flow.CONF_SHOW_ALL_CONTROLS: True,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
+ config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts),
+ },
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ config_flow.MP_DOMAIN: {
+ config_flow.CONF_USE_EPISODE_ART: True,
+ config_flow.CONF_SHOW_ALL_CONTROLS: True,
+ config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
+ config_flow.CONF_MONITORED_USERS: {
+ user: {"enabled": True} for user in mock_plex_server.accounts
+ },
}
}
diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py
index 5c6189a811e2b7..a8bc9fe98239c2 100644
--- a/tests/components/prometheus/test_init.py
+++ b/tests/components/prometheus/test_init.py
@@ -5,7 +5,11 @@
from homeassistant.components import climate, sensor
from homeassistant.components.demo.sensor import DemoSensor
import homeassistant.components.prometheus as prometheus
-from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR
+from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ DEVICE_CLASS_POWER,
+ ENERGY_KILO_WATT_HOUR,
+)
from homeassistant.setup import async_setup_component
@@ -47,7 +51,12 @@ async def prometheus_client(loop, hass, hass_client):
await sensor4.async_update_ha_state()
sensor5 = DemoSensor(
- None, "SPS30 PM <1µm Weight concentration", 3.7069, None, "µg/m³", None
+ None,
+ "SPS30 PM <1µm Weight concentration",
+ 3.7069,
+ None,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ None,
)
sensor5.hass = hass
sensor5.entity_id = "sensor.sps30_pm_1um_weight_concentration"
diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py
index 81f81093a67d2c..7c021199952720 100644
--- a/tests/components/ps4/test_config_flow.py
+++ b/tests/components/ps4/test_config_flow.py
@@ -5,7 +5,12 @@
from homeassistant import data_entry_flow
from homeassistant.components import ps4
-from homeassistant.components.ps4.const import DEFAULT_NAME, DEFAULT_REGION
+from homeassistant.components.ps4.const import (
+ DEFAULT_ALIAS,
+ DEFAULT_NAME,
+ DEFAULT_REGION,
+ DOMAIN,
+)
from homeassistant.const import (
CONF_CODE,
CONF_HOST,
@@ -16,10 +21,12 @@
)
from homeassistant.util import location
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, mock_coro
MOCK_TITLE = "PlayStation 4"
-MOCK_CODE = "12345678"
+MOCK_CODE = 12345678
+MOCK_CODE_LEAD_0 = 1234567
+MOCK_CODE_LEAD_0_STR = "01234567"
MOCK_CREDS = "000aa000"
MOCK_HOST = "192.0.0.0"
MOCK_HOST_ADDITIONAL = "192.0.0.1"
@@ -293,6 +300,42 @@ async def test_additional_device(hass):
assert len(manager.async_entries()) == 2
+async def test_0_pin(hass):
+ """Test Pin with leading '0' is passed correctly."""
+ with patch("pyps4_2ndscreen.Helper.get_creds", return_value=MOCK_CREDS):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "creds"}, data={},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "mode"
+
+ with patch(
+ "pyps4_2ndscreen.Helper.has_devices", return_value=[{"host-ip": MOCK_HOST}]
+ ), patch(
+ "homeassistant.components.ps4.config_flow.location.async_detect_location_info",
+ return_value=mock_coro(MOCK_LOCATION),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], MOCK_AUTO
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "link"
+
+ mock_config = MOCK_CONFIG
+ mock_config[CONF_CODE] = MOCK_CODE_LEAD_0
+ with patch(
+ "pyps4_2ndscreen.Helper.link", return_value=(True, True)
+ ) as mock_call, patch(
+ "pyps4_2ndscreen.Helper.has_devices", return_value=[{"host-ip": MOCK_HOST}]
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], mock_config
+ )
+ mock_call.assert_called_once_with(
+ MOCK_HOST, MOCK_CREDS, MOCK_CODE_LEAD_0_STR, DEFAULT_ALIAS
+ )
+
+
async def test_no_devices_found_abort(hass):
"""Test that failure to find devices aborts flow."""
flow = ps4.PlayStation4FlowHandler()
diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py
index 114daa7d2f7d98..c18476a92a9d34 100644
--- a/tests/components/radarr/test_sensor.py
+++ b/tests/components/radarr/test_sensor.py
@@ -4,6 +4,7 @@
import pytest
import homeassistant.components.radarr.sensor as radarr
+from homeassistant.const import DATA_GIGABYTES
from tests.common import get_test_home_assistant
@@ -218,7 +219,7 @@ def test_diskspace_no_paths(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": [],
"monitored_conditions": ["diskspace"],
}
@@ -227,7 +228,7 @@ def test_diskspace_no_paths(self, req_mock):
device.update()
assert "263.10" == device.state
assert "mdi:harddisk" == device.icon
- assert "GB" == device.unit_of_measurement
+ assert DATA_GIGABYTES == device.unit_of_measurement
assert "Radarr Disk Space" == device.name
assert "263.10/465.42GB (56.53%)" == device.device_state_attributes["/data"]
@@ -238,7 +239,7 @@ def test_diskspace_paths(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["diskspace"],
}
@@ -247,7 +248,7 @@ def test_diskspace_paths(self, req_mock):
device.update()
assert "263.10" == device.state
assert "mdi:harddisk" == device.icon
- assert "GB" == device.unit_of_measurement
+ assert DATA_GIGABYTES == device.unit_of_measurement
assert "Radarr Disk Space" == device.name
assert "263.10/465.42GB (56.53%)" == device.device_state_attributes["/data"]
@@ -258,7 +259,7 @@ def test_commands(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["commands"],
}
@@ -278,7 +279,7 @@ def test_movies(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["movies"],
}
@@ -298,7 +299,7 @@ def test_upcoming_multiple_days(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
@@ -325,7 +326,7 @@ def test_upcoming_today(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "1",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
@@ -348,7 +349,7 @@ def test_system_status(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["status"],
}
@@ -368,7 +369,7 @@ def test_ssl(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "1",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
"ssl": "true",
@@ -393,7 +394,7 @@ def test_exception_handling(self, req_mock):
"platform": "radarr",
"api_key": "foo",
"days": "1",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py
index 9e43f647301835..38dafdda9869ff 100644
--- a/tests/components/rainmachine/test_config_flow.py
+++ b/tests/components/rainmachine/test_config_flow.py
@@ -5,6 +5,7 @@
from homeassistant import data_entry_flow
from homeassistant.components.rainmachine import DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_PASSWORD,
@@ -25,12 +26,15 @@ async def test_duplicate_error(hass):
CONF_SSL: True,
}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.RainMachineFlowHandler()
- flow.hass = hass
+ MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass(
+ hass
+ )
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_IP_ADDRESS: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
async def test_invalid_password(hass):
@@ -44,6 +48,7 @@ async def test_invalid_password(hass):
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"homeassistant.components.rainmachine.config_flow.login",
@@ -57,6 +62,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -71,10 +77,12 @@ async def test_step_import(hass):
CONF_PASSWORD: "password",
CONF_PORT: 8080,
CONF_SSL: True,
+ CONF_SCAN_INTERVAL: 60,
}
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"homeassistant.components.rainmachine.config_flow.login",
@@ -100,10 +108,12 @@ async def test_step_user(hass):
CONF_PASSWORD: "password",
CONF_PORT: 8080,
CONF_SSL: True,
+ CONF_SCAN_INTERVAL: 60,
}
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"homeassistant.components.rainmachine.config_flow.login",
diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py
index ae04066651f029..a21ef578ca9c8a 100644
--- a/tests/components/recorder/test_init.py
+++ b/tests/components/recorder/test_init.py
@@ -198,7 +198,14 @@ def test_recorder_setup_failure():
):
setup.side_effect = ImportError("driver not found")
rec = Recorder(
- hass, keep_days=7, purge_interval=2, uri="sqlite://", include={}, exclude={}
+ hass,
+ keep_days=7,
+ purge_interval=2,
+ uri="sqlite://",
+ db_max_retries=10,
+ db_retry_wait=3,
+ include={},
+ exclude={},
)
rec.start()
rec.join()
diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py
index 7edbfa065ad418..30eeae9a8e38e6 100644
--- a/tests/components/rest/test_sensor.py
+++ b/tests/components/rest/test_sensor.py
@@ -6,10 +6,12 @@
from pytest import raises
import requests
from requests.exceptions import RequestException, Timeout
+from requests.structures import CaseInsensitiveDict
import requests_mock
import homeassistant.components.rest.sensor as rest
import homeassistant.components.sensor as sensor
+from homeassistant.const import DATA_MEGABYTES
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.config_validation import template
from homeassistant.setup import setup_component
@@ -125,7 +127,7 @@ def test_setup_get(self, mock_req):
"method": "GET",
"value_template": "{{ value_json.key }}",
"name": "foo",
- "unit_of_measurement": "MB",
+ "unit_of_measurement": DATA_MEGABYTES,
"verify_ssl": "true",
"timeout": 30,
"authentication": "basic",
@@ -153,7 +155,7 @@ def test_setup_post(self, mock_req):
"value_template": "{{ value_json.key }}",
"payload": '{ "device": "toaster"}',
"name": "foo",
- "unit_of_measurement": "MB",
+ "unit_of_measurement": DATA_MEGABYTES,
"verify_ssl": "true",
"timeout": 30,
"authentication": "basic",
@@ -165,6 +167,33 @@ def test_setup_post(self, mock_req):
)
assert 2 == mock_req.call_count
+ @requests_mock.Mocker()
+ def test_setup_get_xml(self, mock_req):
+ """Test setup with valid configuration."""
+ mock_req.get("http://localhost", status_code=200)
+ with assert_setup_component(1, "sensor"):
+ assert setup_component(
+ self.hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "rest",
+ "resource": "http://localhost",
+ "method": "GET",
+ "value_template": "{{ value_json.key }}",
+ "name": "foo",
+ "unit_of_measurement": DATA_MEGABYTES,
+ "verify_ssl": "true",
+ "timeout": 30,
+ "authentication": "basic",
+ "username": "my username",
+ "password": "my password",
+ "headers": {"Accept": "text/xml"},
+ }
+ },
+ )
+ assert 2 == mock_req.call_count
+
class TestRestSensor(unittest.TestCase):
"""Tests for REST sensor platform."""
@@ -177,13 +206,15 @@ def setUp(self):
self.rest.update = Mock(
"rest.RestData.update",
side_effect=self.update_side_effect(
- '{ "key": "' + self.initial_state + '" }'
+ '{ "key": "' + self.initial_state + '" }',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
),
)
self.name = "foo"
- self.unit_of_measurement = "MB"
+ self.unit_of_measurement = DATA_MEGABYTES
self.device_class = None
self.value_template = template("{{ value_json.key }}")
+ self.json_attrs_path = None
self.value_template.hass = self.hass
self.force_update = False
self.resource_template = None
@@ -198,15 +229,17 @@ def setUp(self):
[],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
- def update_side_effect(self, data):
+ def update_side_effect(self, data, headers):
"""Side effect function for mocking RestData.update()."""
self.rest.data = data
+ self.rest.headers = headers
def test_name(self):
"""Test the name."""
@@ -228,7 +261,8 @@ def test_state(self):
def test_update_when_value_is_none(self):
"""Test state gets updated to unknown when sensor returns no data."""
self.rest.update = Mock(
- "rest.RestData.update", side_effect=self.update_side_effect(None)
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(None, CaseInsensitiveDict()),
)
self.sensor.update()
assert self.sensor.state is None
@@ -238,7 +272,10 @@ def test_update_when_value_changed(self):
"""Test state gets updated when sensor returns a new status."""
self.rest.update = Mock(
"rest.RestData.update",
- side_effect=self.update_side_effect('{ "key": "updated_state" }'),
+ side_effect=self.update_side_effect(
+ '{ "key": "updated_state" }',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
+ ),
)
self.sensor.update()
assert "updated_state" == self.sensor.state
@@ -247,7 +284,10 @@ def test_update_when_value_changed(self):
def test_update_with_no_template(self):
"""Test update when there is no value template."""
self.rest.update = Mock(
- "rest.RestData.update", side_effect=self.update_side_effect("plain_state")
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ "plain_state", CaseInsensitiveDict({"Content-Type": "application/json"})
+ ),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -259,6 +299,7 @@ def test_update_with_no_template(self):
[],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
assert "plain_state" == self.sensor.state
@@ -268,7 +309,10 @@ def test_update_with_json_attrs(self):
"""Test attributes get extracted from a JSON result."""
self.rest.update = Mock(
"rest.RestData.update",
- side_effect=self.update_side_effect('{ "key": "some_json_value" }'),
+ side_effect=self.update_side_effect(
+ '{ "key": "some_json_value" }',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
+ ),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -280,6 +324,7 @@ def test_update_with_json_attrs(self):
["key"],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
assert "some_json_value" == self.sensor.device_state_attributes["key"]
@@ -288,7 +333,10 @@ def test_update_with_json_attrs_list_dict(self):
"""Test attributes get extracted from a JSON list[0] result."""
self.rest.update = Mock(
"rest.RestData.update",
- side_effect=self.update_side_effect('[{ "key": "another_value" }]'),
+ side_effect=self.update_side_effect(
+ '[{ "key": "another_value" }]',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
+ ),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -300,6 +348,7 @@ def test_update_with_json_attrs_list_dict(self):
["key"],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
assert "another_value" == self.sensor.device_state_attributes["key"]
@@ -308,7 +357,10 @@ def test_update_with_json_attrs_list_dict(self):
def test_update_with_json_attrs_no_data(self, mock_logger):
"""Test attributes when no JSON result fetched."""
self.rest.update = Mock(
- "rest.RestData.update", side_effect=self.update_side_effect(None)
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ None, CaseInsensitiveDict({"Content-Type": "application/json"})
+ ),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -320,6 +372,7 @@ def test_update_with_json_attrs_no_data(self, mock_logger):
["key"],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
assert {} == self.sensor.device_state_attributes
@@ -330,7 +383,10 @@ def test_update_with_json_attrs_not_dict(self, mock_logger):
"""Test attributes get extracted from a JSON result."""
self.rest.update = Mock(
"rest.RestData.update",
- side_effect=self.update_side_effect('["list", "of", "things"]'),
+ side_effect=self.update_side_effect(
+ '["list", "of", "things"]',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
+ ),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -342,6 +398,7 @@ def test_update_with_json_attrs_not_dict(self, mock_logger):
["key"],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
assert {} == self.sensor.device_state_attributes
@@ -352,7 +409,10 @@ def test_update_with_json_attrs_bad_JSON(self, mock_logger):
"""Test attributes get extracted from a JSON result."""
self.rest.update = Mock(
"rest.RestData.update",
- side_effect=self.update_side_effect("This is text rather than JSON data."),
+ side_effect=self.update_side_effect(
+ "This is text rather than JSON data.",
+ CaseInsensitiveDict({"Content-Type": "text/plain"}),
+ ),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -364,6 +424,7 @@ def test_update_with_json_attrs_bad_JSON(self, mock_logger):
["key"],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
assert {} == self.sensor.device_state_attributes
@@ -375,7 +436,8 @@ def test_update_with_json_attrs_and_template(self):
self.rest.update = Mock(
"rest.RestData.update",
side_effect=self.update_side_effect(
- '{ "key": "json_state_updated_value" }'
+ '{ "key": "json_state_updated_value" }',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
),
)
self.sensor = rest.RestSensor(
@@ -388,6 +450,7 @@ def test_update_with_json_attrs_and_template(self):
["key"],
self.force_update,
self.resource_template,
+ self.json_attrs_path,
)
self.sensor.update()
@@ -396,6 +459,136 @@ def test_update_with_json_attrs_and_template(self):
"json_state_updated_value" == self.sensor.device_state_attributes["key"]
), self.force_update
+ def test_update_with_json_attrs_with_json_attrs_path(self):
+ """Test attributes get extracted from a JSON result with a template for the attributes."""
+ json_attrs_path = "$.toplevel.second_level"
+ value_template = template("{{ value_json.toplevel.master_value }}")
+ value_template.hass = self.hass
+
+ self.rest.update = Mock(
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ '{ "toplevel": {"master_value": "master", "second_level": {"some_json_key": "some_json_value", "some_json_key2": "some_json_value2" } } }',
+ CaseInsensitiveDict({"Content-Type": "application/json"}),
+ ),
+ )
+ self.sensor = rest.RestSensor(
+ self.hass,
+ self.rest,
+ self.name,
+ self.unit_of_measurement,
+ self.device_class,
+ value_template,
+ ["some_json_key", "some_json_key2"],
+ self.force_update,
+ self.resource_template,
+ json_attrs_path,
+ )
+
+ self.sensor.update()
+ assert "some_json_value" == self.sensor.device_state_attributes["some_json_key"]
+ assert (
+ "some_json_value2" == self.sensor.device_state_attributes["some_json_key2"]
+ )
+ assert "master" == self.sensor.state
+
+ def test_update_with_xml_convert_json_attrs_with_json_attrs_path(self):
+ """Test attributes get extracted from a JSON result that was converted from XML with a template for the attributes."""
+ json_attrs_path = "$.toplevel.second_level"
+ value_template = template("{{ value_json.toplevel.master_value }}")
+ value_template.hass = self.hass
+
+ self.rest.update = Mock(
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ "mastersome_json_valuesome_json_value2",
+ CaseInsensitiveDict({"Content-Type": "text/xml+svg"}),
+ ),
+ )
+ self.sensor = rest.RestSensor(
+ self.hass,
+ self.rest,
+ self.name,
+ self.unit_of_measurement,
+ self.device_class,
+ value_template,
+ ["some_json_key", "some_json_key2"],
+ self.force_update,
+ self.resource_template,
+ json_attrs_path,
+ )
+
+ self.sensor.update()
+ assert "some_json_value" == self.sensor.device_state_attributes["some_json_key"]
+ assert (
+ "some_json_value2" == self.sensor.device_state_attributes["some_json_key2"]
+ )
+ assert "master" == self.sensor.state
+
+ def test_update_with_xml_convert_json_attrs_with_jsonattr_template(self):
+ """Test attributes get extracted from a JSON result that was converted from XML."""
+ json_attrs_path = "$.response"
+ value_template = template("{{ value_json.response.bss.wlan }}")
+ value_template.hass = self.hass
+
+ self.rest.update = Mock(
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ '01255648alexander000bogus000000000upupupup000x0XF0x0XF 0',
+ CaseInsensitiveDict({"Content-Type": "text/xml"}),
+ ),
+ )
+ self.sensor = rest.RestSensor(
+ self.hass,
+ self.rest,
+ self.name,
+ self.unit_of_measurement,
+ self.device_class,
+ value_template,
+ ["led0", "led1", "temp0", "time0", "ver"],
+ self.force_update,
+ self.resource_template,
+ json_attrs_path,
+ )
+
+ self.sensor.update()
+ assert "0" == self.sensor.device_state_attributes["led0"]
+ assert "0" == self.sensor.device_state_attributes["led1"]
+ assert "0x0XF0x0XF" == self.sensor.device_state_attributes["temp0"]
+ assert "0" == self.sensor.device_state_attributes["time0"]
+ assert "12556" == self.sensor.device_state_attributes["ver"]
+ assert "bogus" == self.sensor.state
+
+ @patch("homeassistant.components.rest.sensor._LOGGER")
+ def test_update_with_xml_convert_bad_xml(self, mock_logger):
+ """Test attributes get extracted from a XML result with bad xml."""
+ value_template = template("{{ value_json.toplevel.master_value }}")
+ value_template.hass = self.hass
+
+ self.rest.update = Mock(
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ "this is not xml", CaseInsensitiveDict({"Content-Type": "text/xml"})
+ ),
+ )
+ self.sensor = rest.RestSensor(
+ self.hass,
+ self.rest,
+ self.name,
+ self.unit_of_measurement,
+ self.device_class,
+ value_template,
+ ["key"],
+ self.force_update,
+ self.resource_template,
+ self.json_attrs_path,
+ )
+
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+ assert mock_logger.debug.called
+
class TestRestData(unittest.TestCase):
"""Tests for RestData."""
diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py
index 18c4f94631853b..2a67cf5348ded3 100644
--- a/tests/components/rflink/test_binary_sensor.py
+++ b/tests/components/rflink/test_binary_sensor.py
@@ -130,6 +130,7 @@ def callback(event):
async_fire_time_changed(hass, future)
event_callback(on_event)
await hass.async_block_till_done()
+ await hass.async_block_till_done()
state = hass.states.get("binary_sensor.test2")
assert state.state == STATE_ON
assert len(events) == 1
@@ -140,6 +141,7 @@ def callback(event):
async_fire_time_changed(hass, future)
event_callback(on_event)
await hass.async_block_till_done()
+ await hass.async_block_till_done()
state = hass.states.get("binary_sensor.test2")
assert state.state == STATE_ON
assert len(events) == 2
@@ -149,6 +151,7 @@ def callback(event):
with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
+ await hass.async_block_till_done()
state = hass.states.get("binary_sensor.test2")
assert state.state == STATE_ON
assert len(events) == 2
@@ -158,6 +161,7 @@ def callback(event):
with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
+ await hass.async_block_till_done()
state = hass.states.get("binary_sensor.test2")
assert state.state == STATE_OFF
assert len(events) == 3
diff --git a/tests/components/rflink/test_cover.py b/tests/components/rflink/test_cover.py
index dc286502068b68..e10cdc20143023 100644
--- a/tests/components/rflink/test_cover.py
+++ b/tests/components/rflink/test_cover.py
@@ -144,6 +144,7 @@ def listener(event):
# test event for new unconfigured sensor
event_callback({"id": "protocol_0_0", "command": "down"})
await hass.async_block_till_done()
+ await hass.async_block_till_done()
assert calls[0].data == {"state": "down", "entity_id": DOMAIN + ".test"}
diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py
index b22730a331093b..87696191ac8bf6 100644
--- a/tests/components/rflink/test_light.py
+++ b/tests/components/rflink/test_light.py
@@ -4,7 +4,6 @@
control of RFLink switch devices.
"""
-
from homeassistant.components.light import ATTR_BRIGHTNESS
from homeassistant.components.rflink import EVENT_BUTTON_PRESSED
from homeassistant.const import (
@@ -185,6 +184,7 @@ def listener(event):
# test event for new unconfigured sensor
event_callback({"id": "protocol_0_0", "command": "off"})
await hass.async_block_till_done()
+ await hass.async_block_till_done()
assert calls[0].data == {"state": "off", "entity_id": DOMAIN + ".test"}
@@ -267,15 +267,11 @@ async def test_signal_repetitions_alternation(hass, monkeypatch):
# setup mocking rflink module
_, _, protocol, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch)
- hass.async_create_task(
- hass.services.async_call(
- DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test"}
- )
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test"}
)
- hass.async_create_task(
- hass.services.async_call(
- DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test1"}
- )
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test1"}
)
await hass.async_block_till_done()
@@ -299,24 +295,20 @@ async def test_signal_repetitions_cancelling(hass, monkeypatch):
# setup mocking rflink module
_, _, protocol, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch)
- hass.async_create_task(
- hass.services.async_call(
- DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test"}
- )
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test"}
)
- hass.async_create_task(
- hass.services.async_call(
- DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DOMAIN + ".test"}
- )
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DOMAIN + ".test"}, blocking=True
)
- await hass.async_block_till_done()
-
- assert protocol.send_command_ack.call_args_list[0][0][1] == "off"
- assert protocol.send_command_ack.call_args_list[1][0][1] == "on"
- assert protocol.send_command_ack.call_args_list[2][0][1] == "on"
- assert protocol.send_command_ack.call_args_list[3][0][1] == "on"
+ assert [call[0][1] for call in protocol.send_command_ack.call_args_list] == [
+ "off",
+ "on",
+ "on",
+ "on",
+ ]
async def test_type_toggle(hass, monkeypatch):
diff --git a/tests/components/rflink/test_switch.py b/tests/components/rflink/test_switch.py
index d1fced3320807e..bcade409d3ecb9 100644
--- a/tests/components/rflink/test_switch.py
+++ b/tests/components/rflink/test_switch.py
@@ -214,6 +214,7 @@ def listener(event):
# test event for new unconfigured sensor
event_callback({"id": "protocol_0_0", "command": "off"})
await hass.async_block_till_done()
+ await hass.async_block_till_done()
assert calls[0].data == {"state": "off", "entity_id": DOMAIN + ".test"}
diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py
index 809c71562c069f..39d2c63ffdd0cf 100644
--- a/tests/components/ring/test_init.py
+++ b/tests/components/ring/test_init.py
@@ -1,12 +1,10 @@
"""The tests for the Ring component."""
from asyncio import run_coroutine_threadsafe
-from copy import deepcopy
from datetime import timedelta
import unittest
import requests_mock
-from homeassistant import setup
import homeassistant.components.ring as ring
from tests.common import get_test_home_assistant, load_fixture
@@ -57,25 +55,3 @@ def test_setup(self, mock):
).result()
assert response
-
- @requests_mock.Mocker()
- def test_setup_component_no_login(self, mock):
- """Test the setup when no login is configured."""
- mock.post(
- "https://api.ring.com/clients_api/session",
- text=load_fixture("ring_session.json"),
- )
- conf = deepcopy(VALID_CONFIG)
- del conf["ring"]["username"]
- assert not setup.setup_component(self.hass, ring.DOMAIN, conf)
-
- @requests_mock.Mocker()
- def test_setup_component_no_pwd(self, mock):
- """Test the setup when no password is configured."""
- mock.post(
- "https://api.ring.com/clients_api/session",
- text=load_fixture("ring_session.json"),
- )
- conf = deepcopy(VALID_CONFIG)
- del conf["ring"]["password"]
- assert not setup.setup_component(self.hass, ring.DOMAIN, conf)
diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py
index 6cc727b1a1c292..5a2687e4cf91c0 100644
--- a/tests/components/ring/test_light.py
+++ b/tests/components/ring/test_light.py
@@ -7,7 +7,7 @@
async def test_entity_registry(hass, requests_mock):
- """Tests that the devices are registed in the entity registry."""
+ """Tests that the devices are registered in the entity registry."""
await setup_platform(hass, LIGHT_DOMAIN)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py
index e2a86014f1c717..6979fafc01d76d 100644
--- a/tests/components/ring/test_switch.py
+++ b/tests/components/ring/test_switch.py
@@ -7,7 +7,7 @@
async def test_entity_registry(hass, requests_mock):
- """Tests that the devices are registed in the entity registry."""
+ """Tests that the devices are registered in the entity registry."""
await setup_platform(hass, SWITCH_DOMAIN)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py
index ce6741f0703f05..91ee8a7205f2e1 100644
--- a/tests/components/samsungtv/test_config_flow.py
+++ b/tests/components/samsungtv/test_config_flow.py
@@ -4,6 +4,7 @@
from asynctest import mock
import pytest
from samsungctl.exceptions import AccessDenied, UnhandledResponse
+from websocket import WebSocketProtocolException
from homeassistant.components.samsungtv.const import (
CONF_MANUFACTURER,
@@ -51,7 +52,7 @@
"method": "legacy",
"port": None,
"host": "fake_host",
- "timeout": 1,
+ "timeout": 31,
}
@@ -87,7 +88,7 @@ async def test_user(hass, remote):
assert result["type"] == "create_entry"
assert result["title"] == "fake_name"
assert result["data"][CONF_HOST] == "fake_host"
- assert result["data"][CONF_NAME] is None
+ assert result["data"][CONF_NAME] == "fake_name"
assert result["data"][CONF_MANUFACTURER] is None
assert result["data"][CONF_MODEL] is None
assert result["data"][CONF_ID] is None
@@ -123,19 +124,19 @@ async def test_user_not_supported(hass):
assert result["reason"] == "not_supported"
-async def test_user_not_found(hass):
- """Test starting a flow by user but no device found."""
+async def test_user_not_successful(hass):
+ """Test starting a flow by user but no connection found."""
with patch(
"homeassistant.components.samsungtv.config_flow.Remote",
side_effect=OSError("Boom"),
), patch("homeassistant.components.samsungtv.config_flow.socket"):
- # device not found
+ # device not connectable
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
assert result["type"] == "abort"
- assert result["reason"] == "not_found"
+ assert result["reason"] == "not_successful"
async def test_user_already_configured(hass, remote):
@@ -170,9 +171,9 @@ async def test_ssdp(hass, remote):
result["flow_id"], user_input="whatever"
)
assert result["type"] == "create_entry"
- assert result["title"] == "fake_name (fake_model)"
+ assert result["title"] == "fake_model"
assert result["data"][CONF_HOST] == "fake_host"
- assert result["data"][CONF_NAME] == "fake_name"
+ assert result["data"][CONF_NAME] == "Samsung fake_model"
assert result["data"][CONF_MANUFACTURER] == "fake_manufacturer"
assert result["data"][CONF_MODEL] == "fake_model"
assert result["data"][CONF_ID] == "fake_uuid"
@@ -193,9 +194,9 @@ async def test_ssdp_noprefix(hass, remote):
result["flow_id"], user_input="whatever"
)
assert result["type"] == "create_entry"
- assert result["title"] == "fake2_name (fake2_model)"
+ assert result["title"] == "fake2_model"
assert result["data"][CONF_HOST] == "fake2_host"
- assert result["data"][CONF_NAME] == "fake2_name"
+ assert result["data"][CONF_NAME] == "Samsung fake2_model"
assert result["data"][CONF_MANUFACTURER] == "fake2_manufacturer"
assert result["data"][CONF_MODEL] == "fake2_model"
assert result["data"][CONF_ID] == "fake2_uuid"
@@ -245,7 +246,29 @@ async def test_ssdp_not_supported(hass):
assert result["reason"] == "not_supported"
-async def test_ssdp_not_found(hass):
+async def test_ssdp_not_supported_2(hass):
+ """Test starting a flow from discovery for not supported device."""
+ with patch(
+ "homeassistant.components.samsungtv.config_flow.Remote",
+ side_effect=WebSocketProtocolException("Boom"),
+ ), patch("homeassistant.components.samsungtv.config_flow.socket"):
+
+ # confirm to add the entry
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+
+ # device not supported
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input="whatever"
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_supported"
+
+
+async def test_ssdp_not_successful(hass):
"""Test starting a flow from discovery but no device found."""
with patch(
"homeassistant.components.samsungtv.config_flow.Remote",
@@ -264,7 +287,7 @@ async def test_ssdp_not_found(hass):
result["flow_id"], user_input="whatever"
)
assert result["type"] == "abort"
- assert result["reason"] == "not_found"
+ assert result["reason"] == "not_successful"
async def test_ssdp_already_in_progress(hass, remote):
@@ -380,7 +403,7 @@ async def test_autodetect_none(hass, remote):
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
assert result["type"] == "abort"
- assert result["reason"] == "not_found"
+ assert result["reason"] == "not_successful"
assert remote.call_count == 2
assert remote.call_args_list == [
call(AUTODETECT_WEBSOCKET),
diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py
index 55ec52b56ae193..cd31434e6b0127 100644
--- a/tests/components/samsungtv/test_init.py
+++ b/tests/components/samsungtv/test_init.py
@@ -32,7 +32,7 @@
}
REMOTE_CALL = {
"name": "HomeAssistant",
- "description": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_NAME],
+ "description": "HomeAssistant",
"id": "ha.component.samsung",
"method": "websocket",
"port": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_PORT],
@@ -44,11 +44,13 @@
@pytest.fixture(name="remote")
def remote_fixture():
"""Patch the samsungctl Remote."""
- with patch("homeassistant.components.samsungtv.socket"), patch(
+ with patch("homeassistant.components.samsungtv.socket") as socket1, patch(
"homeassistant.components.samsungtv.config_flow.socket"
- ), patch("homeassistant.components.samsungtv.config_flow.Remote"), patch(
+ ) as socket2, patch("homeassistant.components.samsungtv.config_flow.Remote"), patch(
"homeassistant.components.samsungtv.media_player.SamsungRemote"
) as remote:
+ socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS"
+ socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS"
yield remote
diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py
index 2b9f379515d7cd..ba245ce7d6f58a 100644
--- a/tests/components/samsungtv/test_media_player.py
+++ b/tests/components/samsungtv/test_media_player.py
@@ -75,15 +75,17 @@
@pytest.fixture(name="remote")
def remote_fixture():
"""Patch the samsungctl Remote."""
- with patch("homeassistant.components.samsungtv.config_flow.socket"), patch(
- "homeassistant.components.samsungtv.config_flow.Remote"
- ), patch(
+ with patch("homeassistant.components.samsungtv.config_flow.Remote"), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ) as socket1, patch(
"homeassistant.components.samsungtv.media_player.SamsungRemote"
) as remote_class, patch(
"homeassistant.components.samsungtv.socket"
- ):
+ ) as socket2:
remote = mock.Mock()
remote_class.return_value = remote
+ socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS"
+ socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS"
yield remote
@@ -135,11 +137,12 @@ async def test_update_on(hass, remote, mock_now):
async def test_update_off(hass, remote, mock_now):
"""Testing update tv off."""
+ await setup_samsungtv(hass, MOCK_CONFIG)
+
with patch(
"homeassistant.components.samsungtv.media_player.SamsungRemote",
side_effect=[OSError("Boom"), mock.DEFAULT],
- ), patch("homeassistant.components.samsungtv.config_flow.socket"):
- await setup_samsungtv(hass, MOCK_CONFIG)
+ ):
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
@@ -150,13 +153,35 @@ async def test_update_off(hass, remote, mock_now):
assert state.state == STATE_OFF
+async def test_update_access_denied(hass, remote, mock_now):
+ """Testing update tv unhandled response exception."""
+ await setup_samsungtv(hass, MOCK_CONFIG)
+
+ with patch(
+ "homeassistant.components.samsungtv.media_player.SamsungRemote",
+ side_effect=exceptions.AccessDenied("Boom"),
+ ):
+
+ next_update = mock_now + timedelta(minutes=5)
+ with patch("homeassistant.util.dt.utcnow", return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ assert [
+ flow
+ for flow in hass.config_entries.flow.async_progress()
+ if flow["context"]["source"] == "reauth"
+ ]
+
+
async def test_update_unhandled_response(hass, remote, mock_now):
"""Testing update tv unhandled response exception."""
+ await setup_samsungtv(hass, MOCK_CONFIG)
+
with patch(
"homeassistant.components.samsungtv.media_player.SamsungRemote",
side_effect=[exceptions.UnhandledResponse("Boom"), mock.DEFAULT],
- ), patch("homeassistant.components.samsungtv.config_flow.socket"):
- await setup_samsungtv(hass, MOCK_CONFIG)
+ ):
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py
index f26189eec6c92a..8211ff108572c3 100644
--- a/tests/components/scene/test_init.py
+++ b/tests/components/scene/test_init.py
@@ -3,7 +3,7 @@
import unittest
from homeassistant.components import light, scene
-from homeassistant.setup import setup_component
+from homeassistant.setup import async_setup_component, setup_component
from homeassistant.util.yaml import loader as yaml_loader
from tests.common import get_test_home_assistant
@@ -128,3 +128,11 @@ def test_activate_scene(self):
assert self.light_1.is_on
assert self.light_2.is_on
assert 100 == self.light_2.last_call("turn_on")[1].get("brightness")
+
+
+async def test_services_registered(hass):
+ """Test we register services with empty config."""
+ assert await async_setup_component(hass, "scene", {})
+ assert hass.services.has_service("scene", "reload")
+ assert hass.services.has_service("scene", "turn_on")
+ assert hass.services.has_service("scene", "apply")
diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py
index cb66c26b6a3587..dbaa5e6e117deb 100644
--- a/tests/components/script/test_init.py
+++ b/tests/components/script/test_init.py
@@ -358,9 +358,8 @@ async def test_turning_no_scripts_off(hass):
async def test_async_get_descriptions_script(hass):
"""Test async_set_service_schema for the script integration."""
- script = hass.components.script
script_config = {
- script.DOMAIN: {
+ DOMAIN: {
"test1": {"sequence": [{"service": "homeassistant.restart"}]},
"test2": {
"description": "test2",
@@ -375,18 +374,96 @@ async def test_async_get_descriptions_script(hass):
}
}
- await async_setup_component(hass, script.DOMAIN, script_config)
+ await async_setup_component(hass, DOMAIN, script_config)
descriptions = await hass.helpers.service.async_get_all_descriptions()
- assert descriptions[script.DOMAIN]["test1"]["description"] == ""
- assert not descriptions[script.DOMAIN]["test1"]["fields"]
+ assert descriptions[DOMAIN]["test1"]["description"] == ""
+ assert not descriptions[DOMAIN]["test1"]["fields"]
- assert descriptions[script.DOMAIN]["test2"]["description"] == "test2"
+ assert descriptions[DOMAIN]["test2"]["description"] == "test2"
assert (
- descriptions[script.DOMAIN]["test2"]["fields"]["param"]["description"]
+ descriptions[DOMAIN]["test2"]["fields"]["param"]["description"]
== "param_description"
)
assert (
- descriptions[script.DOMAIN]["test2"]["fields"]["param"]["example"]
- == "param_example"
+ descriptions[DOMAIN]["test2"]["fields"]["param"]["example"] == "param_example"
)
+
+
+async def test_extraction_functions(hass):
+ """Test extraction functions."""
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ "test1": {
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": "light.in_both"},
+ },
+ {
+ "service": "test.script",
+ "data": {"entity_id": "light.in_first"},
+ },
+ {"domain": "light", "device_id": "device-in-both"},
+ ]
+ },
+ "test2": {
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": "light.in_both"},
+ },
+ {
+ "condition": "state",
+ "entity_id": "sensor.condition",
+ "state": "100",
+ },
+ {"scene": "scene.hello"},
+ {"domain": "light", "device_id": "device-in-both"},
+ {"domain": "light", "device_id": "device-in-last"},
+ ],
+ },
+ }
+ },
+ )
+
+ assert set(script.scripts_with_entity(hass, "light.in_both")) == {
+ "script.test1",
+ "script.test2",
+ }
+ assert set(script.entities_in_script(hass, "script.test1")) == {
+ "light.in_both",
+ "light.in_first",
+ }
+ assert set(script.scripts_with_device(hass, "device-in-both")) == {
+ "script.test1",
+ "script.test2",
+ }
+ assert set(script.devices_in_script(hass, "script.test2")) == {
+ "device-in-both",
+ "device-in-last",
+ }
+
+
+async def test_config(hass):
+ """Test passing info in config."""
+ assert await async_setup_component(
+ hass,
+ "script",
+ {
+ "script": {
+ "test_script": {
+ "alias": "Script Name",
+ "icon": "mdi:party",
+ "sequence": [],
+ }
+ }
+ },
+ )
+
+ test_script = hass.states.get("script.test_script")
+ assert test_script.name == "Script Name"
+ assert test_script.attributes["icon"] == "mdi:party"
diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py
index cce98faa29029d..a379b91f82a848 100644
--- a/tests/components/search/test_init.py
+++ b/tests/components/search/test_init.py
@@ -131,6 +131,62 @@ async def test_search(hass):
},
)
+ await async_setup_component(
+ hass,
+ "script",
+ {
+ "script": {
+ "wled": {
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": wled_segment_1_entity.entity_id},
+ },
+ ]
+ },
+ "hue": {
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": hue_segment_1_entity.entity_id},
+ },
+ ]
+ },
+ }
+ },
+ )
+
+ assert await async_setup_component(
+ hass,
+ "automation",
+ {
+ "automation": [
+ {
+ "alias": "wled_entity",
+ "trigger": {"platform": "template", "value_template": "true"},
+ "action": [
+ {
+ "service": "test.script",
+ "data": {"entity_id": wled_segment_1_entity.entity_id},
+ },
+ ],
+ },
+ {
+ "alias": "wled_device",
+ "trigger": {"platform": "template", "value_template": "true"},
+ "action": [
+ {
+ "domain": "light",
+ "device_id": wled_device.id,
+ "entity_id": wled_segment_1_entity.entity_id,
+ "type": "turn_on",
+ },
+ ],
+ },
+ ]
+ },
+ )
+
# Explore the graph from every node and make sure we find the same results
expected = {
"config_entry": {wled_config_entry.entry_id},
@@ -139,6 +195,8 @@ async def test_search(hass):
"entity": {wled_segment_1_entity.entity_id, wled_segment_2_entity.entity_id},
"scene": {"scene.scene_wled_seg_1", "scene.scene_wled_hue"},
"group": {"group.wled", "group.wled_hue"},
+ "script": {"script.wled"},
+ "automation": {"automation.wled_entity", "automation.wled_device"},
}
for search_type, search_id in (
@@ -149,6 +207,9 @@ async def test_search(hass):
("entity", wled_segment_2_entity.entity_id),
("scene", "scene.scene_wled_seg_1"),
("group", "group.wled"),
+ ("script", "script.wled"),
+ ("automation", "automation.wled_entity"),
+ ("automation", "automation.wled_device"),
):
searcher = search.Searcher(hass, device_reg, entity_reg)
results = searcher.async_search(search_type, search_id)
@@ -176,6 +237,8 @@ async def test_search(hass):
"scene.scene_wled_hue",
},
"group": {"group.wled", "group.hue", "group.wled_hue"},
+ "script": {"script.wled", "script.hue"},
+ "automation": {"automation.wled_entity", "automation.wled_device"},
}
for search_type, search_id in (
("scene", "scene.scene_wled_hue"),
@@ -189,6 +252,23 @@ async def test_search(hass):
results == expected_combined
), f"Results for {search_type}/{search_id} do not match up"
+ for search_type, search_id in (
+ ("entity", "automation.non_existing"),
+ ("entity", "scene.non_existing"),
+ ("entity", "group.non_existing"),
+ ("entity", "script.non_existing"),
+ ("entity", "light.non_existing"),
+ ("area", "non_existing"),
+ ("config_entry", "non_existing"),
+ ("device", "non_existing"),
+ ("group", "group.non_existing"),
+ ("scene", "scene.non_existing"),
+ ("script", "script.non_existing"),
+ ("automation", "automation.non_existing"),
+ ):
+ searcher = search.Searcher(hass, device_reg, entity_reg)
+ assert searcher.async_search(search_type, search_id) == {}
+
async def test_ws_api(hass, hass_ws_client):
"""Test WS API."""
diff --git a/tests/components/sense/__init__.py b/tests/components/sense/__init__.py
new file mode 100644
index 00000000000000..bf0a87737b9762
--- /dev/null
+++ b/tests/components/sense/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Sense integration."""
diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py
new file mode 100644
index 00000000000000..fdce335b7cf06d
--- /dev/null
+++ b/tests/components/sense/test_config_flow.py
@@ -0,0 +1,75 @@
+"""Test the Sense config flow."""
+from asynctest import patch
+from sense_energy import SenseAPITimeoutException, SenseAuthenticationException
+
+from homeassistant import config_entries, setup
+from homeassistant.components.sense.const import DOMAIN
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch("sense_energy.ASyncSenseable.authenticate", return_value=True,), patch(
+ "homeassistant.components.sense.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.sense.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"timeout": "6", "email": "test-email", "password": "test-password"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "test-email"
+ assert result2["data"] == {
+ "timeout": 6,
+ "email": "test-email",
+ "password": "test-password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "sense_energy.ASyncSenseable.authenticate",
+ side_effect=SenseAuthenticationException,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"timeout": "6", "email": "test-email", "password": "test-password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "sense_energy.ASyncSenseable.authenticate",
+ side_effect=SenseAPITimeoutException,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"timeout": "6", "email": "test-email", "password": "test-password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py
index bd6a6ce4928191..f9d8bb640c32d2 100644
--- a/tests/components/sensor/test_device_condition.py
+++ b/tests/components/sensor/test_device_condition.py
@@ -33,7 +33,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py
index 7bb69388c1d5ed..8e4b5d1792a249 100644
--- a/tests/components/sensor/test_device_trigger.py
+++ b/tests/components/sensor/test_device_trigger.py
@@ -37,7 +37,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py
index 4548a3a6a3583d..3c0d10bd5b3a95 100644
--- a/tests/components/sighthound/test_image_processing.py
+++ b/tests/components/sighthound/test_image_processing.py
@@ -1,4 +1,6 @@
"""Tests for the Sighthound integration."""
+from copy import deepcopy
+import os
from unittest.mock import patch
import pytest
@@ -10,6 +12,8 @@
from homeassistant.core import callback
from homeassistant.setup import async_setup_component
+TEST_DIR = os.path.dirname(__file__)
+
VALID_CONFIG = {
ip.DOMAIN: {
"platform": "sighthound",
@@ -91,3 +95,23 @@ def capture_person_event(event):
state = hass.states.get(VALID_ENTITY_ID)
assert state.state == "2"
assert len(person_events) == 2
+
+
+async def test_save_image(hass, mock_image, mock_detections):
+ """Save a processed image."""
+ valid_config_save_file = deepcopy(VALID_CONFIG)
+ valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
+ await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ with patch(
+ "homeassistant.components.sighthound.image_processing.Image.open"
+ ) as pil_img_open:
+ pil_img = pil_img_open.return_value
+ pil_img = pil_img.convert.return_value
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
+ await hass.async_block_till_done()
+ state = hass.states.get(VALID_ENTITY_ID)
+ assert state.state == "2"
+ assert pil_img.save.call_count == 1
diff --git a/tests/components/signal_messenger/__init__.py b/tests/components/signal_messenger/__init__.py
new file mode 100644
index 00000000000000..e3b556f6c18c03
--- /dev/null
+++ b/tests/components/signal_messenger/__init__.py
@@ -0,0 +1 @@
+"""Tests for the signal_messenger component."""
diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py
new file mode 100644
index 00000000000000..dbfd19795e8c33
--- /dev/null
+++ b/tests/components/signal_messenger/test_notify.py
@@ -0,0 +1,122 @@
+"""The tests for the signal_messenger platform."""
+
+import os
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from pysignalclirestapi import SignalCliRestApi
+import requests_mock
+
+import homeassistant.components.signal_messenger.notify as signalmessenger
+from homeassistant.setup import async_setup_component
+
+BASE_COMPONENT = "notify"
+
+
+async def test_signal_messenger_init(hass):
+ """Test that service loads successfully."""
+
+ config = {
+ BASE_COMPONENT: {
+ "name": "test",
+ "platform": "signal_messenger",
+ "url": "http://127.0.0.1:8080",
+ "number": "+43443434343",
+ "recipients": ["+435565656565"],
+ }
+ }
+
+ with patch("pysignalclirestapi.SignalCliRestApi.send_message", return_value=None):
+ assert await async_setup_component(hass, BASE_COMPONENT, config)
+ await hass.async_block_till_done()
+
+ # Test that service loads successfully
+ assert hass.services.has_service(BASE_COMPONENT, "test")
+
+
+class TestSignalMesssenger(unittest.TestCase):
+ """Test the signal_messenger notify."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ recipients = ["+435565656565"]
+ number = "+43443434343"
+ client = SignalCliRestApi("http://127.0.0.1:8080", number)
+ self._signalmessenger = signalmessenger.SignalNotificationService(
+ recipients, client
+ )
+
+ @requests_mock.Mocker()
+ def test_send_message(self, mock):
+ """Test send message."""
+ message = "Testing Signal Messenger platform :)"
+ mock.register_uri(
+ "POST", "http://127.0.0.1:8080/v2/send", status_code=201,
+ )
+ mock.register_uri(
+ "GET",
+ "http://127.0.0.1:8080/v1/about",
+ status_code=200,
+ json={"versions": ["v1", "v2"]},
+ )
+ with self.assertLogs(
+ "homeassistant.components.signal_messenger.notify", level="DEBUG"
+ ) as context:
+ self._signalmessenger.send_message(message)
+ self.assertIn("Sending signal message", context.output[0])
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 2)
+
+ @requests_mock.Mocker()
+ def test_send_message_should_show_deprecation_warning(self, mock):
+ """Test send message."""
+ message = "Testing Signal Messenger platform with attachment :)"
+ mock.register_uri(
+ "POST", "http://127.0.0.1:8080/v2/send", status_code=201,
+ )
+ mock.register_uri(
+ "GET",
+ "http://127.0.0.1:8080/v1/about",
+ status_code=200,
+ json={"versions": ["v1", "v2"]},
+ )
+ with self.assertLogs(
+ "homeassistant.components.signal_messenger.notify", level="WARNING"
+ ) as context:
+ with tempfile.NamedTemporaryFile(
+ suffix=".png", prefix=os.path.basename(__file__)
+ ) as tf:
+ data = {"data": {"attachment": tf.name}}
+ self._signalmessenger.send_message(message, **data)
+ self.assertIn(
+ "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108.",
+ context.output[0],
+ )
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 2)
+
+ @requests_mock.Mocker()
+ def test_send_message_with_attachment(self, mock):
+ """Test send message."""
+ message = "Testing Signal Messenger platform :)"
+ mock.register_uri(
+ "POST", "http://127.0.0.1:8080/v2/send", status_code=201,
+ )
+ mock.register_uri(
+ "GET",
+ "http://127.0.0.1:8080/v1/about",
+ status_code=200,
+ json={"versions": ["v1", "v2"]},
+ )
+ with self.assertLogs(
+ "homeassistant.components.signal_messenger.notify", level="DEBUG"
+ ) as context:
+ with tempfile.NamedTemporaryFile(
+ suffix=".png", prefix=os.path.basename(__file__)
+ ) as tf:
+ data = {"data": {"attachments": [tf.name]}}
+ self._signalmessenger.send_message(message, **data)
+ self.assertIn("Sending signal message", context.output[0])
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 2)
diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py
index 2d40495215af51..496c6d88954775 100644
--- a/tests/components/simplisafe/test_config_flow.py
+++ b/tests/components/simplisafe/test_config_flow.py
@@ -2,8 +2,11 @@
import json
from unittest.mock import MagicMock, PropertyMock, mock_open, patch
+from simplipy.errors import SimplipyError
+
from homeassistant import data_entry_flow
from homeassistant.components.simplisafe import DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from tests.common import MockConfigEntry, mock_coro
@@ -20,22 +23,25 @@ async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.SimpliSafeFlowHandler()
- flow.hass = hass
+ MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass(
+ hass
+ )
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_USERNAME: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
async def test_invalid_credentials(hass):
"""Test that invalid credentials throws an error."""
- from simplipy.errors import SimplipyError
-
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"simplipy.API.login_via_credentials",
@@ -49,6 +55,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -62,6 +69,7 @@ async def test_step_import(hass):
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
mop = mock_open(read_data=json.dumps({"refresh_token": "12345"}))
@@ -91,6 +99,7 @@ async def test_step_user(hass):
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
mop = mock_open(read_data=json.dumps({"refresh_token": "12345"}))
diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py
index 92557f9d5439e9..952e82c01be2d5 100644
--- a/tests/components/smhi/test_weather.py
+++ b/tests/components/smhi/test_weather.py
@@ -75,7 +75,7 @@ async def test_setup_hass(hass: HomeAssistant, aioclient_mock) -> None:
async def test_setup_plattform(hass):
- """Test that setup plattform does nothing."""
+ """Test that setup platform does nothing."""
assert await weather_smhi.async_setup_platform(hass, None, None) is None
diff --git a/tests/components/solaredge/test_config_flow.py b/tests/components/solaredge/test_config_flow.py
index 46f40dd80efe49..759639362e417b 100644
--- a/tests/components/solaredge/test_config_flow.py
+++ b/tests/components/solaredge/test_config_flow.py
@@ -18,7 +18,7 @@
@pytest.fixture(name="test_api")
def mock_controller():
- """Mock a successfull Solaredge API."""
+ """Mock a successful Solaredge API."""
api = Mock()
api.get_details.return_value = {"details": {"status": "active"}}
with patch("solaredge.Solaredge", return_value=api):
diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py
index cd05cf13185d49..7828290560a089 100644
--- a/tests/components/solarlog/test_config_flow.py
+++ b/tests/components/solarlog/test_config_flow.py
@@ -46,7 +46,7 @@ async def test_form(hass):
@pytest.fixture(name="test_connect")
def mock_controller():
- """Mock a successfull _host_in_configuration_exists."""
+ """Mock a successful _host_in_configuration_exists."""
with patch(
"homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection",
side_effect=lambda *_: mock_coro(True),
diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py
index 38382dc70ab00b..300d201079b675 100644
--- a/tests/components/sonarr/test_sensor.py
+++ b/tests/components/sonarr/test_sensor.py
@@ -6,6 +6,7 @@
import pytest
import homeassistant.components.sonarr.sensor as sonarr
+from homeassistant.const import DATA_GIGABYTES
from tests.common import get_test_home_assistant
@@ -497,7 +498,7 @@ def test_diskspace_no_paths(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": [],
"monitored_conditions": ["diskspace"],
}
@@ -506,7 +507,7 @@ def test_diskspace_no_paths(self, req_mock):
device.update()
assert "263.10" == device.state
assert "mdi:harddisk" == device.icon
- assert "GB" == device.unit_of_measurement
+ assert DATA_GIGABYTES == device.unit_of_measurement
assert "Sonarr Disk Space" == device.name
assert "263.10/465.42GB (56.53%)" == device.device_state_attributes["/data"]
@@ -517,7 +518,7 @@ def test_diskspace_paths(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["diskspace"],
}
@@ -526,7 +527,7 @@ def test_diskspace_paths(self, req_mock):
device.update()
assert "263.10" == device.state
assert "mdi:harddisk" == device.icon
- assert "GB" == device.unit_of_measurement
+ assert DATA_GIGABYTES == device.unit_of_measurement
assert "Sonarr Disk Space" == device.name
assert "263.10/465.42GB (56.53%)" == device.device_state_attributes["/data"]
@@ -537,7 +538,7 @@ def test_commands(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["commands"],
}
@@ -557,7 +558,7 @@ def test_queue(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["queue"],
}
@@ -577,7 +578,7 @@ def test_series(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["series"],
}
@@ -599,7 +600,7 @@ def test_wanted(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["wanted"],
}
@@ -621,7 +622,7 @@ def test_upcoming_multiple_days(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
@@ -645,7 +646,7 @@ def test_upcoming_today(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "1",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
@@ -665,7 +666,7 @@ def test_system_status(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "2",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["status"],
}
@@ -685,7 +686,7 @@ def test_ssl(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "1",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
"ssl": "true",
@@ -707,7 +708,7 @@ def test_exception_handling(self, req_mock):
"platform": "sonarr",
"api_key": "foo",
"days": "1",
- "unit": "GB",
+ "unit": DATA_GIGABYTES,
"include_paths": ["/data"],
"monitored_conditions": ["upcoming"],
}
diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py
index e0257585ad5a39..246d1eb16277f3 100644
--- a/tests/components/sonos/conftest.py
+++ b/tests/components/sonos/conftest.py
@@ -33,7 +33,7 @@ def soco_fixture(music_library, speaker_info, dummy_soco_service):
yield mock_soco
-@pytest.fixture(name="discover")
+@pytest.fixture(name="discover", autouse=True)
def discover_fixture(soco):
"""Create a mock pysonos discover fixture."""
diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py
index d21d3f017927e1..5014ded96bb4bb 100644
--- a/tests/components/sonos/test_media_player.py
+++ b/tests/components/sonos/test_media_player.py
@@ -1,5 +1,9 @@
"""Tests for the Sonos Media Player platform."""
+import pytest
+
from homeassistant.components.sonos import DOMAIN, media_player
+from homeassistant.core import Context
+from homeassistant.exceptions import Unauthorized
from homeassistant.setup import async_setup_component
@@ -24,3 +28,17 @@ async def test_async_setup_entry_discover(hass, config_entry, discover):
entity = hass.data[media_player.DATA_SONOS].entities[0]
assert entity.unique_id == "RINCON_test"
+
+
+async def test_services(hass, config_entry, config, hass_read_only_user):
+ """Test join/unjoin requires control access."""
+ await setup_platform(hass, config_entry, config)
+
+ with pytest.raises(Unauthorized):
+ await hass.services.async_call(
+ DOMAIN,
+ media_player.SERVICE_JOIN,
+ {"master": "media_player.bla", "entity_id": "media_player.blub"},
+ blocking=True,
+ context=Context(user_id=hass_read_only_user.id),
+ )
diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py
index 8789db1ca1f23b..b18f9efda97187 100644
--- a/tests/components/soundtouch/test_media_player.py
+++ b/tests/components/soundtouch/test_media_player.py
@@ -1,34 +1,121 @@
"""Test the Soundtouch component."""
-import logging
-import unittest
-from unittest import mock
-
-from libsoundtouch.device import Config, Preset, SoundTouchDevice as STD, Status, Volume
-
+from unittest.mock import call
+
+from asynctest import patch
+from libsoundtouch.device import (
+ Config,
+ Preset,
+ SoundTouchDevice as STD,
+ Status,
+ Volume,
+ ZoneSlave,
+ ZoneStatus,
+)
+import pytest
+
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_CONTENT_TYPE,
+)
from homeassistant.components.soundtouch import media_player as soundtouch
+from homeassistant.components.soundtouch.const import DOMAIN
+from homeassistant.components.soundtouch.media_player import DATA_SOUNDTOUCH
from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.setup import async_setup_component
+
+# pylint: disable=super-init-not-called
+
+
+DEVICE_1_IP = "192.168.0.1"
+DEVICE_2_IP = "192.168.0.2"
+
+
+def get_config(host=DEVICE_1_IP, port=8090, name="soundtouch"):
+ """Return a default component."""
+ return {"platform": DOMAIN, "host": host, "port": port, "name": name}
+
+
+DEVICE_1_CONFIG = {**get_config(), "name": "soundtouch_1"}
+DEVICE_2_CONFIG = {**get_config(), "host": DEVICE_2_IP, "name": "soundtouch_2"}
-from tests.common import get_test_home_assistant
+@pytest.fixture(name="one_device")
+def one_device_fixture():
+ """Mock one master device."""
+ device_1 = MockDevice()
+ device_patch = patch(
+ "homeassistant.components.soundtouch.media_player.soundtouch_device",
+ return_value=device_1,
+ )
+ with device_patch as device:
+ yield device
-class MockService:
- """Mock Soundtouch service."""
- def __init__(self, master, slaves):
- """Create a new service."""
- self.data = {"master": master, "slaves": slaves}
+@pytest.fixture(name="two_zones")
+def two_zones_fixture():
+ """Mock one master and one slave."""
+ device_1 = MockDevice(
+ MockZoneStatus(
+ is_master=True,
+ master_id=1,
+ master_ip=DEVICE_1_IP,
+ slaves=[MockZoneSlave(DEVICE_2_IP)],
+ )
+ )
+ device_2 = MockDevice(
+ MockZoneStatus(
+ is_master=False,
+ master_id=1,
+ master_ip=DEVICE_1_IP,
+ slaves=[MockZoneSlave(DEVICE_2_IP)],
+ )
+ )
+ devices = {DEVICE_1_IP: device_1, DEVICE_2_IP: device_2}
+ device_patch = patch(
+ "homeassistant.components.soundtouch.media_player.soundtouch_device",
+ side_effect=lambda host, _: devices[host],
+ )
+ with device_patch as device:
+ yield device
-def _mock_soundtouch_device(*args, **kwargs):
- return MockDevice()
+@pytest.fixture(name="mocked_status")
+def status_fixture():
+ """Mock the device status."""
+ status_patch = patch(
+ "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusPlaying
+ )
+ with status_patch as status:
+ yield status
+
+
+@pytest.fixture(name="mocked_volume")
+def volume_fixture():
+ """Mock the device volume."""
+ volume_patch = patch("libsoundtouch.device.SoundTouchDevice.volume")
+ with volume_patch as volume:
+ yield volume
+
+
+async def setup_soundtouch(hass, config):
+ """Set up soundtouch integration."""
+ assert await async_setup_component(hass, "media_player", {"media_player": config})
+ await hass.async_block_till_done()
+ await hass.async_start()
class MockDevice(STD):
"""Mock device."""
- def __init__(self):
+ def __init__(self, zone_status=None):
"""Init the class."""
- self._config = MockConfig
+ self._config = MockConfig()
+ self._zone_status = zone_status or MockZoneStatus()
+
+ def zone_status(self, refresh=True):
+ """Zone status mock object."""
+ return self._zone_status
class MockConfig(Config):
@@ -39,6 +126,26 @@ def __init__(self):
self._name = "name"
+class MockZoneStatus(ZoneStatus):
+ """Mock zone status."""
+
+ def __init__(self, is_master=True, master_id=None, master_ip=None, slaves=None):
+ """Init the class."""
+ self._is_master = is_master
+ self._master_id = master_id
+ self._master_ip = master_ip
+ self._slaves = slaves or []
+
+
+class MockZoneSlave(ZoneSlave):
+ """Mock zone slave."""
+
+ def __init__(self, device_ip=None, role=None):
+ """Init the class."""
+ self._ip = device_ip
+ self._role = role
+
+
def _mocked_presets(*args, **kwargs):
"""Return a list of mocked presets."""
return [MockPreset("1")]
@@ -59,6 +166,7 @@ class MockVolume(Volume):
def __init__(self):
"""Init class."""
self._actual = 12
+ self._muted = False
class MockVolumeMuted(Volume):
@@ -130,697 +238,623 @@ def __init__(self):
"""Init the class."""
self._source = ""
self._play_status = "PAUSE_STATE"
+ self._image = "image.url"
+ self._artist = None
+ self._track = None
+ self._album = None
+ self._duration = None
+ self._station_name = None
-def default_component():
- """Return a default component."""
- return {"host": "192.168.0.1", "port": 8090, "name": "soundtouch"}
+async def test_ensure_setup_config(mocked_status, mocked_volume, hass, one_device):
+ """Test setup OK with custom config."""
+ await setup_soundtouch(
+ hass, get_config(host="192.168.1.44", port=8888, name="custom_sound")
+ )
+ assert one_device.call_count == 1
+ assert one_device.call_args == call("192.168.1.44", 8888)
+ assert len(hass.states.async_all()) == 1
+ state = hass.states.get("media_player.custom_sound")
+ assert state.name == "custom_sound"
-class TestSoundtouchMediaPlayer(unittest.TestCase):
- """Bose Soundtouch test class."""
- def setUp(self): # pylint: disable=invalid-name
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- logging.disable(logging.CRITICAL)
+async def test_ensure_setup_discovery(mocked_status, mocked_volume, hass, one_device):
+ """Test setup with discovery."""
+ new_device = {
+ "port": "8090",
+ "host": "192.168.1.1",
+ "properties": {},
+ "hostname": "hostname.local",
+ }
+ await async_load_platform(
+ hass, "media_player", DOMAIN, new_device, {"media_player": {}}
+ )
+ await hass.async_block_till_done()
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- logging.disable(logging.NOTSET)
- self.hass.stop()
+ assert one_device.call_count == 1
+ assert one_device.call_args == call("192.168.1.1", 8090)
+ assert len(hass.states.async_all()) == 1
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=None,
- )
- def test_ensure_setup_config(self, mocked_soundtouch_device):
- """Test setup OK with custom config."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert len(all_devices) == 1
- assert all_devices[0].name == "soundtouch"
- assert all_devices[0].config["port"] == 8090
- assert mocked_soundtouch_device.call_count == 1
-
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=None,
- )
- def test_ensure_setup_discovery(self, mocked_soundtouch_device):
- """Test setup with discovery."""
- new_device = {
- "port": "8090",
- "host": "192.168.1.1",
- "properties": {},
- "hostname": "hostname.local",
- }
- soundtouch.setup_platform(self.hass, None, mock.MagicMock(), new_device)
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert len(all_devices) == 1
- assert all_devices[0].config["port"] == 8090
- assert all_devices[0].config["host"] == "192.168.1.1"
- assert mocked_soundtouch_device.call_count == 1
-
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=None,
- )
- def test_ensure_setup_discovery_no_duplicate(self, mocked_soundtouch_device):
- """Test setup OK if device already exists."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert len(self.hass.data[soundtouch.DATA_SOUNDTOUCH]) == 1
- new_device = {
- "port": "8090",
- "host": "192.168.1.1",
- "properties": {},
- "hostname": "hostname.local",
- }
- soundtouch.setup_platform(
- self.hass, None, mock.MagicMock(), new_device # New device
- )
- assert len(self.hass.data[soundtouch.DATA_SOUNDTOUCH]) == 2
- existing_device = {
- "port": "8090",
- "host": "192.168.0.1",
- "properties": {},
- "hostname": "hostname.local",
- }
- soundtouch.setup_platform(
- self.hass, None, mock.MagicMock(), existing_device # Existing device
- )
- assert mocked_soundtouch_device.call_count == 2
- assert len(self.hass.data[soundtouch.DATA_SOUNDTOUCH]) == 2
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_update(self, mocked_soundtouch_device, mocked_status, mocked_volume):
- """Test update device state."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- self.hass.data[soundtouch.DATA_SOUNDTOUCH][0].update()
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusPlaying
+async def test_ensure_setup_discovery_no_duplicate(
+ mocked_status, mocked_volume, hass, one_device
+):
+ """Test setup OK if device already exists."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert len(hass.states.async_all()) == 1
+
+ new_device = {
+ "port": "8090",
+ "host": "192.168.1.1",
+ "properties": {},
+ "hostname": "hostname.local",
+ }
+ await async_load_platform(
+ hass, "media_player", DOMAIN, new_device, {"media_player": DEVICE_1_CONFIG}
)
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_playing_media(
- self, mocked_soundtouch_device, mocked_status, mocked_volume
- ):
- """Test playing media info."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].state == STATE_PLAYING
- assert all_devices[0].media_image_url == "image.url"
- assert all_devices[0].media_title == "artist - track"
- assert all_devices[0].media_track == "track"
- assert all_devices[0].media_artist == "artist"
- assert all_devices[0].media_album_name == "album"
- assert all_devices[0].media_duration == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusUnknown
- )
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_playing_unknown_media(
- self, mocked_soundtouch_device, mocked_status, mocked_volume
- ):
- """Test playing media info."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].media_title is None
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.status",
- side_effect=MockStatusPlayingRadio,
- )
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_playing_radio(
- self, mocked_soundtouch_device, mocked_status, mocked_volume
- ):
- """Test playing radio info."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].state == STATE_PLAYING
- assert all_devices[0].media_image_url == "image.url"
- assert all_devices[0].media_title == "station"
- assert all_devices[0].media_track is None
- assert all_devices[0].media_artist is None
- assert all_devices[0].media_album_name is None
- assert all_devices[0].media_duration is None
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume", side_effect=MockVolume)
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_get_volume_level(
- self, mocked_soundtouch_device, mocked_status, mocked_volume
- ):
- """Test volume level."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].volume_level == 0.12
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusStandby
- )
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_get_state_off(
- self, mocked_soundtouch_device, mocked_status, mocked_volume
- ):
- """Test state device is off."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].state == STATE_OFF
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusPause
- )
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_get_state_pause(
- self, mocked_soundtouch_device, mocked_status, mocked_volume
- ):
- """Test state device is paused."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].state == STATE_PAUSED
-
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.volume", side_effect=MockVolumeMuted
- )
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_is_muted(self, mocked_soundtouch_device, mocked_status, mocked_volume):
- """Test device volume is muted."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].is_volume_muted is True
-
- @mock.patch("homeassistant.components.soundtouch.media_player.soundtouch_device")
- def test_media_commands(self, mocked_soundtouch_device):
- """Test supported media commands."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- assert mocked_soundtouch_device.call_count == 1
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert all_devices[0].supported_features == 18365
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.power_off")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_should_turn_off(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_power_off
- ):
- """Test device is turned off."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].turn_off()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 1
- assert mocked_power_off.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.power_on")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_should_turn_on(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_power_on
- ):
- """Test device is turned on."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].turn_on()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 1
- assert mocked_power_on.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume_up")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_volume_up(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_volume_up
- ):
- """Test volume up."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].volume_up()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 2
- assert mocked_volume_up.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume_down")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_volume_down(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_volume_down
- ):
- """Test volume down."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].volume_down()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 2
- assert mocked_volume_down.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.set_volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_set_volume_level(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_set_volume
- ):
- """Test set volume level."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].set_volume_level(0.17)
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 2
- mocked_set_volume.assert_called_with(17)
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.mute")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_mute(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_mute
- ):
- """Test mute volume."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].mute_volume(None)
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 2
- assert mocked_mute.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.play")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_play(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_play
- ):
- """Test play command."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].media_play()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 1
- assert mocked_play.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.pause")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_pause(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_pause
- ):
- """Test pause command."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].media_pause()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 1
- assert mocked_pause.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.play_pause")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_play_pause_play(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_play_pause
- ):
- """Test play/pause."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].media_play_pause()
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 1
- assert mocked_play_pause.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.previous_track")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.next_track")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_next_previous_track(
- self,
- mocked_soundtouch_device,
- mocked_status,
- mocked_volume,
- mocked_next_track,
- mocked_previous_track,
- ):
- """Test next/previous track."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices[0].media_next_track()
- assert mocked_status.call_count == 2
- assert mocked_next_track.call_count == 1
- all_devices[0].media_previous_track()
- assert mocked_status.call_count == 3
- assert mocked_previous_track.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.select_preset")
- @mock.patch(
- "libsoundtouch.device.SoundTouchDevice.presets", side_effect=_mocked_presets
- )
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_play_media(
- self,
- mocked_soundtouch_device,
- mocked_status,
- mocked_volume,
- mocked_presets,
- mocked_select_preset,
- ):
- """Test play preset 1."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices[0].play_media("PLAYLIST", 1)
- assert mocked_presets.call_count == 1
- assert mocked_select_preset.call_count == 1
- all_devices[0].play_media("PLAYLIST", 2)
- assert mocked_presets.call_count == 2
- assert mocked_select_preset.call_count == 1
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.play_url")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_play_media_url(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_play_url
- ):
- """Test play preset 1."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- assert mocked_soundtouch_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
- all_devices[0].play_media("MUSIC", "http://fqdn/file.mp3")
- mocked_play_url.assert_called_with("http://fqdn/file.mp3")
-
- @mock.patch("libsoundtouch.device.SoundTouchDevice.create_zone")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_play_everywhere(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_create_zone
- ):
- """Test play everywhere."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].entity_id = "media_player.entity_1"
- all_devices[1].entity_id = "media_player.entity_2"
- assert mocked_soundtouch_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
-
- # one master, one slave => create zone
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_PLAY_EVERYWHERE,
- {"master": "media_player.entity_1"},
- True,
- )
- assert mocked_create_zone.call_count == 1
-
- # unknown master. create zone is must not be called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_PLAY_EVERYWHERE,
- {"master": "media_player.entity_X"},
- True,
- )
- assert mocked_create_zone.call_count == 1
-
- # no slaves, create zone must not be called
- all_devices.pop(1)
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_PLAY_EVERYWHERE,
- {"master": "media_player.entity_1"},
- True,
- )
- assert mocked_create_zone.call_count == 1
+ await hass.async_block_till_done()
+ assert one_device.call_count == 2
+ assert len(hass.states.async_all()) == 2
- @mock.patch("libsoundtouch.device.SoundTouchDevice.create_zone")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_create_zone(
- self, mocked_soundtouch_device, mocked_status, mocked_volume, mocked_create_zone
- ):
- """Test creating a zone."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].entity_id = "media_player.entity_1"
- all_devices[1].entity_id = "media_player.entity_2"
- assert mocked_soundtouch_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
-
- # one master, one slave => create zone
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_CREATE_ZONE,
- {"master": "media_player.entity_1", "slaves": ["media_player.entity_2"]},
- True,
- )
- assert mocked_create_zone.call_count == 1
-
- # unknown master. create zone is must not be called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_CREATE_ZONE,
- {"master": "media_player.entity_X", "slaves": ["media_player.entity_2"]},
- True,
- )
- assert mocked_create_zone.call_count == 1
-
- # no slaves, create zone must not be called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_CREATE_ZONE,
- {"master": "media_player.entity_X", "slaves": []},
- True,
- )
- assert mocked_create_zone.call_count == 1
+ existing_device = {
+ "port": "8090",
+ "host": "192.168.0.1",
+ "properties": {},
+ "hostname": "hostname.local",
+ }
+ await async_load_platform(
+ hass, "media_player", DOMAIN, existing_device, {"media_player": DEVICE_1_CONFIG}
+ )
+ await hass.async_block_till_done()
+ assert one_device.call_count == 2
+ assert len(hass.states.async_all()) == 2
- @mock.patch("libsoundtouch.device.SoundTouchDevice.remove_zone_slave")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_remove_zone_slave(
- self,
- mocked_soundtouch_device,
- mocked_status,
- mocked_volume,
- mocked_remove_zone_slave,
- ):
- """Test adding a slave to an existing zone."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].entity_id = "media_player.entity_1"
- all_devices[1].entity_id = "media_player.entity_2"
- assert mocked_soundtouch_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
-
- # remove one slave
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
- {"master": "media_player.entity_1", "slaves": ["media_player.entity_2"]},
- True,
- )
- assert mocked_remove_zone_slave.call_count == 1
-
- # unknown master. add zone slave is not called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
- {"master": "media_player.entity_X", "slaves": ["media_player.entity_2"]},
- True,
- )
- assert mocked_remove_zone_slave.call_count == 1
-
- # no slave to add, add zone slave is not called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
- {"master": "media_player.entity_1", "slaves": []},
- True,
- )
- assert mocked_remove_zone_slave.call_count == 1
- @mock.patch("libsoundtouch.device.SoundTouchDevice.add_zone_slave")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.volume")
- @mock.patch("libsoundtouch.device.SoundTouchDevice.status")
- @mock.patch(
- "homeassistant.components.soundtouch.media_player.soundtouch_device",
- side_effect=_mock_soundtouch_device,
- )
- def test_add_zone_slave(
- self,
- mocked_soundtouch_device,
- mocked_status,
- mocked_volume,
- mocked_add_zone_slave,
- ):
- """Test removing a slave from a zone."""
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- soundtouch.setup_platform(self.hass, default_component(), mock.MagicMock())
- all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
- all_devices[0].entity_id = "media_player.entity_1"
- all_devices[1].entity_id = "media_player.entity_2"
- assert mocked_soundtouch_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
-
- # add one slave
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_ADD_ZONE_SLAVE,
- {"master": "media_player.entity_1", "slaves": ["media_player.entity_2"]},
- True,
- )
- assert mocked_add_zone_slave.call_count == 1
-
- # unknown master. add zone slave is not called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_ADD_ZONE_SLAVE,
- {"master": "media_player.entity_X", "slaves": ["media_player.entity_2"]},
- True,
- )
- assert mocked_add_zone_slave.call_count == 1
-
- # no slave to add, add zone slave is not called
- self.hass.services.call(
- soundtouch.DOMAIN,
- soundtouch.SERVICE_ADD_ZONE_SLAVE,
- {"master": "media_player.entity_1", "slaves": ["media_player.entity_X"]},
- True,
- )
- assert mocked_add_zone_slave.call_count == 1
+async def test_playing_media(mocked_status, mocked_volume, hass, one_device):
+ """Test playing media info."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.state == STATE_PLAYING
+ assert entity_1_state.attributes["media_title"] == "artist - track"
+ assert entity_1_state.attributes["media_track"] == "track"
+ assert entity_1_state.attributes["media_artist"] == "artist"
+ assert entity_1_state.attributes["media_album_name"] == "album"
+ assert entity_1_state.attributes["media_duration"] == 1
+
+
+async def test_playing_unknown_media(mocked_status, mocked_volume, hass, one_device):
+ """Test playing media info."""
+ mocked_status.side_effect = MockStatusUnknown
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.state == STATE_PLAYING
+
+
+async def test_playing_radio(mocked_status, mocked_volume, hass, one_device):
+ """Test playing radio info."""
+ mocked_status.side_effect = MockStatusPlayingRadio
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.state == STATE_PLAYING
+ assert entity_1_state.attributes["media_title"] == "station"
+
+
+async def test_get_volume_level(mocked_status, mocked_volume, hass, one_device):
+ """Test volume level."""
+ mocked_volume.side_effect = MockVolume
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.attributes["volume_level"] == 0.12
+
+
+async def test_get_state_off(mocked_status, mocked_volume, hass, one_device):
+ """Test state device is off."""
+ mocked_status.side_effect = MockStatusStandby
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.state == STATE_OFF
+
+
+async def test_get_state_pause(mocked_status, mocked_volume, hass, one_device):
+ """Test state device is paused."""
+ mocked_status.side_effect = MockStatusPause
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.state == STATE_PAUSED
+
+
+async def test_is_muted(mocked_status, mocked_volume, hass, one_device):
+ """Test device volume is muted."""
+ mocked_volume.side_effect = MockVolumeMuted
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.attributes["is_volume_muted"]
+
+
+async def test_media_commands(mocked_status, mocked_volume, hass, one_device):
+ """Test supported media commands."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.attributes["supported_features"] == 18365
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.power_off")
+async def test_should_turn_off(
+ mocked_power_off, mocked_status, mocked_volume, hass, one_device
+):
+ """Test device is turned off."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player", "turn_off", {"entity_id": "media_player.soundtouch_1"}, True,
+ )
+ assert mocked_status.call_count == 2
+ assert mocked_power_off.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.power_on")
+async def test_should_turn_on(
+ mocked_power_on, mocked_status, mocked_volume, hass, one_device
+):
+ """Test device is turned on."""
+ mocked_status.side_effect = MockStatusStandby
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player", "turn_on", {"entity_id": "media_player.soundtouch_1"}, True,
+ )
+ assert mocked_status.call_count == 2
+ assert mocked_power_on.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.volume_up")
+async def test_volume_up(
+ mocked_volume_up, mocked_status, mocked_volume, hass, one_device
+):
+ """Test volume up."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player", "volume_up", {"entity_id": "media_player.soundtouch_1"}, True,
+ )
+ assert mocked_volume.call_count == 2
+ assert mocked_volume_up.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.volume_down")
+async def test_volume_down(
+ mocked_volume_down, mocked_status, mocked_volume, hass, one_device
+):
+ """Test volume down."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player", "volume_down", {"entity_id": "media_player.soundtouch_1"}, True,
+ )
+ assert mocked_volume.call_count == 2
+ assert mocked_volume_down.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.set_volume")
+async def test_set_volume_level(
+ mocked_set_volume, mocked_status, mocked_volume, hass, one_device
+):
+ """Test set volume level."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "volume_set",
+ {"entity_id": "media_player.soundtouch_1", "volume_level": 0.17},
+ True,
+ )
+ assert mocked_volume.call_count == 2
+ mocked_set_volume.assert_called_with(17)
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.mute")
+async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device):
+ """Test mute volume."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "volume_mute",
+ {"entity_id": "media_player.soundtouch_1", "is_volume_muted": True},
+ True,
+ )
+ assert mocked_volume.call_count == 2
+ assert mocked_mute.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.play")
+async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device):
+ """Test play command."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player", "media_play", {"entity_id": "media_player.soundtouch_1"}, True,
+ )
+ assert mocked_status.call_count == 2
+ assert mocked_play.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.pause")
+async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_device):
+ """Test pause command."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player", "media_pause", {"entity_id": "media_player.soundtouch_1"}, True,
+ )
+ assert mocked_status.call_count == 2
+ assert mocked_pause.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.play_pause")
+async def test_play_pause(
+ mocked_play_pause, mocked_status, mocked_volume, hass, one_device
+):
+ """Test play/pause."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "media_play_pause",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
+ )
+ assert mocked_status.call_count == 2
+ assert mocked_play_pause.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.previous_track")
+@patch("libsoundtouch.device.SoundTouchDevice.next_track")
+async def test_next_previous_track(
+ mocked_next_track,
+ mocked_previous_track,
+ mocked_status,
+ mocked_volume,
+ hass,
+ one_device,
+):
+ """Test next/previous track."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "media_next_track",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
+ )
+ assert mocked_status.call_count == 2
+ assert mocked_next_track.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "media_previous_track",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
+ )
+ assert mocked_status.call_count == 3
+ assert mocked_previous_track.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.select_preset")
+@patch("libsoundtouch.device.SoundTouchDevice.presets", side_effect=_mocked_presets)
+async def test_play_media(
+ mocked_presets, mocked_select_preset, mocked_status, mocked_volume, hass, one_device
+):
+ """Test play preset 1."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "play_media",
+ {
+ "entity_id": "media_player.soundtouch_1",
+ ATTR_MEDIA_CONTENT_TYPE: "PLAYLIST",
+ ATTR_MEDIA_CONTENT_ID: 1,
+ },
+ True,
+ )
+ assert mocked_presets.call_count == 1
+ assert mocked_select_preset.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "play_media",
+ {
+ "entity_id": "media_player.soundtouch_1",
+ ATTR_MEDIA_CONTENT_TYPE: "PLAYLIST",
+ ATTR_MEDIA_CONTENT_ID: 2,
+ },
+ True,
+ )
+ assert mocked_presets.call_count == 2
+ assert mocked_select_preset.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.play_url")
+async def test_play_media_url(
+ mocked_play_url, mocked_status, mocked_volume, hass, one_device
+):
+ """Test play preset 1."""
+ await setup_soundtouch(hass, DEVICE_1_CONFIG)
+
+ assert one_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+
+ await hass.services.async_call(
+ "media_player",
+ "play_media",
+ {
+ "entity_id": "media_player.soundtouch_1",
+ ATTR_MEDIA_CONTENT_TYPE: "MUSIC",
+ ATTR_MEDIA_CONTENT_ID: "http://fqdn/file.mp3",
+ },
+ True,
+ )
+ mocked_play_url.assert_called_with("http://fqdn/file.mp3")
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.create_zone")
+async def test_play_everywhere(
+ mocked_create_zone, mocked_status, mocked_volume, hass, two_zones
+):
+ """Test play everywhere."""
+ mocked_device = two_zones
+ await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
+
+ assert mocked_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # one master, one slave => create zone
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_PLAY_EVERYWHERE,
+ {"master": "media_player.soundtouch_1"},
+ True,
+ )
+ assert mocked_create_zone.call_count == 1
+
+ # unknown master, create zone must not be called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_PLAY_EVERYWHERE,
+ {"master": "media_player.entity_X"},
+ True,
+ )
+ assert mocked_create_zone.call_count == 1
+
+ # no slaves, create zone must not be called
+ for entity in list(hass.data[DATA_SOUNDTOUCH]):
+ if entity.entity_id == "media_player.soundtouch_1":
+ continue
+ hass.data[DATA_SOUNDTOUCH].remove(entity)
+ await entity.async_remove()
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_PLAY_EVERYWHERE,
+ {"master": "media_player.soundtouch_1"},
+ True,
+ )
+ assert mocked_create_zone.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.create_zone")
+async def test_create_zone(
+ mocked_create_zone, mocked_status, mocked_volume, hass, two_zones
+):
+ """Test creating a zone."""
+ mocked_device = two_zones
+ await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
+
+ assert mocked_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # one master, one slave => create zone
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_CREATE_ZONE,
+ {
+ "master": "media_player.soundtouch_1",
+ "slaves": ["media_player.soundtouch_2"],
+ },
+ True,
+ )
+ assert mocked_create_zone.call_count == 1
+
+ # unknown master, create zone must not be called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_CREATE_ZONE,
+ {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]},
+ True,
+ )
+ assert mocked_create_zone.call_count == 1
+
+ # no slaves, create zone must not be called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_CREATE_ZONE,
+ {"master": "media_player.soundtouch_1", "slaves": []},
+ True,
+ )
+ assert mocked_create_zone.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.remove_zone_slave")
+async def test_remove_zone_slave(
+ mocked_remove_zone_slave, mocked_status, mocked_volume, hass, two_zones
+):
+ """Test adding a slave to an existing zone."""
+ mocked_device = two_zones
+ await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
+
+ assert mocked_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # remove one slave
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
+ {
+ "master": "media_player.soundtouch_1",
+ "slaves": ["media_player.soundtouch_2"],
+ },
+ True,
+ )
+ assert mocked_remove_zone_slave.call_count == 1
+
+ # unknown master. add zone slave is not called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
+ {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]},
+ True,
+ )
+ assert mocked_remove_zone_slave.call_count == 1
+
+ # no slave to add, add zone slave is not called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
+ {"master": "media_player.soundtouch_1", "slaves": []},
+ True,
+ )
+ assert mocked_remove_zone_slave.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.add_zone_slave")
+async def test_add_zone_slave(
+ mocked_add_zone_slave, mocked_status, mocked_volume, hass, two_zones,
+):
+ """Test removing a slave from a zone."""
+ mocked_device = two_zones
+ await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
+
+ assert mocked_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # add one slave
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_ADD_ZONE_SLAVE,
+ {
+ "master": "media_player.soundtouch_1",
+ "slaves": ["media_player.soundtouch_2"],
+ },
+ True,
+ )
+ assert mocked_add_zone_slave.call_count == 1
+
+ # unknown master, add zone slave is not called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_ADD_ZONE_SLAVE,
+ {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]},
+ True,
+ )
+ assert mocked_add_zone_slave.call_count == 1
+
+ # no slave to add, add zone slave is not called
+ await hass.services.async_call(
+ soundtouch.DOMAIN,
+ soundtouch.SERVICE_ADD_ZONE_SLAVE,
+ {"master": "media_player.soundtouch_1", "slaves": ["media_player.entity_X"]},
+ True,
+ )
+ assert mocked_add_zone_slave.call_count == 1
diff --git a/tests/components/spotify/__init__.py b/tests/components/spotify/__init__.py
new file mode 100644
index 00000000000000..51e3404d3ad391
--- /dev/null
+++ b/tests/components/spotify/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Spotify integration."""
diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py
new file mode 100644
index 00000000000000..eabaa57d3a8df3
--- /dev/null
+++ b/tests/components/spotify/test_config_flow.py
@@ -0,0 +1,139 @@
+"""Tests for the Spotify config flow."""
+from unittest.mock import patch
+
+from spotipy import SpotifyException
+
+from homeassistant import data_entry_flow, setup
+from homeassistant.components.spotify.const import (
+ CONF_CLIENT_ID,
+ CONF_CLIENT_SECRET,
+ DOMAIN,
+)
+from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
+from homeassistant.helpers import config_entry_oauth2_flow
+
+from tests.common import MockConfigEntry
+
+
+async def test_abort_if_no_configuration(hass):
+ """Check flow aborts when no configuration is present."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "missing_configuration"
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "missing_configuration"
+
+
+async def test_zeroconf_abort_if_existing_entry(hass):
+ """Check zeroconf flow aborts when an entry already exist."""
+ MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+ """Check a full flow."""
+ assert await setup.async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"},
+ "http": {"base_url": "https://example.com"},
+ },
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ # pylint: disable=protected-access
+ state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
+ assert result["url"] == (
+ "https://accounts.spotify.com/authorize"
+ "?response_type=code&client_id=client"
+ "&redirect_uri=https://example.com/auth/external/callback"
+ f"&state={state}"
+ "&scope=user-modify-playback-state,user-read-playback-state,user-read-private"
+ )
+
+ client = await aiohttp_client(hass.http.app)
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+ assert resp.headers["content-type"] == "text/html; charset=utf-8"
+
+ aioclient_mock.post(
+ "https://accounts.spotify.com/api/token",
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": "mock-access-token",
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch("homeassistant.components.spotify.config_flow.Spotify"):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["data"]["auth_implementation"] == DOMAIN
+ result["data"]["token"].pop("expires_at")
+ assert result["data"]["token"] == {
+ "refresh_token": "mock-refresh-token",
+ "access_token": "mock-access-token",
+ "type": "Bearer",
+ "expires_in": 60,
+ }
+
+
+async def test_abort_if_spotify_error(hass, aiohttp_client, aioclient_mock):
+ """Check Spotify errors causes flow to abort."""
+ await setup.async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"},
+ "http": {"base_url": "https://example.com"},
+ },
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ # pylint: disable=protected-access
+ state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
+ client = await aiohttp_client(hass.http.app)
+ await client.get(f"/auth/external/callback?code=abcd&state={state}")
+
+ aioclient_mock.post(
+ "https://accounts.spotify.com/api/token",
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": "mock-access-token",
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch(
+ "homeassistant.components.spotify.config_flow.Spotify.current_user",
+ side_effect=SpotifyException(400, -1, "message"),
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "connection_error"
diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py
index eac75a3b4e7697..82748c122abf3e 100644
--- a/tests/components/startca/test_sensor.py
+++ b/tests/components/startca/test_sensor.py
@@ -1,6 +1,7 @@
"""Tests for the Start.ca sensor platform."""
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.startca.sensor import StartcaData
+from homeassistant.const import DATA_GIGABYTES
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -55,47 +56,47 @@ async def test_capped_setup(hass, aioclient_mock):
assert state.state == "76.24"
state = hass.states.get("sensor.start_ca_usage")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "304.95"
state = hass.states.get("sensor.start_ca_data_limit")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "400"
state = hass.states.get("sensor.start_ca_used_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "304.95"
state = hass.states.get("sensor.start_ca_used_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "6.48"
state = hass.states.get("sensor.start_ca_used_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "311.43"
state = hass.states.get("sensor.start_ca_grace_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "304.95"
state = hass.states.get("sensor.start_ca_grace_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "6.48"
state = hass.states.get("sensor.start_ca_grace_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "311.43"
state = hass.states.get("sensor.start_ca_total_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "304.95"
state = hass.states.get("sensor.start_ca_total_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "6.48"
state = hass.states.get("sensor.start_ca_remaining")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "95.05"
@@ -150,47 +151,47 @@ async def test_unlimited_setup(hass, aioclient_mock):
assert state.state == "0"
state = hass.states.get("sensor.start_ca_usage")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "0.0"
state = hass.states.get("sensor.start_ca_data_limit")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "inf"
state = hass.states.get("sensor.start_ca_used_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "0.0"
state = hass.states.get("sensor.start_ca_used_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "0.0"
state = hass.states.get("sensor.start_ca_used_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "0.0"
state = hass.states.get("sensor.start_ca_grace_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "304.95"
state = hass.states.get("sensor.start_ca_grace_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "6.48"
state = hass.states.get("sensor.start_ca_grace_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "311.43"
state = hass.states.get("sensor.start_ca_total_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "304.95"
state = hass.states.get("sensor.start_ca_total_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "6.48"
state = hass.states.get("sensor.start_ca_remaining")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "inf"
diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py
index 293f8d1e4cfe29..888f56efb293f4 100644
--- a/tests/components/stream/test_hls.py
+++ b/tests/components/stream/test_hls.py
@@ -47,7 +47,7 @@ async def test_hls_stream(hass, hass_client):
# Stop stream, if it hasn't quit already
stream.stop()
- # Ensure playlist not accessable after stream ends
+ # Ensure playlist not accessible after stream ends
fail_response = await http_client.get(parsed_url.path)
assert fail_response.status == 404
@@ -84,7 +84,7 @@ async def test_stream_timeout(hass, hass_client):
future = dt_util.utcnow() + timedelta(minutes=5)
async_fire_time_changed(hass, future)
- # Ensure playlist not accessable
+ # Ensure playlist not accessible
fail_response = await http_client.get(parsed_url.path)
assert fail_response.status == 404
diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py
index 06ad7323eadeab..fbd24fe20952f3 100644
--- a/tests/components/switch/test_device_action.py
+++ b/tests/components/switch/test_device_action.py
@@ -32,7 +32,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py
index d51a00ddf79870..fe32fca9cb7459 100644
--- a/tests/components/switch/test_device_condition.py
+++ b/tests/components/switch/test_device_condition.py
@@ -35,7 +35,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py
index 19588ebfba0722..73d12d0a7296e6 100644
--- a/tests/components/switch/test_device_trigger.py
+++ b/tests/components/switch/test_device_trigger.py
@@ -35,7 +35,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py
index 0b1c4f917814e5..0ad87b59a81540 100644
--- a/tests/components/system_log/test_init.py
+++ b/tests/components/system_log/test_init.py
@@ -30,6 +30,7 @@ def _generate_and_log_exception(exception, log):
def assert_log(log, exception, message, level):
"""Assert that specified values are in a specific log entry."""
+ assert log["name"] == "test_logger"
assert exception in log["exception"]
assert message == log["message"]
assert level == log["level"]
diff --git a/tests/components/teksavvy/test_sensor.py b/tests/components/teksavvy/test_sensor.py
index 30bb98911f8e8c..641112e6362d2d 100644
--- a/tests/components/teksavvy/test_sensor.py
+++ b/tests/components/teksavvy/test_sensor.py
@@ -1,6 +1,7 @@
"""Tests for the TekSavvy sensor platform."""
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.teksavvy.sensor import TekSavvyData
+from homeassistant.const import DATA_GIGABYTES
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -45,31 +46,31 @@ async def test_capped_setup(hass, aioclient_mock):
await async_setup_component(hass, "sensor", {"sensor": config})
state = hass.states.get("sensor.teksavvy_data_limit")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "400"
state = hass.states.get("sensor.teksavvy_off_peak_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "36.24"
state = hass.states.get("sensor.teksavvy_off_peak_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "1.58"
state = hass.states.get("sensor.teksavvy_off_peak_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "37.82"
state = hass.states.get("sensor.teksavvy_on_peak_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "226.75"
state = hass.states.get("sensor.teksavvy_on_peak_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "8.82"
state = hass.states.get("sensor.teksavvy_on_peak_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "235.57"
state = hass.states.get("sensor.teksavvy_usage_ratio")
@@ -77,11 +78,11 @@ async def test_capped_setup(hass, aioclient_mock):
assert state.state == "56.69"
state = hass.states.get("sensor.teksavvy_usage")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "226.75"
state = hass.states.get("sensor.teksavvy_remaining")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "173.25"
@@ -126,35 +127,35 @@ async def test_unlimited_setup(hass, aioclient_mock):
await async_setup_component(hass, "sensor", {"sensor": config})
state = hass.states.get("sensor.teksavvy_data_limit")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "inf"
state = hass.states.get("sensor.teksavvy_off_peak_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "36.24"
state = hass.states.get("sensor.teksavvy_off_peak_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "1.58"
state = hass.states.get("sensor.teksavvy_off_peak_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "37.82"
state = hass.states.get("sensor.teksavvy_on_peak_download")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "226.75"
state = hass.states.get("sensor.teksavvy_on_peak_upload")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "8.82"
state = hass.states.get("sensor.teksavvy_on_peak_total")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "235.57"
state = hass.states.get("sensor.teksavvy_usage")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "226.75"
state = hass.states.get("sensor.teksavvy_usage_ratio")
@@ -162,7 +163,7 @@ async def test_unlimited_setup(hass, aioclient_mock):
assert state.state == "0"
state = hass.states.get("sensor.teksavvy_remaining")
- assert state.attributes.get("unit_of_measurement") == "GB"
+ assert state.attributes.get("unit_of_measurement") == DATA_GIGABYTES
assert state.state == "inf"
diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py
index f4972ada2c7942..6ee265de8d5e45 100644
--- a/tests/components/tellduslive/test_config_flow.py
+++ b/tests/components/tellduslive/test_config_flow.py
@@ -239,7 +239,7 @@ async def test_abort_if_exception_generating_auth_url(hass, mock_tellduslive):
async def test_discovery_already_configured(hass, mock_tellduslive):
- """Test abort if alredy configured fires from discovery."""
+ """Test abort if already configured fires from discovery."""
MockConfigEntry(domain="tellduslive", data={"host": "some-host"}).add_to_hass(hass)
flow = init_config_flow(hass)
diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py
index c3e1f2843fd8ea..5109607d799a4c 100644
--- a/tests/components/template/test_cover.py
+++ b/tests/components/template/test_cover.py
@@ -32,7 +32,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
@@ -270,26 +270,22 @@ async def test_template_mutex(hass, calls):
assert hass.states.async_all() == []
-async def test_template_open_or_position(hass, calls):
+async def test_template_open_or_position(hass, caplog):
"""Test that at least one of open_cover or set_position is used."""
- with assert_setup_component(1, "cover"):
- assert await setup.async_setup_component(
- hass,
- "cover",
- {
- "cover": {
- "platform": "template",
- "covers": {
- "test_template_cover": {"value_template": "{{ 1 == 1 }}"}
- },
- }
- },
- )
-
- await hass.async_start()
+ assert await setup.async_setup_component(
+ hass,
+ "cover",
+ {
+ "cover": {
+ "platform": "template",
+ "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}},
+ }
+ },
+ )
await hass.async_block_till_done()
assert hass.states.async_all() == []
+ assert "Invalid config for [cover.template]" in caplog.text
async def test_template_open_and_close(hass, calls):
@@ -885,7 +881,7 @@ async def test_availability_template(hass, calls):
async def test_availability_without_availability_template(hass, calls):
- """Test that component is availble if there is no."""
+ """Test that component is available if there is no."""
assert await setup.async_setup_component(
hass,
"cover",
diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py
index 981b87ff43e685..b6b0a87c9f2607 100644
--- a/tests/components/template/test_fan.py
+++ b/tests/components/template/test_fan.py
@@ -38,7 +38,7 @@
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py
index 3e1ec207169bab..dccca97a1cc9d1 100644
--- a/tests/components/template/test_light.py
+++ b/tests/components/template/test_light.py
@@ -4,7 +4,11 @@
import pytest
from homeassistant import setup
-from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP,
+ ATTR_HS_COLOR,
+)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import callback
@@ -816,6 +820,123 @@ def test_entity_picture_template(self):
assert state.attributes["entity_picture"] == "/local/light.png"
+ def test_color_action_no_template(self):
+ """Test setting color with optimistic template."""
+ assert setup.setup_component(
+ self.hass,
+ "light",
+ {
+ "light": {
+ "platform": "template",
+ "lights": {
+ "test_template_light": {
+ "value_template": "{{1 == 1}}",
+ "turn_on": {
+ "service": "light.turn_on",
+ "entity_id": "light.test_state",
+ },
+ "turn_off": {
+ "service": "light.turn_off",
+ "entity_id": "light.test_state",
+ },
+ "set_color": [
+ {
+ "service": "test.automation",
+ "data_template": {
+ "entity_id": "test.test_state",
+ "h": "{{h}}",
+ "s": "{{s}}",
+ },
+ },
+ {
+ "service": "test.automation",
+ "data_template": {
+ "entity_id": "test.test_state",
+ "s": "{{s}}",
+ "h": "{{h}}",
+ },
+ },
+ ],
+ }
+ },
+ }
+ },
+ )
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("light.test_template_light")
+ assert state.attributes.get("hs_color") is None
+
+ common.turn_on(
+ self.hass, "light.test_template_light", **{ATTR_HS_COLOR: (40, 50)}
+ )
+ self.hass.block_till_done()
+ assert len(self.calls) == 2
+ assert self.calls[0].data["h"] == "40"
+ assert self.calls[0].data["s"] == "50"
+ assert self.calls[1].data["h"] == "40"
+ assert self.calls[1].data["s"] == "50"
+
+ state = self.hass.states.get("light.test_template_light")
+ _LOGGER.info(str(state.attributes))
+ assert state is not None
+ assert self.calls[0].data["h"] == "40"
+ assert self.calls[0].data["s"] == "50"
+ assert self.calls[1].data["h"] == "40"
+ assert self.calls[1].data["s"] == "50"
+
+ @pytest.mark.parametrize(
+ "expected_hs,template",
+ [
+ ((360, 100), "{{(360, 100)}}"),
+ ((359.9, 99.9), "{{(359.9, 99.9)}}"),
+ (None, "{{(361, 100)}}"),
+ (None, "{{(360, 101)}}"),
+ (None, "{{x - 12}}"),
+ ],
+ )
+ def test_color_template(self, expected_hs, template):
+ """Test the template for the color."""
+ with assert_setup_component(1, "light"):
+ assert setup.setup_component(
+ self.hass,
+ "light",
+ {
+ "light": {
+ "platform": "template",
+ "lights": {
+ "test_template_light": {
+ "value_template": "{{ 1 == 1 }}",
+ "turn_on": {
+ "service": "light.turn_on",
+ "entity_id": "light.test_state",
+ },
+ "turn_off": {
+ "service": "light.turn_off",
+ "entity_id": "light.test_state",
+ },
+ "set_color": [
+ {
+ "service": "input_number.set_value",
+ "data_template": {
+ "entity_id": "input_number.h",
+ "color_temp": "{{h}}",
+ },
+ }
+ ],
+ "color_template": template,
+ }
+ },
+ }
+ },
+ )
+ self.hass.start()
+ self.hass.block_till_done()
+ state = self.hass.states.get("light.test_template_light")
+ assert state is not None
+ assert state.attributes.get("hs_color") == expected_hs
+
async def test_available_template_with_entities(hass):
"""Test availability templates with values from other entities."""
diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py
index 7b7e822ce58fe2..477583f23fb4ef 100644
--- a/tests/components/tesla/test_config_flow.py
+++ b/tests/components/tesla/test_config_flow.py
@@ -4,7 +4,7 @@
from teslajsonpy import TeslaException
from homeassistant import config_entries, data_entry_flow, setup
-from homeassistant.components.tesla.const import DOMAIN
+from homeassistant.components.tesla.const import DOMAIN, MIN_SCAN_INTERVAL
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_PASSWORD,
@@ -40,8 +40,8 @@ async def test_form(hass):
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == "test@email.com"
assert result2["data"] == {
- "token": "test-refresh-token",
- "access_token": "test-access-token",
+ CONF_TOKEN: "test-refresh-token",
+ CONF_ACCESS_TOKEN: "test-access-token",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
@@ -157,4 +157,4 @@ async def test_option_flow_input_floor(hass):
result["flow_id"], user_input={CONF_SCAN_INTERVAL: 1}
)
assert result["type"] == "create_entry"
- assert result["data"] == {CONF_SCAN_INTERVAL: 300}
+ assert result["data"] == {CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL}
diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py
index 03581d16c094b6..1da0c16d43c9ce 100644
--- a/tests/components/tod/test_binary_sensor.py
+++ b/tests/components/tod/test_binary_sensor.py
@@ -24,7 +24,7 @@ class TestBinarySensorTod(unittest.TestCase):
def setup_method(self, method):
"""Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
- self.hass.config.latitute = 50.27583
+ self.hass.config.latitude = 50.27583
self.hass.config.longitude = 18.98583
def teardown_method(self, method):
diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py
index 6aafe29901da70..62c4bc3a065511 100644
--- a/tests/components/tts/test_init.py
+++ b/tests/components/tts/test_init.py
@@ -95,7 +95,10 @@ def test_setup_component_and_test_service(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -103,13 +106,13 @@ def test_setup_component_and_test_service(self):
assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3".format(
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format(
self.hass.config.api.base_url
)
assert os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
)
@@ -125,7 +128,10 @@ def test_setup_component_and_test_service_with_config_language(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -133,13 +139,13 @@ def test_setup_component_and_test_service_with_config_language(self):
assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3".format(
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format(
self.hass.config.api.base_url
)
assert os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
)
)
@@ -163,7 +169,8 @@ def test_setup_component_and_test_service_with_service_language(self):
tts.DOMAIN,
"demo_say",
{
- tts.ATTR_MESSAGE: "I person is on front of your door.",
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_LANGUAGE: "de",
},
)
@@ -173,13 +180,13 @@ def test_setup_component_and_test_service_with_service_language(self):
assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3".format(
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format(
self.hass.config.api.base_url
)
assert os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
)
)
@@ -196,7 +203,8 @@ def test_setup_component_test_service_with_wrong_service_language(self):
tts.DOMAIN,
"demo_say",
{
- tts.ATTR_MESSAGE: "I person is on front of your door.",
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_LANGUAGE: "lang",
},
)
@@ -206,7 +214,7 @@ def test_setup_component_test_service_with_wrong_service_language(self):
assert not os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_lang_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3",
)
)
@@ -223,7 +231,8 @@ def test_setup_component_and_test_service_with_service_options(self):
tts.DOMAIN,
"demo_say",
{
- tts.ATTR_MESSAGE: "I person is on front of your door.",
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_LANGUAGE: "de",
tts.ATTR_OPTIONS: {"voice": "alex"},
},
@@ -236,13 +245,13 @@ def test_setup_component_and_test_service_with_service_options(self):
assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_de_{}_demo.mp3".format(
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format(
self.hass.config.api.base_url, opt_hash
)
assert os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format(
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format(
opt_hash
),
)
@@ -265,7 +274,8 @@ def test_setup_component_and_test_with_service_options_def(self, def_mock):
tts.DOMAIN,
"demo_say",
{
- tts.ATTR_MESSAGE: "I person is on front of your door.",
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_LANGUAGE: "de",
},
)
@@ -277,13 +287,13 @@ def test_setup_component_and_test_with_service_options_def(self, def_mock):
assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_de_{}_demo.mp3".format(
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format(
self.hass.config.api.base_url, opt_hash
)
assert os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format(
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format(
opt_hash
),
)
@@ -302,7 +312,8 @@ def test_setup_component_and_test_service_with_service_options_wrong(self):
tts.DOMAIN,
"demo_say",
{
- tts.ATTR_MESSAGE: "I person is on front of your door.",
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_LANGUAGE: "de",
tts.ATTR_OPTIONS: {"speed": 1},
},
@@ -315,7 +326,7 @@ def test_setup_component_and_test_service_with_service_options_wrong(self):
assert not os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format(
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format(
opt_hash
),
)
@@ -333,7 +344,10 @@ def test_setup_component_and_test_service_with_base_url_set(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -341,7 +355,7 @@ def test_setup_component_and_test_service_with_base_url_set(self):
assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
assert (
calls[0].data[ATTR_MEDIA_CONTENT_ID] == "http://fnord"
- "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd"
+ "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
"_en_-_demo.mp3"
)
@@ -357,7 +371,10 @@ def test_setup_component_and_test_service_clear_cache(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -365,7 +382,7 @@ def test_setup_component_and_test_service_clear_cache(self):
assert os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
)
@@ -375,7 +392,7 @@ def test_setup_component_and_test_service_clear_cache(self):
assert not os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
)
@@ -393,7 +410,10 @@ def test_setup_component_and_test_service_with_receive_voice(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -401,7 +421,7 @@ def test_setup_component_and_test_service_with_receive_voice(self):
req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID])
_, demo_data = self.demo_provider.get_tts_audio("bla", "en")
demo_data = tts.SpeechManager.write_tags(
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
demo_data,
self.demo_provider,
"AI person is in front of your door.",
@@ -425,7 +445,10 @@ def test_setup_component_and_test_service_with_receive_voice_german(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -433,10 +456,10 @@ def test_setup_component_and_test_service_with_receive_voice_german(self):
req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID])
_, demo_data = self.demo_provider.get_tts_audio("bla", "de")
demo_data = tts.SpeechManager.write_tags(
- "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
demo_data,
self.demo_provider,
- "I person is on front of your door.",
+ "There is someone at the door.",
"de",
None,
)
@@ -453,7 +476,7 @@ def test_setup_component_and_web_view_wrong_file(self):
self.hass.start()
url = (
- "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"
+ "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
).format(self.hass.config.api.base_url)
req = requests.get(url)
@@ -487,7 +510,10 @@ def test_setup_component_test_without_cache(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -495,7 +521,7 @@ def test_setup_component_test_without_cache(self):
assert not os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
)
@@ -512,7 +538,8 @@ def test_setup_component_test_with_cache_call_service_without_cache(self):
tts.DOMAIN,
"demo_say",
{
- tts.ATTR_MESSAGE: "I person is on front of your door.",
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_CACHE: False,
},
)
@@ -522,7 +549,7 @@ def test_setup_component_test_with_cache_call_service_without_cache(self):
assert not os.path.isfile(
os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
)
@@ -533,7 +560,7 @@ def test_setup_component_test_with_cache_dir(self):
_, demo_data = self.demo_provider.get_tts_audio("bla", "en")
cache_file = os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
os.mkdir(self.default_tts_cache)
@@ -552,14 +579,17 @@ def test_setup_component_test_with_cache_dir(self):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
assert len(calls) == 1
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3".format(
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format(
self.hass.config.api.base_url
)
@@ -579,7 +609,10 @@ def test_setup_component_test_with_error_on_get_tts(self, tts_mock):
self.hass.services.call(
tts.DOMAIN,
"demo_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
)
self.hass.block_till_done()
@@ -590,7 +623,7 @@ def test_setup_component_load_cache_retrieve_without_mem_cache(self):
_, demo_data = self.demo_provider.get_tts_audio("bla", "en")
cache_file = os.path.join(
self.default_tts_cache,
- "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
)
os.mkdir(self.default_tts_cache)
@@ -605,7 +638,7 @@ def test_setup_component_load_cache_retrieve_without_mem_cache(self):
self.hass.start()
url = (
- "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"
+ "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
).format(self.hass.config.api.base_url)
req = requests.get(url)
@@ -622,14 +655,15 @@ async def test_setup_component_and_web_get_url(hass, hass_client):
client = await hass_client()
url = "/api/tts_get_url"
- data = {"platform": "demo", "message": "I person is on front of your door."}
+ data = {"platform": "demo", "message": "There is someone at the door."}
req = await client.post(url, json=data)
assert req.status == 200
response = await req.json()
assert response.get("url") == (
- "{}/api/tts_proxy/265944c108cbb00b2a62"
- "1be5930513e03a0bb2cd_en_-_demo.mp3".format(hass.config.api.base_url)
+ "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format(
+ hass.config.api.base_url
+ )
)
tts_cache = hass.config.path(tts.DEFAULT_CACHE_DIR)
@@ -646,7 +680,7 @@ async def test_setup_component_and_web_get_url_bad_config(hass, hass_client):
client = await hass_client()
url = "/api/tts_get_url"
- data = {"message": "I person is on front of your door."}
+ data = {"message": "There is someone at the door."}
req = await client.post(url, json=data)
assert req.status == 400
diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py
new file mode 100644
index 00000000000000..ec26cf264ef74b
--- /dev/null
+++ b/tests/components/twitch/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Twitch component."""
diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py
new file mode 100644
index 00000000000000..6c656f874d0cc3
--- /dev/null
+++ b/tests/components/twitch/test_twitch.py
@@ -0,0 +1,174 @@
+"""The tests for an update of the Twitch component."""
+from unittest.mock import MagicMock, patch
+
+from requests import HTTPError
+from twitch.resources import Channel, Follow, Stream, Subscription, User
+
+from homeassistant.components import sensor
+from homeassistant.setup import async_setup_component
+
+ENTITY_ID = "sensor.channel123"
+CONFIG = {
+ sensor.DOMAIN: {
+ "platform": "twitch",
+ "client_id": "1234",
+ "channels": ["channel123"],
+ }
+}
+CONFIG_WITH_OAUTH = {
+ sensor.DOMAIN: {
+ "platform": "twitch",
+ "client_id": "1234",
+ "channels": ["channel123"],
+ "token": "9876",
+ }
+}
+
+USER_ID = User({"id": 123, "display_name": "channel123", "logo": "logo.png"})
+STREAM_OBJECT_ONLINE = Stream(
+ {
+ "channel": {"game": "Good Game", "status": "Title"},
+ "preview": {"medium": "stream-medium.png"},
+ }
+)
+CHANNEL_OBJECT = Channel({"followers": 42, "views": 24})
+OAUTH_USER_ID = User({"id": 987})
+SUB_ACTIVE = Subscription({"created_at": "2020-01-20T21:22:42", "is_gift": False})
+FOLLOW_ACTIVE = Follow({"created_at": "2020-01-20T21:22:42"})
+
+
+async def test_init(hass):
+ """Test initial config."""
+
+ channels = MagicMock()
+ channels.get_by_id.return_value = CHANNEL_OBJECT
+ streams = MagicMock()
+ streams.get_stream_by_user.return_value = None
+
+ twitch_mock = MagicMock()
+ twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID]
+ twitch_mock.channels = channels
+ twitch_mock.streams = streams
+
+ with patch(
+ "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock
+ ):
+ assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
+
+ sensor_state = hass.states.get(ENTITY_ID)
+ assert sensor_state.state == "offline"
+ assert sensor_state.name == "channel123"
+ assert sensor_state.attributes["icon"] == "mdi:twitch"
+ assert sensor_state.attributes["friendly_name"] == "channel123"
+ assert sensor_state.attributes["views"] == 24
+ assert sensor_state.attributes["followers"] == 42
+
+
+async def test_offline(hass):
+ """Test offline state."""
+
+ twitch_mock = MagicMock()
+ twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID]
+ twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT
+ twitch_mock.streams.get_stream_by_user.return_value = None
+
+ with patch(
+ "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ ):
+ assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
+
+ sensor_state = hass.states.get(ENTITY_ID)
+ assert sensor_state.state == "offline"
+ assert sensor_state.attributes["entity_picture"] == "logo.png"
+
+
+async def test_streaming(hass):
+ """Test streaming state."""
+
+ twitch_mock = MagicMock()
+ twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID]
+ twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT
+ twitch_mock.streams.get_stream_by_user.return_value = STREAM_OBJECT_ONLINE
+
+ with patch(
+ "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ ):
+ assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
+
+ sensor_state = hass.states.get(ENTITY_ID)
+ assert sensor_state.state == "streaming"
+ assert sensor_state.attributes["entity_picture"] == "stream-medium.png"
+ assert sensor_state.attributes["game"] == "Good Game"
+ assert sensor_state.attributes["title"] == "Title"
+
+
+async def test_oauth_without_sub_and_follow(hass):
+ """Test state with oauth."""
+
+ twitch_mock = MagicMock()
+ twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID]
+ twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT
+ twitch_mock._oauth_token = True # A replacement for the token
+ twitch_mock.users.get.return_value = OAUTH_USER_ID
+ twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError()
+ twitch_mock.users.check_follows_channel.side_effect = HTTPError()
+
+ with patch(
+ "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ ):
+ assert (
+ await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True
+ )
+
+ sensor_state = hass.states.get(ENTITY_ID)
+ assert sensor_state.attributes["subscribed"] is False
+ assert sensor_state.attributes["following"] is False
+
+
+async def test_oauth_with_sub(hass):
+ """Test state with oauth and sub."""
+
+ twitch_mock = MagicMock()
+ twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID]
+ twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT
+ twitch_mock._oauth_token = True # A replacement for the token
+ twitch_mock.users.get.return_value = OAUTH_USER_ID
+ twitch_mock.users.check_subscribed_to_channel.return_value = SUB_ACTIVE
+ twitch_mock.users.check_follows_channel.side_effect = HTTPError()
+
+ with patch(
+ "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ ):
+ assert (
+ await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True
+ )
+
+ sensor_state = hass.states.get(ENTITY_ID)
+ assert sensor_state.attributes["subscribed"] is True
+ assert sensor_state.attributes["subscribed_since"] == "2020-01-20T21:22:42"
+ assert sensor_state.attributes["subscription_is_gifted"] is False
+ assert sensor_state.attributes["following"] is False
+
+
+async def test_oauth_with_follow(hass):
+ """Test state with oauth and follow."""
+
+ twitch_mock = MagicMock()
+ twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID]
+ twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT
+ twitch_mock._oauth_token = True # A replacement for the token
+ twitch_mock.users.get.return_value = OAUTH_USER_ID
+ twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError()
+ twitch_mock.users.check_follows_channel.return_value = FOLLOW_ACTIVE
+
+ with patch(
+ "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ ):
+ assert (
+ await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True
+ )
+
+ sensor_state = hass.states.get(ENTITY_ID)
+ assert sensor_state.attributes["subscribed"] is False
+ assert sensor_state.attributes["following"] is True
+ assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42"
diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py
index ce568a64b95876..4979efc22dc557 100644
--- a/tests/components/uk_transport/test_sensor.py
+++ b/tests/components/uk_transport/test_sensor.py
@@ -2,6 +2,7 @@
import re
import unittest
+from asynctest import patch
import requests_mock
from homeassistant.components.uk_transport.sensor import (
@@ -17,6 +18,7 @@
UkTransportSensor,
)
from homeassistant.setup import setup_component
+from homeassistant.util.dt import now
from tests.common import get_test_home_assistant, load_fixture
@@ -77,7 +79,9 @@ def test_bus(self, mock_req):
@requests_mock.Mocker()
def test_train(self, mock_req):
"""Test for operational uk_transport sensor with proper attributes."""
- with requests_mock.Mocker() as mock_req:
+ with requests_mock.Mocker() as mock_req, patch(
+ "homeassistant.util.dt.now", return_value=now().replace(hour=13)
+ ):
uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + "*")
mock_req.get(uri, text=load_fixture("uk_transport_train.json"))
assert setup_component(self.hass, "sensor", {"sensor": self.config})
diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py
new file mode 100644
index 00000000000000..189b80c193218f
--- /dev/null
+++ b/tests/components/unifi/conftest.py
@@ -0,0 +1,13 @@
+"""Fixtures for UniFi methods."""
+from asynctest import patch
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def mock_discovery():
+ """No real network traffic allowed."""
+ with patch(
+ "homeassistant.components.unifi.config_flow.async_discover_unifi",
+ return_value=None,
+ ) as mock:
+ yield mock
diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py
index cc8896d55ce1ae..64d1ab9775e66b 100644
--- a/tests/components/unifi/test_config_flow.py
+++ b/tests/components/unifi/test_config_flow.py
@@ -2,6 +2,7 @@
import aiounifi
from asynctest import patch
+from homeassistant import data_entry_flow
from homeassistant.components import unifi
from homeassistant.components.unifi import config_flow
from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID
@@ -13,17 +14,29 @@
CONF_VERIFY_SSL,
)
+from .test_controller import setup_unifi_integration
+
from tests.common import MockConfigEntry
+WLANS = [{"name": "SSID 1"}, {"name": "SSID 2"}]
+
-async def test_flow_works(hass, aioclient_mock):
+async def test_flow_works(hass, aioclient_mock, mock_discovery):
"""Test config flow."""
+ mock_discovery.return_value = "1"
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "user"
+ assert result["data_schema"]({CONF_USERNAME: "", CONF_PASSWORD: ""}) == {
+ CONF_HOST: "unifi",
+ CONF_USERNAME: "",
+ CONF_PASSWORD: "",
+ CONF_PORT: 8443,
+ CONF_VERIFY_SSL: False,
+ }
aioclient_mock.post(
"https://1.2.3.4:1234/api/login",
@@ -228,36 +241,39 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock):
async def test_option_flow(hass):
"""Test config flow options."""
- entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=None)
- hass.config_entries._entries.append(entry)
+ controller = await setup_unifi_integration(hass, wlans_response=WLANS)
- flow = await hass.config_entries.options.async_create_flow(
- entry.entry_id, context={"source": "test"}, data=None
+ result = await hass.config_entries.options.async_init(
+ controller.config_entry.entry_id
)
- result = await flow.async_step_init()
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "device_tracker"
- result = await flow.async_step_device_tracker(
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
user_input={
config_flow.CONF_TRACK_CLIENTS: False,
config_flow.CONF_TRACK_WIRED_CLIENTS: False,
config_flow.CONF_TRACK_DEVICES: False,
+ config_flow.CONF_SSID_FILTER: ["SSID 1"],
config_flow.CONF_DETECTION_TIME: 100,
- }
+ },
)
- assert result["type"] == "form"
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "statistics_sensors"
- result = await flow.async_step_statistics_sensors(
- user_input={config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True}
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True}
)
- assert result["type"] == "create_entry"
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
config_flow.CONF_TRACK_CLIENTS: False,
config_flow.CONF_TRACK_WIRED_CLIENTS: False,
config_flow.CONF_TRACK_DEVICES: False,
config_flow.CONF_DETECTION_TIME: 100,
+ config_flow.CONF_SSID_FILTER: ["SSID 1"],
config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True,
}
diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py
index 74137cf8a3a3be..daec8cddf5dec6 100644
--- a/tests/components/unifi/test_controller.py
+++ b/tests/components/unifi/test_controller.py
@@ -63,6 +63,7 @@ async def setup_unifi_integration(
clients_response=None,
devices_response=None,
clients_all_response=None,
+ wlans_response=None,
known_wireless_clients=None,
controllers=None,
):
@@ -98,6 +99,10 @@ async def setup_unifi_integration(
if clients_all_response:
mock_client_all_responses.append(clients_all_response)
+ mock_wlans_responses = deque()
+ if wlans_response:
+ mock_wlans_responses.append(wlans_response)
+
mock_requests = []
async def mock_request(self, method, path, json=None):
@@ -109,11 +114,16 @@ async def mock_request(self, method, path, json=None):
return mock_device_responses.popleft()
if path == "s/{site}/rest/user" and mock_client_all_responses:
return mock_client_all_responses.popleft()
+ if path == "s/{site}/rest/wlanconf" and mock_wlans_responses:
+ return mock_wlans_responses.popleft()
return {}
+ # "aiounifi.Controller.start_websocket", return_value=True
with patch("aiounifi.Controller.login", return_value=True), patch(
"aiounifi.Controller.sites", return_value=sites
- ), patch("aiounifi.Controller.request", new=mock_request):
+ ), patch("aiounifi.Controller.request", new=mock_request), patch.object(
+ aiounifi.websocket.WSClient, "start", return_value=True
+ ):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -125,6 +135,7 @@ async def mock_request(self, method, path, json=None):
controller.mock_client_responses = mock_client_responses
controller.mock_device_responses = mock_device_responses
controller.mock_client_all_responses = mock_client_all_responses
+ controller.mock_wlans_responses = mock_wlans_responses
controller.mock_requests = mock_requests
return controller
@@ -233,47 +244,28 @@ async def test_reset_after_successful_setup(hass):
assert len(controller.listeners) == 0
-async def test_failed_update_failed_login(hass):
- """Running update can handle a failed login."""
- controller = await setup_unifi_integration(hass)
-
- with patch.object(
- controller.api.clients, "update", side_effect=aiounifi.LoginRequired
- ), patch.object(controller.api, "login", side_effect=aiounifi.AiounifiException):
- await controller.async_update()
- await hass.async_block_till_done()
-
- assert controller.available is False
-
-
-async def test_failed_update_successful_login(hass):
- """Running update can login when requested."""
+async def test_wireless_client_event_calls_update_wireless_devices(hass):
+ """Call update_wireless_devices method when receiving wireless client event."""
controller = await setup_unifi_integration(hass)
- with patch.object(
- controller.api.clients, "update", side_effect=aiounifi.LoginRequired
- ), patch.object(controller.api, "login", return_value=Mock(True)):
- await controller.async_update()
- await hass.async_block_till_done()
-
- assert controller.available is True
-
-
-async def test_failed_update(hass):
- """Running update can login when requested."""
- controller = await setup_unifi_integration(hass)
-
- with patch.object(
- controller.api.clients, "update", side_effect=aiounifi.AiounifiException
- ):
- await controller.async_update()
- await hass.async_block_till_done()
-
- assert controller.available is False
+ with patch(
+ "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients",
+ return_value=None,
+ ) as wireless_clients_mock:
+ controller.api.websocket._data = {
+ "meta": {"rc": "ok", "message": "events"},
+ "data": [
+ {
+ "datetime": "2020-01-20T19:37:04Z",
+ "key": aiounifi.events.WIRELESS_CLIENT_CONNECTED,
+ "msg": "User[11:22:33:44:55:66] has connected to WLAN",
+ "time": 1579549024893,
+ }
+ ],
+ }
+ controller.api.session_handler("data")
- await controller.async_update()
- await hass.async_block_till_done()
- assert controller.available is True
+ assert wireless_clients_mock.assert_called_once
async def test_get_controller(hass):
@@ -307,7 +299,7 @@ async def test_get_controller_controller_unavailable(hass):
async def test_get_controller_unknown_error(hass):
- """Check that get_controller can handle unkown errors."""
+ """Check that get_controller can handle unknown errors."""
with patch(
"aiounifi.Controller.login", side_effect=aiounifi.AiounifiException
), pytest.raises(unifi.errors.AuthenticationRequired):
diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py
index f123772a6ca983..608e72b483ad84 100644
--- a/tests/components/unifi/test_device_tracker.py
+++ b/tests/components/unifi/test_device_tracker.py
@@ -2,6 +2,8 @@
from copy import copy
from datetime import timedelta
+from aiounifi.controller import SIGNAL_CONNECTION_STATE
+from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING
from asynctest import patch
from homeassistant import config_entries
@@ -9,6 +11,7 @@
import homeassistant.components.device_tracker as device_tracker
from homeassistant.components.unifi.const import (
CONF_SSID_FILTER,
+ CONF_TRACK_CLIENTS,
CONF_TRACK_DEVICES,
CONF_TRACK_WIRED_CLIENTS,
)
@@ -112,7 +115,7 @@ async def test_tracked_devices(hass):
devices_response=[DEVICE_1, DEVICE_2],
known_wireless_clients=(CLIENT_4["mac"],),
)
- assert len(hass.states.async_all()) == 5
+ assert len(hass.states.async_all()) == 6
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -123,7 +126,8 @@ async def test_tracked_devices(hass):
assert client_2.state == "not_home"
client_3 = hass.states.get("device_tracker.client_3")
- assert client_3 is None
+ assert client_3 is not None
+ assert client_3.state == "not_home"
# Wireless client with wired bug, if bug active on restart mark device away
client_4 = hass.states.get("device_tracker.client_4")
@@ -134,13 +138,15 @@ async def test_tracked_devices(hass):
assert device_1 is not None
assert device_1.state == "not_home"
+ # State change signalling works
client_1_copy = copy(CLIENT_1)
client_1_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
+ event = {"meta": {"message": "sta:sync"}, "data": [client_1_copy]}
+ controller.api.message_handler(event)
device_1_copy = copy(DEVICE_1)
device_1_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
- controller.mock_client_responses.append([client_1_copy])
- controller.mock_device_responses.append([device_1_copy])
- await controller.async_update()
+ event = {"meta": {"message": "device:sync"}, "data": [device_1_copy]}
+ controller.api.message_handler(event)
await hass.async_block_till_done()
client_1 = hass.states.get("device_tracker.client_1")
@@ -149,33 +155,215 @@ async def test_tracked_devices(hass):
device_1 = hass.states.get("device_tracker.device_1")
assert device_1.state == "home"
+ # Disabled device is unavailable
device_1_copy = copy(DEVICE_1)
device_1_copy["disabled"] = True
- controller.mock_client_responses.append({})
- controller.mock_device_responses.append([device_1_copy])
- await controller.async_update()
+ event = {"meta": {"message": "device:sync"}, "data": [device_1_copy]}
+ controller.api.message_handler(event)
await hass.async_block_till_done()
device_1 = hass.states.get("device_tracker.device_1")
assert device_1.state == STATE_UNAVAILABLE
- controller.config_entry.add_update_listener(controller.async_options_updated)
+
+async def test_controller_state_change(hass):
+ """Verify entities state reflect on controller becoming unavailable."""
+ controller = await setup_unifi_integration(
+ hass, clients_response=[CLIENT_1], devices_response=[DEVICE_1],
+ )
+ assert len(hass.states.async_all()) == 3
+
+ # Controller unavailable
+ controller.async_unifi_signalling_callback(
+ SIGNAL_CONNECTION_STATE, STATE_DISCONNECTED
+ )
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1.state == STATE_UNAVAILABLE
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1.state == STATE_UNAVAILABLE
+
+ # Controller available
+ controller.async_unifi_signalling_callback(SIGNAL_CONNECTION_STATE, STATE_RUNNING)
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1.state == "not_home"
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1.state == "not_home"
+
+
+async def test_option_track_clients(hass):
+ """Test the tracking of clients can be turned off."""
+ controller = await setup_unifi_integration(
+ hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
+ )
+ assert len(hass.states.async_all()) == 4
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_TRACK_CLIENTS: False},
+ )
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_TRACK_CLIENTS: True},
+ )
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+
+async def test_option_track_wired_clients(hass):
+ """Test the tracking of wired clients can be turned off."""
+ controller = await setup_unifi_integration(
+ hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
+ )
+ assert len(hass.states.async_all()) == 4
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
hass.config_entries.async_update_entry(
- controller.config_entry,
- options={
- CONF_SSID_FILTER: [],
- CONF_TRACK_WIRED_CLIENTS: False,
- CONF_TRACK_DEVICES: False,
- },
+ controller.config_entry, options={CONF_TRACK_WIRED_CLIENTS: False},
)
await hass.async_block_till_done()
+
client_1 = hass.states.get("device_tracker.client_1")
- assert client_1
+ assert client_1 is not None
+
client_2 = hass.states.get("device_tracker.wired_client")
assert client_2 is None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_TRACK_WIRED_CLIENTS: True},
+ )
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+
+async def test_option_track_devices(hass):
+ """Test the tracking of devices can be turned off."""
+ controller = await setup_unifi_integration(
+ hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
+ )
+ assert len(hass.states.async_all()) == 4
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_TRACK_DEVICES: False},
+ )
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
device_1 = hass.states.get("device_tracker.device_1")
assert device_1 is None
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_TRACK_DEVICES: True},
+ )
+ await hass.async_block_till_done()
+
+ client_1 = hass.states.get("device_tracker.client_1")
+ assert client_1 is not None
+
+ client_2 = hass.states.get("device_tracker.wired_client")
+ assert client_2 is not None
+
+ device_1 = hass.states.get("device_tracker.device_1")
+ assert device_1 is not None
+
+
+async def test_option_ssid_filter(hass):
+ """Test the SSID filter works."""
+ controller = await setup_unifi_integration(
+ hass, options={CONF_SSID_FILTER: ["ssid"]}, clients_response=[CLIENT_3],
+ )
+ assert len(hass.states.async_all()) == 2
+
+ # SSID filter active
+ client_3 = hass.states.get("device_tracker.client_3")
+ assert client_3.state == "not_home"
+
+ client_3_copy = copy(CLIENT_3)
+ client_3_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
+ event = {"meta": {"message": "sta:sync"}, "data": [client_3_copy]}
+ controller.api.message_handler(event)
+ await hass.async_block_till_done()
+
+ # SSID filter active even though time stamp should mark as home
+ client_3 = hass.states.get("device_tracker.client_3")
+ assert client_3.state == "not_home"
+
+ # Remove SSID filter
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_SSID_FILTER: []},
+ )
+ event = {"meta": {"message": "sta:sync"}, "data": [client_3_copy]}
+ controller.api.message_handler(event)
+ await hass.async_block_till_done()
+
+ # SSID no longer filtered
+ client_3 = hass.states.get("device_tracker.client_3")
+ assert client_3.state == "home"
+
async def test_wireless_client_go_wired_issue(hass):
"""Test the solution to catch wireless device go wired UniFi issue.
@@ -194,9 +382,8 @@ async def test_wireless_client_go_wired_issue(hass):
client_1_client["is_wired"] = True
client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
- controller.mock_client_responses.append([client_1_client])
- controller.mock_device_responses.append({})
- await controller.async_update()
+ event = {"meta": {"message": "sta:sync"}, "data": [client_1_client]}
+ controller.api.message_handler(event)
await hass.async_block_till_done()
client_1 = hass.states.get("device_tracker.client_1")
@@ -207,9 +394,8 @@ async def test_wireless_client_go_wired_issue(hass):
"utcnow",
return_value=(dt_util.utcnow() + timedelta(minutes=5)),
):
- controller.mock_client_responses.append([client_1_client])
- controller.mock_device_responses.append({})
- await controller.async_update()
+ event = {"meta": {"message": "sta:sync"}, "data": [client_1_client]}
+ controller.api.message_handler(event)
await hass.async_block_till_done()
client_1 = hass.states.get("device_tracker.client_1")
@@ -217,9 +403,8 @@ async def test_wireless_client_go_wired_issue(hass):
client_1_client["is_wired"] = False
client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
- controller.mock_client_responses.append([client_1_client])
- controller.mock_device_responses.append({})
- await controller.async_update()
+ event = {"meta": {"message": "sta:sync"}, "data": [client_1_client]}
+ controller.api.message_handler(event)
await hass.async_block_till_done()
client_1 = hass.states.get("device_tracker.client_1")
@@ -269,7 +454,7 @@ async def test_restoring_client(hass):
async def test_dont_track_clients(hass):
- """Test dont track clients config works."""
+ """Test don't track clients config works."""
await setup_unifi_integration(
hass,
options={unifi.controller.CONF_TRACK_CLIENTS: False},
@@ -287,7 +472,7 @@ async def test_dont_track_clients(hass):
async def test_dont_track_devices(hass):
- """Test dont track devices config works."""
+ """Test don't track devices config works."""
await setup_unifi_integration(
hass,
options={unifi.controller.CONF_TRACK_DEVICES: False},
@@ -305,7 +490,7 @@ async def test_dont_track_devices(hass):
async def test_dont_track_wired_clients(hass):
- """Test dont track wired clients config works."""
+ """Test don't track wired clients config works."""
await setup_unifi_integration(
hass,
options={unifi.controller.CONF_TRACK_WIRED_CLIENTS: False},
diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py
index 1f5a3852e164ac..12f9c1bfd17d0d 100644
--- a/tests/components/unifi/test_init.py
+++ b/tests/components/unifi/test_init.py
@@ -4,6 +4,8 @@
from homeassistant.components import unifi
from homeassistant.setup import async_setup_component
+from .test_controller import setup_unifi_integration
+
from tests.common import MockConfigEntry, mock_coro
@@ -42,67 +44,15 @@ async def test_setup_with_config(hass):
async def test_successful_config_entry(hass):
"""Test that configured options for a host are loaded via config entry."""
- entry = MockConfigEntry(
- domain=unifi.DOMAIN,
- data={
- "controller": {
- "host": "0.0.0.0",
- "username": "user",
- "password": "pass",
- "port": 80,
- "site": "default",
- "verify_ssl": True,
- },
- "poe_control": True,
- },
- )
- entry.add_to_hass(hass)
- mock_registry = Mock()
- with patch.object(unifi, "UniFiController") as mock_controller, patch(
- "homeassistant.helpers.device_registry.async_get_registry",
- return_value=mock_coro(mock_registry),
- ):
- mock_controller.return_value.async_setup.return_value = mock_coro(True)
- mock_controller.return_value.mac = "00:11:22:33:44:55"
- assert await unifi.async_setup_entry(hass, entry) is True
-
- assert len(mock_controller.mock_calls) == 2
- p_hass, p_entry = mock_controller.mock_calls[0][1]
-
- assert p_hass is hass
- assert p_entry is entry
-
- assert len(mock_registry.mock_calls) == 1
- assert mock_registry.mock_calls[0][2] == {
- "config_entry_id": entry.entry_id,
- "connections": {("mac", "00:11:22:33:44:55")},
- "manufacturer": unifi.ATTR_MANUFACTURER,
- "model": "UniFi Controller",
- "name": "UniFi Controller",
- }
+ await setup_unifi_integration(hass)
+ assert hass.data[unifi.DOMAIN]
async def test_controller_fail_setup(hass):
"""Test that a failed setup still stores controller."""
- entry = MockConfigEntry(
- domain=unifi.DOMAIN,
- data={
- "controller": {
- "host": "0.0.0.0",
- "username": "user",
- "password": "pass",
- "port": 80,
- "site": "default",
- "verify_ssl": True,
- },
- "poe_control": True,
- },
- )
- entry.add_to_hass(hass)
-
with patch.object(unifi, "UniFiController") as mock_cntrlr:
mock_cntrlr.return_value.async_setup.return_value = mock_coro(False)
- assert await unifi.async_setup_entry(hass, entry) is False
+ await setup_unifi_integration(hass)
assert hass.data[unifi.DOMAIN] == {}
@@ -140,33 +90,8 @@ async def test_controller_no_mac(hass):
async def test_unload_entry(hass):
"""Test being able to unload an entry."""
- entry = MockConfigEntry(
- domain=unifi.DOMAIN,
- data={
- "controller": {
- "host": "0.0.0.0",
- "username": "user",
- "password": "pass",
- "port": 80,
- "site": "default",
- "verify_ssl": True,
- },
- "poe_control": True,
- },
- )
- entry.add_to_hass(hass)
+ controller = await setup_unifi_integration(hass)
+ assert hass.data[unifi.DOMAIN]
- with patch.object(unifi, "UniFiController") as mock_controller, patch(
- "homeassistant.helpers.device_registry.async_get_registry",
- return_value=mock_coro(Mock()),
- ):
- mock_controller.return_value.async_setup.return_value = mock_coro(True)
- mock_controller.return_value.mac = "00:11:22:33:44:55"
- assert await unifi.async_setup_entry(hass, entry) is True
-
- assert len(mock_controller.return_value.mock_calls) == 1
-
- mock_controller.return_value.async_reset.return_value = mock_coro(True)
- assert await unifi.async_unload_entry(hass, entry)
- assert len(mock_controller.return_value.async_reset.mock_calls) == 1
- assert hass.data[unifi.DOMAIN] == {}
+ assert await unifi.async_unload_entry(hass, controller.config_entry)
+ assert not hass.data[unifi.DOMAIN]
diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py
index 723d6871636826..7d0600f5885f04 100644
--- a/tests/components/unifi/test_sensor.py
+++ b/tests/components/unifi/test_sensor.py
@@ -54,7 +54,7 @@ async def test_no_clients(hass):
hass, options={unifi.const.CONF_ALLOW_BANDWIDTH_SENSORS: True},
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 1
@@ -70,7 +70,7 @@ async def test_sensors(hass):
clients_response=CLIENTS,
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 5
wired_client_rx = hass.states.get("sensor.wired_client_name_rx")
@@ -90,8 +90,32 @@ async def test_sensors(hass):
clients[1]["rx_bytes"] = 2345000000
clients[1]["tx_bytes"] = 6789000000
- controller.mock_client_responses.append(clients)
- await controller.async_update()
+ event = {"meta": {"message": "sta:sync"}, "data": clients}
+ controller.api.message_handler(event)
+ await hass.async_block_till_done()
+
+ wireless_client_rx = hass.states.get("sensor.wireless_client_name_rx")
+ assert wireless_client_rx.state == "2345.0"
+
+ wireless_client_tx = hass.states.get("sensor.wireless_client_name_tx")
+ assert wireless_client_tx.state == "6789.0"
+
+ hass.config_entries.async_update_entry(
+ controller.config_entry,
+ options={unifi.const.CONF_ALLOW_BANDWIDTH_SENSORS: False},
+ )
+ await hass.async_block_till_done()
+
+ wireless_client_rx = hass.states.get("sensor.wireless_client_name_rx")
+ assert wireless_client_rx is None
+
+ wireless_client_tx = hass.states.get("sensor.wireless_client_name_tx")
+ assert wireless_client_tx is None
+
+ hass.config_entries.async_update_entry(
+ controller.config_entry,
+ options={unifi.const.CONF_ALLOW_BANDWIDTH_SENSORS: True},
+ )
await hass.async_block_till_done()
wireless_client_rx = hass.states.get("sensor.wireless_client_name_rx")
diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py
index cc4c41bcbfd586..a2b609078deeb2 100644
--- a/tests/components/unifi/test_switch.py
+++ b/tests/components/unifi/test_switch.py
@@ -207,7 +207,7 @@ async def test_no_clients(hass):
},
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 1
@@ -223,7 +223,7 @@ async def test_controller_not_client(hass):
devices_response=[DEVICE_1],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 1
cloudkey = hass.states.get("switch.cloud_key")
assert cloudkey is None
@@ -244,7 +244,7 @@ async def test_not_admin(hass):
devices_response=[DEVICE_1],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 1
@@ -262,7 +262,7 @@ async def test_switches(hass):
clients_all_response=[BLOCKED, UNBLOCKED, CLIENT_1],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 4
switch_1 = hass.states.get("switch.poe_client_1")
@@ -297,18 +297,22 @@ async def test_new_client_discovered_on_block_control(hass):
clients_all_response=[BLOCKED],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 2
- controller.mock_client_all_responses.append([BLOCKED])
+ controller.api.websocket._data = {
+ "meta": {"message": "sta:sync"},
+ "data": [BLOCKED],
+ }
+ controller.api.session_handler("data")
# Calling a service will trigger the updates to run
await hass.services.async_call(
"switch", "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True
)
- assert len(controller.mock_requests) == 7
+ assert len(controller.mock_requests) == 5
assert len(hass.states.async_all()) == 2
- assert controller.mock_requests[3] == {
+ assert controller.mock_requests[4] == {
"json": {"mac": "00:00:00:00:01:01", "cmd": "block-sta"},
"method": "post",
"path": "s/{site}/cmd/stamgr/",
@@ -317,8 +321,8 @@ async def test_new_client_discovered_on_block_control(hass):
await hass.services.async_call(
"switch", "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True
)
- assert len(controller.mock_requests) == 11
- assert controller.mock_requests[7] == {
+ assert len(controller.mock_requests) == 6
+ assert controller.mock_requests[5] == {
"json": {"mac": "00:00:00:00:01:01", "cmd": "unblock-sta"},
"method": "post",
"path": "s/{site}/cmd/stamgr/",
@@ -337,19 +341,22 @@ async def test_new_client_discovered_on_poe_control(hass):
devices_response=[DEVICE_1],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 2
- controller.mock_client_responses.append([CLIENT_1, CLIENT_2])
- controller.mock_device_responses.append([DEVICE_1])
+ controller.api.websocket._data = {
+ "meta": {"message": "sta:sync"},
+ "data": [CLIENT_2],
+ }
+ controller.api.session_handler("data")
# Calling a service will trigger the updates to run
await hass.services.async_call(
"switch", "turn_off", {"entity_id": "switch.poe_client_1"}, blocking=True
)
- assert len(controller.mock_requests) == 6
+ assert len(controller.mock_requests) == 5
assert len(hass.states.async_all()) == 3
- assert controller.mock_requests[3] == {
+ assert controller.mock_requests[4] == {
"json": {
"port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "off"}]
},
@@ -360,8 +367,8 @@ async def test_new_client_discovered_on_poe_control(hass):
await hass.services.async_call(
"switch", "turn_on", {"entity_id": "switch.poe_client_1"}, blocking=True
)
- assert len(controller.mock_requests) == 9
- assert controller.mock_requests[3] == {
+ assert len(controller.mock_requests) == 6
+ assert controller.mock_requests[4] == {
"json": {
"port_overrides": [
{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "auto"}
@@ -386,7 +393,7 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass):
hass, clients_response=POE_SWITCH_CLIENTS, devices_response=[DEVICE_1],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 4
switch_1 = hass.states.get("switch.poe_client_1")
@@ -437,7 +444,7 @@ async def test_restoring_client(hass):
clients_all_response=[CLIENT_1],
)
- assert len(controller.mock_requests) == 3
+ assert len(controller.mock_requests) == 4
assert len(hass.states.async_all()) == 3
device_1 = hass.states.get("switch.client_1")
diff --git a/tests/components/updater/test_init.py b/tests/components/updater/test_init.py
index 07b5cb059bf79f..10fa026db29a1d 100644
--- a/tests/components/updater/test_init.py
+++ b/tests/components/updater/test_init.py
@@ -1,20 +1,15 @@
"""The tests for the Updater component."""
import asyncio
-from datetime import timedelta
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
+from asynctest import patch
import pytest
from homeassistant.components import updater
+from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
-from tests.common import (
- MockDependency,
- async_fire_time_changed,
- mock_component,
- mock_coro,
-)
+from tests.common import MockDependency, mock_component, mock_coro
NEW_VERSION = "10000.0"
MOCK_VERSION = "10.0"
@@ -32,95 +27,39 @@ def mock_distro():
yield
+@pytest.fixture(autouse=True)
+def mock_version():
+ """Mock current version."""
+ with patch("homeassistant.components.updater.current_version", MOCK_VERSION):
+ yield
+
+
@pytest.fixture(name="mock_get_newest_version")
def mock_get_newest_version_fixture():
"""Fixture to mock get_newest_version."""
- with patch("homeassistant.components.updater.get_newest_version") as mock:
+ with patch(
+ "homeassistant.components.updater.get_newest_version",
+ return_value=(NEW_VERSION, RELEASE_NOTES),
+ ) as mock:
yield mock
-@pytest.fixture(name="mock_get_uuid")
+@pytest.fixture(name="mock_get_uuid", autouse=True)
def mock_get_uuid_fixture():
"""Fixture to mock get_uuid."""
with patch("homeassistant.components.updater._load_uuid") as mock:
yield mock
-@pytest.fixture(name="mock_utcnow")
-def mock_utcnow_fixture():
- """Fixture to mock utcnow."""
- with patch("homeassistant.components.updater.dt_util") as mock:
- yield mock.utcnow
-
-
-async def test_new_version_shows_entity_startup(
- hass, mock_get_uuid, mock_get_newest_version
-):
- """Test if binary sensor is unavailable at first."""
- mock_get_uuid.return_value = MOCK_HUUID
- mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES))
-
- res = await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
- assert res, "Updater failed to set up"
-
- await hass.async_block_till_done()
- assert hass.states.is_state("binary_sensor.updater", "unavailable")
- assert "newest_version" not in hass.states.get("binary_sensor.updater").attributes
- assert "release_notes" not in hass.states.get("binary_sensor.updater").attributes
-
-
-async def test_rename_entity(hass, mock_get_uuid, mock_get_newest_version, mock_utcnow):
- """Test if renaming the binary sensor works correctly."""
- mock_get_uuid.return_value = MOCK_HUUID
- mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES))
-
- now = dt_util.utcnow()
- later = now + timedelta(hours=1)
- mock_utcnow.return_value = now
-
- res = await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
- assert res, "Updater failed to set up"
-
- await hass.async_block_till_done()
- assert hass.states.is_state("binary_sensor.updater", "unavailable")
- assert hass.states.get("binary_sensor.new_entity_id") is None
-
- entity_registry = await hass.helpers.entity_registry.async_get_registry()
- entity_registry.async_update_entity(
- "binary_sensor.updater", new_entity_id="binary_sensor.new_entity_id"
- )
-
- await hass.async_block_till_done()
- assert hass.states.is_state("binary_sensor.new_entity_id", "unavailable")
- assert hass.states.get("binary_sensor.updater") is None
-
- with patch("homeassistant.components.updater.current_version", MOCK_VERSION):
- async_fire_time_changed(hass, later)
- await hass.async_block_till_done()
-
- assert hass.states.is_state("binary_sensor.new_entity_id", "on")
- assert hass.states.get("binary_sensor.updater") is None
-
-
async def test_new_version_shows_entity_true(
- hass, mock_get_uuid, mock_get_newest_version, mock_utcnow
+ hass, mock_get_uuid, mock_get_newest_version
):
"""Test if sensor is true if new version is available."""
mock_get_uuid.return_value = MOCK_HUUID
- mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES))
-
- now = dt_util.utcnow()
- later = now + timedelta(hours=1)
- mock_utcnow.return_value = now
- res = await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
- assert res, "Updater failed to set up"
+ assert await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
await hass.async_block_till_done()
- with patch("homeassistant.components.updater.current_version", MOCK_VERSION):
- async_fire_time_changed(hass, later)
- await hass.async_block_till_done()
-
assert hass.states.is_state("binary_sensor.updater", "on")
assert (
hass.states.get("binary_sensor.updater").attributes["newest_version"]
@@ -133,23 +72,15 @@ async def test_new_version_shows_entity_true(
async def test_same_version_shows_entity_false(
- hass, mock_get_uuid, mock_get_newest_version, mock_utcnow
+ hass, mock_get_uuid, mock_get_newest_version
):
"""Test if sensor is false if no new version is available."""
mock_get_uuid.return_value = MOCK_HUUID
mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, ""))
- now = dt_util.utcnow()
- later = now + timedelta(hours=1)
- mock_utcnow.return_value = now
-
- res = await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
- assert res, "Updater failed to set up"
+ assert await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
await hass.async_block_till_done()
- with patch("homeassistant.components.updater.current_version", MOCK_VERSION):
- async_fire_time_changed(hass, later)
- await hass.async_block_till_done()
assert hass.states.is_state("binary_sensor.updater", "off")
assert (
@@ -159,29 +90,18 @@ async def test_same_version_shows_entity_false(
assert "release_notes" not in hass.states.get("binary_sensor.updater").attributes
-async def test_disable_reporting(
- hass, mock_get_uuid, mock_get_newest_version, mock_utcnow
-):
+async def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version):
"""Test we do not gather analytics when disable reporting is active."""
mock_get_uuid.return_value = MOCK_HUUID
mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, ""))
- now = dt_util.utcnow()
- later = now + timedelta(hours=1)
- mock_utcnow.return_value = now
-
- res = await async_setup_component(
+ assert await async_setup_component(
hass, updater.DOMAIN, {updater.DOMAIN: {"reporting": False}}
)
- assert res, "Updater failed to set up"
-
await hass.async_block_till_done()
- with patch("homeassistant.components.updater.current_version", MOCK_VERSION):
- async_fire_time_changed(hass, later)
- await hass.async_block_till_done()
assert hass.states.is_state("binary_sensor.updater", "off")
- res = await updater.get_newest_version(hass, MOCK_HUUID, MOCK_CONFIG)
+ await updater.get_newest_version(hass, MOCK_HUUID, MOCK_CONFIG)
call = mock_get_newest_version.mock_calls[0][1]
assert call[0] is hass
assert call[1] is None
@@ -215,9 +135,10 @@ async def test_error_fetching_new_version_timeout(hass):
with patch(
"homeassistant.helpers.system_info.async_get_system_info",
Mock(return_value=mock_coro({"fake": "bla"})),
- ), patch("async_timeout.timeout", side_effect=asyncio.TimeoutError):
- res = await updater.get_newest_version(hass, MOCK_HUUID, False)
- assert res is None
+ ), patch("async_timeout.timeout", side_effect=asyncio.TimeoutError), pytest.raises(
+ UpdateFailed
+ ):
+ await updater.get_newest_version(hass, MOCK_HUUID, False)
async def test_error_fetching_new_version_bad_json(hass, aioclient_mock):
@@ -227,9 +148,8 @@ async def test_error_fetching_new_version_bad_json(hass, aioclient_mock):
with patch(
"homeassistant.helpers.system_info.async_get_system_info",
Mock(return_value=mock_coro({"fake": "bla"})),
- ):
- res = await updater.get_newest_version(hass, MOCK_HUUID, False)
- assert res is None
+ ), pytest.raises(UpdateFailed):
+ await updater.get_newest_version(hass, MOCK_HUUID, False)
async def test_error_fetching_new_version_invalid_response(hass, aioclient_mock):
@@ -245,31 +165,21 @@ async def test_error_fetching_new_version_invalid_response(hass, aioclient_mock)
with patch(
"homeassistant.helpers.system_info.async_get_system_info",
Mock(return_value=mock_coro({"fake": "bla"})),
- ):
- res = await updater.get_newest_version(hass, MOCK_HUUID, False)
- assert res is None
+ ), pytest.raises(UpdateFailed):
+ await updater.get_newest_version(hass, MOCK_HUUID, False)
async def test_new_version_shows_entity_after_hour_hassio(
- hass, mock_get_uuid, mock_get_newest_version, mock_utcnow
+ hass, mock_get_uuid, mock_get_newest_version
):
"""Test if binary sensor gets updated if new version is available / Hass.io."""
mock_get_uuid.return_value = MOCK_HUUID
- mock_get_newest_version.return_value = mock_coro((NEW_VERSION, RELEASE_NOTES))
mock_component(hass, "hassio")
hass.data["hassio_hass_version"] = "999.0"
- now = dt_util.utcnow()
- later = now + timedelta(hours=1)
- mock_utcnow.return_value = now
-
- res = await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
- assert res, "Updater failed to set up"
+ assert await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}})
await hass.async_block_till_done()
- with patch("homeassistant.components.updater.current_version", MOCK_VERSION):
- async_fire_time_changed(hass, later)
- await hass.async_block_till_done()
assert hass.states.is_state("binary_sensor.updater", "on")
assert (
diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py
index 7be944305da8d2..16715266b8c47d 100644
--- a/tests/components/vacuum/test_device_condition.py
+++ b/tests/components/vacuum/test_device_condition.py
@@ -35,7 +35,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py
index 554de025e58a8e..f3439700e33fab 100644
--- a/tests/components/vacuum/test_device_trigger.py
+++ b/tests/components/vacuum/test_device_trigger.py
@@ -30,7 +30,7 @@ def entity_reg(hass):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py
index 66273e01f4360f..daeffb4ed1d2d7 100644
--- a/tests/components/velbus/test_config_flow.py
+++ b/tests/components/velbus/test_config_flow.py
@@ -22,7 +22,7 @@ def mock_controller_assert():
@pytest.fixture(name="controller")
def mock_controller():
- """Mock a successfull velbus controller."""
+ """Mock a successful velbus controller."""
controller = Mock()
with patch("velbus.Controller", return_value=controller):
yield controller
diff --git a/tests/components/vilfo/__init__.py b/tests/components/vilfo/__init__.py
new file mode 100644
index 00000000000000..680b556fc12a0d
--- /dev/null
+++ b/tests/components/vilfo/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Vilfo Router integration."""
diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py
new file mode 100644
index 00000000000000..d73d15df8dd7ce
--- /dev/null
+++ b/tests/components/vilfo/test_config_flow.py
@@ -0,0 +1,184 @@
+"""Test the Vilfo Router config flow."""
+from unittest.mock import patch
+
+import vilfo
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.vilfo.const import DOMAIN
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_ID, CONF_MAC
+
+from tests.common import mock_coro
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ with patch("vilfo.Client.ping", return_value=None), patch(
+ "vilfo.Client.get_board_information", return_value=None,
+ ), patch(
+ "homeassistant.components.vilfo.async_setup", return_value=mock_coro(True)
+ ) as mock_setup, patch(
+ "homeassistant.components.vilfo.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"},
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result2["title"] == "testadmin.vilfo.com"
+ assert result2["data"] == {
+ "host": "testadmin.vilfo.com",
+ "access_token": "test-token",
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("vilfo.Client.ping", return_value=None), patch(
+ "vilfo.Client.get_board_information",
+ side_effect=vilfo.exceptions.AuthenticationException,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "testadmin.vilfo.com", "access_token": "test-token"},
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("vilfo.Client.ping", side_effect=vilfo.exceptions.VilfoException):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "testadmin.vilfo.com", "access_token": "test-token"},
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+ with patch("vilfo.Client.ping", side_effect=vilfo.exceptions.VilfoException):
+ result3 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "testadmin.vilfo.com", "access_token": "test-token"},
+ )
+
+ assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result3["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_wrong_host(hass):
+ """Test we handle wrong host errors."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={"host": "this is an invalid hostname", "access_token": "test-token"},
+ )
+
+ assert result["errors"] == {"host": "wrong_host"}
+
+
+async def test_form_already_configured(hass):
+ """Test that we handle already configured exceptions appropriately."""
+ first_flow_result1 = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("vilfo.Client.ping", return_value=None), patch(
+ "vilfo.Client.get_board_information", return_value=None,
+ ):
+ first_flow_result2 = await hass.config_entries.flow.async_configure(
+ first_flow_result1["flow_id"],
+ {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"},
+ )
+
+ second_flow_result1 = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("vilfo.Client.ping", return_value=None), patch(
+ "vilfo.Client.get_board_information", return_value=None,
+ ):
+ second_flow_result2 = await hass.config_entries.flow.async_configure(
+ second_flow_result1["flow_id"],
+ {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"},
+ )
+
+ assert first_flow_result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert second_flow_result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert second_flow_result2["reason"] == "already_configured"
+
+
+async def test_form_unexpected_exception(hass):
+ """Test that we handle unexpected exceptions."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("vilfo.Client.ping", side_effect=Exception):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "testadmin.vilfo.com", "access_token": "test-token"},
+ )
+
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_validate_input_returns_data(hass):
+ """Test we handle the MAC address being resolved or not."""
+ mock_data = {"host": "testadmin.vilfo.com", "access_token": "test-token"}
+ mock_data_with_ip = {"host": "192.168.0.1", "access_token": "test-token"}
+ mock_mac = "FF-00-00-00-00-00"
+
+ with patch("vilfo.Client.ping", return_value=None), patch(
+ "vilfo.Client.get_board_information", return_value=None
+ ):
+ result = await hass.components.vilfo.config_flow.validate_input(
+ hass, data=mock_data
+ )
+
+ assert result["title"] == mock_data["host"]
+ assert result[CONF_HOST] == mock_data["host"]
+ assert result[CONF_MAC] is None
+ assert result[CONF_ID] == mock_data["host"]
+
+ with patch("vilfo.Client.ping", return_value=None), patch(
+ "vilfo.Client.get_board_information", return_value=None
+ ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac):
+ result2 = await hass.components.vilfo.config_flow.validate_input(
+ hass, data=mock_data
+ )
+ result3 = await hass.components.vilfo.config_flow.validate_input(
+ hass, data=mock_data_with_ip
+ )
+
+ assert result2["title"] == mock_data["host"]
+ assert result2[CONF_HOST] == mock_data["host"]
+ assert result2[CONF_MAC] == mock_mac
+ assert result2[CONF_ID] == mock_mac
+
+ assert result3["title"] == mock_data_with_ip["host"]
+ assert result3[CONF_HOST] == mock_data_with_ip["host"]
+ assert result3[CONF_MAC] == mock_mac
+ assert result3[CONF_ID] == mock_mac
diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py
new file mode 100644
index 00000000000000..c42f03db0643fc
--- /dev/null
+++ b/tests/components/vizio/conftest.py
@@ -0,0 +1,107 @@
+"""Configure py.test."""
+from asynctest import patch
+import pytest
+from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME
+
+from .const import CURRENT_INPUT, INPUT_LIST, MODEL, UNIQUE_ID, VERSION
+
+
+class MockInput:
+ """Mock Vizio device input."""
+
+ def __init__(self, name):
+ """Initialize mock Vizio device input."""
+ self.meta_name = name
+ self.name = name
+
+
+def get_mock_inputs(input_list):
+ """Return list of MockInput."""
+ return [MockInput(input) for input in input_list]
+
+
+@pytest.fixture(name="skip_notifications", autouse=True)
+def skip_notifications_fixture():
+ """Skip notification calls."""
+ with patch("homeassistant.components.persistent_notification.async_create"), patch(
+ "homeassistant.components.persistent_notification.async_dismiss"
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_connect")
+def vizio_connect_fixture():
+ """Mock valid vizio device and entry setup."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id",
+ return_value=UNIQUE_ID,
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_bypass_setup")
+def vizio_bypass_setup_fixture():
+ """Mock component setup."""
+ with patch("homeassistant.components.vizio.async_setup_entry", return_value=True):
+ yield
+
+
+@pytest.fixture(name="vizio_bypass_update")
+def vizio_bypass_update_fixture():
+ """Mock component update."""
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
+ return_value=True,
+ ), patch("homeassistant.components.vizio.media_player.VizioDevice.async_update"):
+ yield
+
+
+@pytest.fixture(name="vizio_guess_device_type")
+def vizio_guess_device_type_fixture():
+ """Mock vizio async_guess_device_type function."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.async_guess_device_type",
+ return_value="speaker",
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_cant_connect")
+def vizio_cant_connect_fixture():
+ """Mock vizio device can't connect with valid auth."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config",
+ return_value=False,
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_update")
+def vizio_update_fixture():
+ """Mock valid updates to vizio device."""
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume",
+ return_value=int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2),
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
+ return_value=CURRENT_INPUT,
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
+ return_value=get_mock_inputs(INPUT_LIST),
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_model_name",
+ return_value=MODEL,
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_version",
+ return_value=VERSION,
+ ):
+ yield
diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py
new file mode 100644
index 00000000000000..c241394737eded
--- /dev/null
+++ b/tests/components/vizio/const.py
@@ -0,0 +1,79 @@
+"""Constants for the Vizio integration tests."""
+import logging
+
+from homeassistant.components.media_player import (
+ DEVICE_CLASS_SPEAKER,
+ DEVICE_CLASS_TV,
+ DOMAIN as MP_DOMAIN,
+)
+from homeassistant.components.vizio.const import CONF_VOLUME_STEP
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN,
+ CONF_DEVICE_CLASS,
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PORT,
+ CONF_TYPE,
+)
+from homeassistant.util import slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+NAME = "Vizio"
+NAME2 = "Vizio2"
+HOST = "192.168.1.1:9000"
+HOST2 = "192.168.1.2:9000"
+ACCESS_TOKEN = "deadbeef"
+VOLUME_STEP = 2
+UNIQUE_ID = "testid"
+MODEL = "model"
+VERSION = "version"
+
+MOCK_USER_VALID_TV_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+}
+
+MOCK_OPTIONS = {
+ CONF_VOLUME_STEP: VOLUME_STEP,
+}
+
+MOCK_IMPORT_VALID_TV_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_VOLUME_STEP: VOLUME_STEP,
+}
+
+MOCK_TV_CONFIG_NO_TOKEN = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+}
+
+MOCK_SPEAKER_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
+}
+
+VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local."
+ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}"
+ZEROCONF_HOST = HOST.split(":")[0]
+ZEROCONF_PORT = HOST.split(":")[1]
+
+MOCK_ZEROCONF_SERVICE_INFO = {
+ CONF_TYPE: VIZIO_ZEROCONF_SERVICE_TYPE,
+ CONF_NAME: ZEROCONF_NAME,
+ CONF_HOST: ZEROCONF_HOST,
+ CONF_PORT: ZEROCONF_PORT,
+ "properties": {"name": "SB4031-D5"},
+}
+
+CURRENT_INPUT = "HDMI"
+INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
+
+ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}"
diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py
index c82c7a8de0f05b..2dd32800c2dae0 100644
--- a/tests/components/vizio/test_config_flow.py
+++ b/tests/components/vizio/test_config_flow.py
@@ -1,7 +1,4 @@
"""Tests for Vizio config flow."""
-import logging
-
-from asynctest import patch
import pytest
import voluptuous as vol
@@ -20,112 +17,25 @@
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_NAME,
- CONF_PORT,
- CONF_TYPE,
)
from homeassistant.helpers.typing import HomeAssistantType
-from tests.common import MockConfigEntry
+from .const import (
+ ACCESS_TOKEN,
+ HOST,
+ HOST2,
+ MOCK_IMPORT_VALID_TV_CONFIG,
+ MOCK_SPEAKER_CONFIG,
+ MOCK_TV_CONFIG_NO_TOKEN,
+ MOCK_USER_VALID_TV_CONFIG,
+ MOCK_ZEROCONF_SERVICE_INFO,
+ NAME,
+ NAME2,
+ UNIQUE_ID,
+ VOLUME_STEP,
+)
-_LOGGER = logging.getLogger(__name__)
-
-NAME = "Vizio"
-NAME2 = "Vizio2"
-HOST = "192.168.1.1:9000"
-HOST2 = "192.168.1.2:9000"
-ACCESS_TOKEN = "deadbeef"
-VOLUME_STEP = 2
-UNIQUE_ID = "testid"
-
-MOCK_USER_VALID_TV_CONFIG = {
- CONF_NAME: NAME,
- CONF_HOST: HOST,
- CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
- CONF_ACCESS_TOKEN: ACCESS_TOKEN,
-}
-
-MOCK_IMPORT_VALID_TV_CONFIG = {
- CONF_NAME: NAME,
- CONF_HOST: HOST,
- CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
- CONF_ACCESS_TOKEN: ACCESS_TOKEN,
- CONF_VOLUME_STEP: VOLUME_STEP,
-}
-
-MOCK_INVALID_TV_CONFIG = {
- CONF_NAME: NAME,
- CONF_HOST: HOST,
- CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
-}
-
-MOCK_SPEAKER_CONFIG = {
- CONF_NAME: NAME,
- CONF_HOST: HOST,
- CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
-}
-
-VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local."
-ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}"
-ZEROCONF_HOST = HOST.split(":")[0]
-ZEROCONF_PORT = HOST.split(":")[1]
-
-MOCK_ZEROCONF_ENTRY = {
- CONF_TYPE: VIZIO_ZEROCONF_SERVICE_TYPE,
- CONF_NAME: ZEROCONF_NAME,
- CONF_HOST: ZEROCONF_HOST,
- CONF_PORT: ZEROCONF_PORT,
- "properties": {"name": "SB4031-D5"},
-}
-
-
-@pytest.fixture(name="vizio_connect")
-def vizio_connect_fixture():
- """Mock valid vizio device and entry setup."""
- with patch(
- "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config",
- return_value=True,
- ), patch(
- "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id",
- return_value=UNIQUE_ID,
- ):
- yield
-
-
-@pytest.fixture(name="vizio_bypass_setup")
-def vizio_bypass_setup_fixture():
- """Mock component setup."""
- with patch("homeassistant.components.vizio.async_setup_entry", return_value=True):
- yield
-
-
-@pytest.fixture(name="vizio_bypass_update")
-def vizio_bypass_update_fixture():
- """Mock component update."""
- with patch(
- "homeassistant.components.vizio.media_player.VizioAsync.can_connect",
- return_value=True,
- ), patch("homeassistant.components.vizio.media_player.VizioDevice.async_update"):
- yield
-
-
-@pytest.fixture(name="vizio_guess_device_type")
-def vizio_guess_device_type_fixture():
- """Mock vizio async_guess_device_type function."""
- with patch(
- "homeassistant.components.vizio.config_flow.async_guess_device_type",
- return_value="speaker",
- ):
- yield
-
-
-@pytest.fixture(name="vizio_cant_connect")
-def vizio_cant_connect_fixture():
- """Mock vizio device cant connect."""
- with patch(
- "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config",
- return_value=False,
- ):
- yield
+from tests.common import MockConfigEntry
async def test_user_flow_minimum_fields(
@@ -142,12 +52,7 @@ async def test_user_flow_minimum_fields(
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={
- CONF_NAME: NAME,
- CONF_HOST: HOST,
- CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
- },
+ result["flow_id"], user_input=MOCK_SPEAKER_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -172,13 +77,7 @@ async def test_user_flow_all_fields(
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={
- CONF_NAME: NAME,
- CONF_HOST: HOST,
- CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
- CONF_ACCESS_TOKEN: ACCESS_TOKEN,
- },
+ result["flow_id"], user_input=MOCK_USER_VALID_TV_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -231,6 +130,30 @@ async def test_user_host_already_configured(
assert result["errors"] == {CONF_HOST: "host_exists"}
+async def test_user_host_already_configured_no_port(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+) -> None:
+ """Test host is already configured during user setup when existing entry has no port."""
+ # Mock entry without port so we can test that the same entry WITH a port will fail
+ no_port_entry = MOCK_SPEAKER_CONFIG.copy()
+ no_port_entry[CONF_HOST] = no_port_entry[CONF_HOST].split(":")[0]
+ entry = MockConfigEntry(
+ domain=DOMAIN, data=no_port_entry, options={CONF_VOLUME_STEP: VOLUME_STEP}
+ )
+ entry.add_to_hass(hass)
+ fail_entry = MOCK_SPEAKER_CONFIG.copy()
+ fail_entry[CONF_NAME] = "newtestname"
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=fail_entry
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {CONF_HOST: "host_exists"}
+
+
async def test_user_name_already_configured(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
@@ -296,7 +219,7 @@ async def test_user_error_on_tv_needs_token(
) -> None:
"""Test when config fails custom validation for non null access token when device_class = tv during user setup."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=MOCK_INVALID_TV_CONFIG
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -377,15 +300,15 @@ async def test_import_flow_update_options(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
- data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG),
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG),
)
await hass.async_block_till_done()
- assert result["result"].options == {CONF_VOLUME_STEP: VOLUME_STEP}
+ assert result["result"].options == {CONF_VOLUME_STEP: DEFAULT_VOLUME_STEP}
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry_id = result["result"].entry_id
- updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy()
+ updated_config = MOCK_SPEAKER_CONFIG.copy()
updated_config[CONF_VOLUME_STEP] = VOLUME_STEP + 1
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -394,13 +317,43 @@ async def test_import_flow_update_options(
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "updated_options"
+ assert result["reason"] == "updated_entry"
assert (
hass.config_entries.async_get_entry(entry_id).options[CONF_VOLUME_STEP]
== VOLUME_STEP + 1
)
+async def test_import_flow_update_name(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
+ """Test import config flow with updated name."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG),
+ )
+ await hass.async_block_till_done()
+
+ assert result["result"].data[CONF_NAME] == NAME
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ entry_id = result["result"].entry_id
+
+ updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy()
+ updated_config[CONF_NAME] = NAME2
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=vol.Schema(VIZIO_SCHEMA)(updated_config),
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "updated_entry"
+ assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2
+
+
async def test_zeroconf_flow(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
@@ -408,7 +361,7 @@ async def test_zeroconf_flow(
vizio_guess_device_type: pytest.fixture,
) -> None:
"""Test zeroconf config flow."""
- discovery_info = MOCK_ZEROCONF_ENTRY.copy()
+ discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info
)
@@ -417,7 +370,7 @@ async def test_zeroconf_flow(
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
- # Apply discovery updates to entry to mimick when user hits submit without changing
+ # Apply discovery updates to entry to mimic when user hits submit without changing
# defaults which were set from discovery parameters
user_input = result["data_schema"](discovery_info)
@@ -444,7 +397,7 @@ async def test_zeroconf_flow_already_configured(
entry.add_to_hass(hass)
# Try rediscovering same device
- discovery_info = MOCK_ZEROCONF_ENTRY.copy()
+ discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info
)
@@ -452,3 +405,29 @@ async def test_zeroconf_flow_already_configured(
# Flow should abort because device is already setup
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_setup"
+
+
+async def test_zeroconf_dupe_fail(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+ vizio_guess_device_type: pytest.fixture,
+) -> None:
+ """Test zeroconf config flow when device gets discovered multiple times."""
+ discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info
+ )
+
+ # Form should always show even if all required properties are discovered
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+ discovery_info = MOCK_ZEROCONF_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info
+ )
+
+ # Flow should abort because device is already setup
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_in_progress"
diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py
new file mode 100644
index 00000000000000..1be067e95703e0
--- /dev/null
+++ b/tests/components/vizio/test_init.py
@@ -0,0 +1,43 @@
+"""Tests for Vizio init."""
+import pytest
+
+from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
+from homeassistant.components.vizio.const import DOMAIN
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.setup import async_setup_component
+
+from .const import MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID
+
+from tests.common import MockConfigEntry
+
+
+async def test_setup_component(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test component setup."""
+ assert await async_setup_component(
+ hass, DOMAIN, {DOMAIN: MOCK_USER_VALID_TV_CONFIG}
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1
+
+
+async def test_load_and_unload(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test loading and unloading entry."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID
+ )
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1
+
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0
diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py
new file mode 100644
index 00000000000000..a94effa743334b
--- /dev/null
+++ b/tests/components/vizio/test_media_player.py
@@ -0,0 +1,313 @@
+"""Tests for Vizio config flow."""
+from datetime import timedelta
+from unittest.mock import call
+
+from asynctest import patch
+import pytest
+from pyvizio.const import (
+ DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
+ DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
+ MAX_VOLUME,
+)
+
+from homeassistant.components.media_player import (
+ ATTR_INPUT_SOURCE,
+ ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED,
+ DEVICE_CLASS_SPEAKER,
+ DEVICE_CLASS_TV,
+ DOMAIN as MP_DOMAIN,
+ SERVICE_MEDIA_NEXT_TRACK,
+ SERVICE_MEDIA_PREVIOUS_TRACK,
+ SERVICE_SELECT_SOURCE,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ SERVICE_VOLUME_DOWN,
+ SERVICE_VOLUME_MUTE,
+ SERVICE_VOLUME_SET,
+ SERVICE_VOLUME_UP,
+)
+from homeassistant.components.vizio.const import CONF_VOLUME_STEP, DOMAIN
+from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import dt as dt_util
+
+from .const import (
+ CURRENT_INPUT,
+ ENTITY_ID,
+ INPUT_LIST,
+ MOCK_SPEAKER_CONFIG,
+ MOCK_USER_VALID_TV_CONFIG,
+ NAME,
+ UNIQUE_ID,
+ VOLUME_STEP,
+)
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def _test_setup(
+ hass: HomeAssistantType, ha_device_class: str, vizio_power_state: bool
+) -> None:
+ """Test Vizio Device entity setup."""
+ if vizio_power_state:
+ ha_power_state = STATE_ON
+ elif vizio_power_state is False:
+ ha_power_state = STATE_OFF
+ else:
+ ha_power_state = STATE_UNAVAILABLE
+
+ if ha_device_class == DEVICE_CLASS_SPEAKER:
+ vizio_device_class = VIZIO_DEVICE_CLASS_SPEAKER
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID
+ )
+ else:
+ vizio_device_class = VIZIO_DEVICE_CLASS_TV
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID
+ )
+
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume",
+ return_value=int(MAX_VOLUME[vizio_device_class] / 2),
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
+ return_value=vizio_power_state,
+ ):
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ attr = hass.states.get(ENTITY_ID).attributes
+ assert attr["friendly_name"] == NAME
+ assert attr["device_class"] == ha_device_class
+
+ assert hass.states.get(ENTITY_ID).state == ha_power_state
+ if ha_power_state == STATE_ON:
+ assert attr["source_list"] == INPUT_LIST
+ assert attr["source"] == CURRENT_INPUT
+ assert (
+ attr["volume_level"]
+ == float(int(MAX_VOLUME[vizio_device_class] / 2))
+ / MAX_VOLUME[vizio_device_class]
+ )
+
+
+async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None:
+ """Test generic Vizio entity setup failure."""
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
+ return_value=False,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id=UNIQUE_ID)
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0
+
+
+async def _test_service(
+ hass: HomeAssistantType,
+ vizio_func_name: str,
+ ha_service_name: str,
+ additional_service_data: dict,
+ *args,
+ **kwargs,
+) -> None:
+ """Test generic Vizio media player entity service."""
+ service_data = {ATTR_ENTITY_ID: ENTITY_ID}
+ if additional_service_data:
+ service_data.update(additional_service_data)
+
+ with patch(
+ f"homeassistant.components.vizio.media_player.VizioAsync.{vizio_func_name}"
+ ) as service_call:
+ await hass.services.async_call(
+ MP_DOMAIN, ha_service_name, service_data=service_data, blocking=True,
+ )
+ assert service_call.called
+
+ if args or kwargs:
+ assert service_call.call_args == call(*args, **kwargs)
+
+
+async def test_speaker_on(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test Vizio Speaker entity setup when on."""
+ await _test_setup(hass, DEVICE_CLASS_SPEAKER, True)
+
+
+async def test_speaker_off(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test Vizio Speaker entity setup when off."""
+ await _test_setup(hass, DEVICE_CLASS_SPEAKER, False)
+
+
+async def test_speaker_unavailable(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test Vizio Speaker entity setup when unavailable."""
+ await _test_setup(hass, DEVICE_CLASS_SPEAKER, None)
+
+
+async def test_init_tv_on(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test Vizio TV entity setup when on."""
+ await _test_setup(hass, DEVICE_CLASS_TV, True)
+
+
+async def test_init_tv_off(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test Vizio TV entity setup when off."""
+ await _test_setup(hass, DEVICE_CLASS_TV, False)
+
+
+async def test_init_tv_unavailable(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test Vizio TV entity setup when unavailable."""
+ await _test_setup(hass, DEVICE_CLASS_TV, None)
+
+
+async def test_setup_failure_speaker(
+ hass: HomeAssistantType, vizio_connect: pytest.fixture
+) -> None:
+ """Test speaker entity setup failure."""
+ await _test_setup_failure(hass, MOCK_SPEAKER_CONFIG)
+
+
+async def test_setup_failure_tv(
+ hass: HomeAssistantType, vizio_connect: pytest.fixture
+) -> None:
+ """Test TV entity setup failure."""
+ await _test_setup_failure(hass, MOCK_USER_VALID_TV_CONFIG)
+
+
+async def test_services(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test all Vizio media player entity services."""
+ await _test_setup(hass, DEVICE_CLASS_TV, True)
+
+ await _test_service(hass, "pow_on", SERVICE_TURN_ON, None)
+ await _test_service(hass, "pow_off", SERVICE_TURN_OFF, None)
+ await _test_service(
+ hass, "mute_on", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}
+ )
+ await _test_service(
+ hass, "mute_off", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}
+ )
+ await _test_service(
+ hass, "set_input", SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "USB"}, "USB"
+ )
+ await _test_service(hass, "vol_up", SERVICE_VOLUME_UP, None)
+ await _test_service(hass, "vol_down", SERVICE_VOLUME_DOWN, None)
+ await _test_service(
+ hass, "vol_up", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 1}
+ )
+ await _test_service(
+ hass, "vol_down", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0}
+ )
+ await _test_service(hass, "ch_up", SERVICE_MEDIA_NEXT_TRACK, None)
+ await _test_service(hass, "ch_down", SERVICE_MEDIA_PREVIOUS_TRACK, None)
+
+
+async def test_options_update(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+) -> None:
+ """Test when config entry update event fires."""
+ await _test_setup(hass, DEVICE_CLASS_SPEAKER, True)
+ config_entry = hass.config_entries.async_entries(DOMAIN)[0]
+ assert config_entry.options
+ new_options = config_entry.options.copy()
+ updated_options = {CONF_VOLUME_STEP: VOLUME_STEP}
+ new_options.update(updated_options)
+ hass.config_entries.async_update_entry(
+ entry=config_entry, options=new_options,
+ )
+ assert config_entry.options == updated_options
+ await _test_service(hass, "vol_up", SERVICE_VOLUME_UP, None, num=VOLUME_STEP)
+
+
+async def _test_update_availability_switch(
+ hass: HomeAssistantType,
+ initial_power_state: bool,
+ final_power_state: bool,
+ caplog: pytest.fixture,
+) -> None:
+ now = dt_util.utcnow()
+ future_interval = timedelta(minutes=1)
+
+ # Setup device as if time is right now
+ with patch("homeassistant.util.dt.utcnow", return_value=now):
+ await _test_setup(hass, DEVICE_CLASS_SPEAKER, initial_power_state)
+
+ # Clear captured logs so that only availability state changes are captured for
+ # future assertion
+ caplog.clear()
+
+ # Fast forward time to future twice to trigger update and assert vizio log message
+ for i in range(1, 3):
+ future = now + (future_interval * i)
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
+ return_value=final_power_state,
+ ), patch("homeassistant.util.dt.utcnow", return_value=future), patch(
+ "homeassistant.util.utcnow", return_value=future
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ if final_power_state is None:
+ assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE
+ else:
+ assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE
+
+ # Ensure connection status messages from vizio.media_player appear exactly once
+ # (on availability state change)
+ vizio_log_list = [
+ log
+ for log in caplog.records
+ if log.name == "homeassistant.components.vizio.media_player"
+ ]
+ assert len(vizio_log_list) == 1
+
+
+async def test_update_unavailable_to_available(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test device becomes available after being unavailable."""
+ await _test_update_availability_switch(hass, None, True, caplog)
+
+
+async def test_update_available_to_unavailable(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test device becomes unavailable after being available."""
+ await _test_update_availability_switch(hass, True, None, caplog)
diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py
index d2a7197fe1a006..a65201735ae45a 100644
--- a/tests/components/voicerss/test_tts.py
+++ b/tests/components/voicerss/test_tts.py
@@ -67,7 +67,10 @@ def test_service_say(self, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"voicerss_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -97,7 +100,10 @@ def test_service_say_german_config(self, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"voicerss_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -121,6 +127,7 @@ def test_service_say_german_service(self, aioclient_mock):
tts.DOMAIN,
"voicerss_say",
{
+ "entity_id": "media_player.something",
tts.ATTR_MESSAGE: "I person is on front of your door.",
tts.ATTR_LANGUAGE: "de-de",
},
@@ -145,7 +152,10 @@ def test_service_say_error(self, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"voicerss_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -167,7 +177,10 @@ def test_service_say_timeout(self, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"voicerss_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ },
)
self.hass.block_till_done()
@@ -194,7 +207,10 @@ def test_service_say_error_msg(self, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"voicerss_say",
- {tts.ATTR_MESSAGE: "I person is on front of your door."},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ },
)
self.hass.block_till_done()
diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py
index 4da60783c44cc1..80fd05a41ccddf 100644
--- a/tests/components/vultr/test_sensor.py
+++ b/tests/components/vultr/test_sensor.py
@@ -10,7 +10,12 @@
from homeassistant.components import vultr as base_vultr
from homeassistant.components.vultr import CONF_SUBSCRIPTION
import homeassistant.components.vultr.sensor as vultr
-from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PLATFORM
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS,
+ CONF_NAME,
+ CONF_PLATFORM,
+ DATA_GIGABYTES,
+)
from tests.common import get_test_home_assistant, load_fixture
from tests.components.vultr.test_init import VALID_CONFIG
@@ -83,7 +88,7 @@ def test_sensor(self, mock):
device.update()
- if device.unit_of_measurement == "GB": # Test Bandwidth Used
+ if device.unit_of_measurement == DATA_GIGABYTES: # Test Bandwidth Used
if device.subscription == "576965":
assert "Vultr my new server Current Bandwidth Used" == device.name
assert "mdi:chart-histogram" == device.icon
diff --git a/tests/components/weblink/__init__.py b/tests/components/weblink/__init__.py
deleted file mode 100644
index 1d58e9c24d6c77..00000000000000
--- a/tests/components/weblink/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for the weblink component."""
diff --git a/tests/components/weblink/test_init.py b/tests/components/weblink/test_init.py
deleted file mode 100644
index 5f803107c4629a..00000000000000
--- a/tests/components/weblink/test_init.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""The tests for the weblink component."""
-import unittest
-
-from homeassistant.components import weblink
-from homeassistant.setup import setup_component
-
-from tests.common import get_test_home_assistant
-
-
-class TestComponentWeblink(unittest.TestCase):
- """Test the Weblink component."""
-
- def setUp(self):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_bad_config(self):
- """Test if new entity is created."""
- assert not setup_component(
- self.hass, "weblink", {"weblink": {"entities": [{}]}}
- )
-
- def test_bad_config_relative_url(self):
- """Test if new entity is created."""
- assert not setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My router",
- weblink.CONF_URL: "../states/group.bla",
- }
- ]
- }
- },
- )
-
- def test_bad_config_relative_file(self):
- """Test if new entity is created."""
- assert not setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {weblink.CONF_NAME: "My group", weblink.CONF_URL: "group.bla"}
- ]
- }
- },
- )
-
- def test_good_config_absolute_path(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My second URL",
- weblink.CONF_URL: "/states/group.bla",
- }
- ]
- }
- },
- )
-
- def test_good_config_path_short(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {weblink.CONF_NAME: "My third URL", weblink.CONF_URL: "/states"}
- ]
- }
- },
- )
-
- def test_good_config_path_directory(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My last URL",
- weblink.CONF_URL: "/states/bla/",
- }
- ]
- }
- },
- )
-
- def test_good_config_ftp_link(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My FTP URL",
- weblink.CONF_URL: "ftp://somehost/",
- }
- ]
- }
- },
- )
-
- def test_entities_get_created(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- weblink.DOMAIN,
- {
- weblink.DOMAIN: {
- "entities": [
- {
- weblink.CONF_NAME: "My router",
- weblink.CONF_URL: "http://127.0.0.1/",
- }
- ]
- }
- },
- )
-
- state = self.hass.states.get("weblink.my_router")
-
- assert state is not None
- assert state.state == "http://127.0.0.1/"
diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py
index 779e39c67cea18..894968f5db444d 100644
--- a/tests/components/wled/test_sensor.py
+++ b/tests/components/wled/test_sensor.py
@@ -8,10 +8,9 @@
ATTR_LED_COUNT,
ATTR_MAX_POWER,
CURRENT_MA,
- DATA_BYTES,
DOMAIN,
)
-from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT
+from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DATA_BYTES
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py
index edd5c058f12615..182c629d795acc 100644
--- a/tests/components/yandextts/test_tts.py
+++ b/tests/components/yandextts/test_tts.py
@@ -67,7 +67,9 @@ def test_service_say(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -103,7 +105,9 @@ def test_service_say_russian_config(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -135,7 +139,11 @@ def test_service_say_russian_service(self, aioclient_mock):
self.hass.services.call(
tts.DOMAIN,
"yandextts_say",
- {tts.ATTR_MESSAGE: "HomeAssistant", tts.ATTR_LANGUAGE: "ru-RU"},
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ tts.ATTR_LANGUAGE: "ru-RU",
+ },
)
self.hass.block_till_done()
@@ -165,7 +173,9 @@ def test_service_say_timeout(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -195,7 +205,9 @@ def test_service_say_http_error(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -230,7 +242,9 @@ def test_service_say_specified_speaker(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -266,7 +280,9 @@ def test_service_say_specified_emotion(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -298,7 +314,9 @@ def test_service_say_specified_low_speed(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -330,7 +348,9 @@ def test_service_say_specified_speed(self, aioclient_mock):
setup_component(self.hass, tts.DOMAIN, config)
self.hass.services.call(
- tts.DOMAIN, "yandextts_say", {tts.ATTR_MESSAGE: "HomeAssistant"}
+ tts.DOMAIN,
+ "yandextts_say",
+ {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"},
)
self.hass.block_till_done()
@@ -362,6 +382,7 @@ def test_service_say_specified_options(self, aioclient_mock):
tts.DOMAIN,
"yandextts_say",
{
+ "entity_id": "media_player.something",
tts.ATTR_MESSAGE: "HomeAssistant",
"options": {"emotion": "evil", "speed": 2},
},
diff --git a/tests/components/yessssms/test_notify.py b/tests/components/yessssms/test_notify.py
index f42f2f6af2ed88..f1eec4da942a6c 100644
--- a/tests/components/yessssms/test_notify.py
+++ b/tests/components/yessssms/test_notify.py
@@ -36,7 +36,7 @@ def init_valid_settings(hass, config):
@pytest.fixture(name="invalid_provider_settings")
def init_invalid_provider_settings(hass, config):
- """Set invalid provider data and initalize component."""
+ """Set invalid provider data and initialize component."""
config["notify"][CONF_PROVIDER] = "FantasyMobile" # invalid provider
return async_setup_component(hass, "notify", config)
diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py
index 161a7cef66bc46..d3e3bd6286fbfa 100644
--- a/tests/components/yr/test_sensor.py
+++ b/tests/components/yr/test_sensor.py
@@ -3,6 +3,7 @@
from unittest.mock import patch
from homeassistant.bootstrap import async_setup_component
+from homeassistant.const import SPEED_METERS_PER_SECOND
import homeassistant.util.dt as dt_util
from tests.common import assert_setup_component, load_fixture
@@ -70,7 +71,7 @@ async def test_custom_setup(hass, aioclient_mock):
assert state.state == "0.0"
state = hass.states.get("sensor.yr_wind_speed")
- assert state.attributes.get("unit_of_measurement") == "m/s"
+ assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND
assert state.state == "3.5"
@@ -116,5 +117,5 @@ async def test_forecast_setup(hass, aioclient_mock):
assert state.state == "0.0"
state = hass.states.get("sensor.yr_wind_speed")
- assert state.attributes.get("unit_of_measurement") == "m/s"
+ assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND
assert state.state == "3.6"
diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py
index 06712e638f69f7..dfa0c45564954c 100644
--- a/tests/components/zha/common.py
+++ b/tests/components/zha/common.py
@@ -1,6 +1,6 @@
"""Common test objects."""
import time
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
from asynctest import CoroutineMock
import zigpy.profiles.zha
@@ -10,28 +10,9 @@
import zigpy.zcl.foundation as zcl_f
import zigpy.zdo.types
-from homeassistant.components.zha.core.const import (
- DATA_ZHA,
- DATA_ZHA_BRIDGE_ID,
- DATA_ZHA_CONFIG,
- DATA_ZHA_DISPATCHERS,
-)
+import homeassistant.components.zha.core.const as zha_const
from homeassistant.util import slugify
-from tests.common import mock_coro
-
-
-class FakeApplication:
- """Fake application for mocking zigpy."""
-
- def __init__(self):
- """Init fake application."""
- self.ieee = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32")
- self.nwk = 0x087D
-
-
-APPLICATION = FakeApplication()
-
class FakeEndpoint:
"""Fake endpoint for moking zigpy."""
@@ -73,14 +54,16 @@ def patch_cluster(cluster):
cluster.read_attributes = CoroutineMock()
cluster.read_attributes_raw = Mock()
cluster.unbind = CoroutineMock(return_value=[0])
+ cluster.write_attributes = CoroutineMock(return_value=[0])
class FakeDevice:
"""Fake device for mocking zigpy."""
- def __init__(self, ieee, manufacturer, model):
+ def __init__(self, app, ieee, manufacturer, model, node_desc=None):
"""Init fake device."""
- self._application = APPLICATION
+ self._application = app
+ self.application = app
self.ieee = zigpy.types.EUI64.convert(ieee)
self.nwk = 0xB79C
self.zdo = Mock()
@@ -90,71 +73,23 @@ def __init__(self, ieee, manufacturer, model):
self.last_seen = time.time()
self.status = 2
self.initializing = False
+ self.skip_configuration = False
self.manufacturer = manufacturer
self.model = model
self.node_desc = zigpy.zdo.types.NodeDescriptor()
self.add_to_group = CoroutineMock()
self.remove_from_group = CoroutineMock()
+ if node_desc is None:
+ node_desc = b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00"
+ self.node_desc = zigpy.zdo.types.NodeDescriptor.deserialize(node_desc)[0]
-def make_device(endpoints, ieee, manufacturer, model):
- """Make a fake device using the specified cluster classes."""
- device = FakeDevice(ieee, manufacturer, model)
- for epid, ep in endpoints.items():
- endpoint = FakeEndpoint(manufacturer, model, epid)
- endpoint.device = device
- device.endpoints[epid] = endpoint
- endpoint.device_type = ep["device_type"]
- profile_id = ep.get("profile_id")
- if profile_id:
- endpoint.profile_id = profile_id
-
- for cluster_id in ep.get("in_clusters", []):
- endpoint.add_input_cluster(cluster_id)
-
- for cluster_id in ep.get("out_clusters", []):
- endpoint.add_output_cluster(cluster_id)
-
- return device
-
-
-async def async_init_zigpy_device(
- hass,
- in_cluster_ids,
- out_cluster_ids,
- device_type,
- gateway,
- ieee="00:0d:6f:00:0a:90:69:e7",
- manufacturer="FakeManufacturer",
- model="FakeModel",
- is_new_join=False,
-):
- """Create and initialize a device.
-
- This creates a fake device and adds it to the "network". It can be used to
- test existing device functionality and new device pairing functionality.
- The is_new_join parameter influences whether or not the device will go
- through cluster binding and zigbee cluster configure reporting. That only
- happens when the device is paired to the network for the first time.
- """
- device = make_device(
- {
- 1: {
- "in_clusters": in_cluster_ids,
- "out_clusters": out_cluster_ids,
- "device_type": device_type,
- }
- },
- ieee,
- manufacturer,
- model,
- )
- if is_new_join:
- await gateway.async_device_initialized(device)
- else:
- await gateway.async_device_restored(device)
- await hass.async_block_till_done()
- return device
+def get_zha_gateway(hass):
+ """Return ZHA gateway from hass.data."""
+ try:
+ return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
+ except KeyError:
+ return None
def make_attribute(attrid, value, status=0):
@@ -166,14 +101,6 @@ def make_attribute(attrid, value, status=0):
return attr
-async def async_setup_entry(hass, config_entry):
- """Mock setup entry for zha."""
- hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = {}
- hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = []
- hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = APPLICATION.ieee
- return True
-
-
async def find_entity_id(domain, zha_device, hass):
"""Find the entity id under the testing.
@@ -192,43 +119,13 @@ async def find_entity_id(domain, zha_device, hass):
return None
-async def async_enable_traffic(hass, zha_gateway, zha_devices):
+async def async_enable_traffic(hass, zha_devices):
"""Allow traffic to flow through the gateway and the zha device."""
for zha_device in zha_devices:
zha_device.update_available(True)
await hass.async_block_till_done()
-async def async_test_device_join(
- hass, zha_gateway, cluster_id, entity_id, device_type=None
-):
- """Test a newly joining device.
-
- This creates a new fake device and adds it to the network. It is meant to
- simulate pairing a new device to the network so that code pathways that
- only trigger during device joins can be tested.
- """
- # create zigpy device mocking out the zigbee network operations
- with patch(
- "zigpy.zcl.Cluster.configure_reporting",
- return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]),
- ):
- with patch(
- "zigpy.zcl.Cluster.bind",
- return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]),
- ):
- await async_init_zigpy_device(
- hass,
- [cluster_id, zigpy.zcl.clusters.general.Basic.cluster_id],
- [],
- device_type,
- zha_gateway,
- ieee="00:0d:6f:00:0a:90:69:f7",
- is_new_join=True,
- )
- assert hass.states.get(entity_id) is not None
-
-
def make_zcl_header(command_id: int, global_command: bool = True) -> zcl_f.ZCLHeader:
"""Cluster.handle_message() ZCL Header helper."""
if global_command:
@@ -236,3 +133,25 @@ def make_zcl_header(command_id: int, global_command: bool = True) -> zcl_f.ZCLHe
else:
frc = zcl_f.FrameControl(zcl_f.FrameType.CLUSTER_COMMAND)
return zcl_f.ZCLHeader(frc, tsn=1, command_id=command_id)
+
+
+def reset_clusters(clusters):
+ """Reset mocks on cluster."""
+ for cluster in clusters:
+ cluster.bind.reset_mock()
+ cluster.configure_reporting.reset_mock()
+ cluster.write_attributes.reset_mock()
+
+
+async def async_test_rejoin(hass, zigpy_device, clusters, report_counts, ep_id=1):
+ """Test device rejoins."""
+ reset_clusters(clusters)
+
+ zha_gateway = get_zha_gateway(hass)
+ await zha_gateway.async_device_initialized(zigpy_device)
+ await hass.async_block_till_done()
+ for cluster, reports in zip(clusters, report_counts):
+ assert cluster.bind.call_count == 1
+ assert cluster.bind.await_count == 1
+ assert cluster.configure_reporting.call_count == reports
+ assert cluster.configure_reporting.await_count == reports
diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py
index d8abfb8f227d69..e3a8f6bf4dce24 100644
--- a/tests/components/zha/conftest.py
+++ b/tests/components/zha/conftest.py
@@ -1,72 +1,202 @@
"""Test configuration for the ZHA component."""
from unittest import mock
-from unittest.mock import patch
+import asynctest
import pytest
import zigpy
from zigpy.application import ControllerApplication
+import zigpy.group
+import zigpy.types
-from homeassistant import config_entries
-from homeassistant.components.zha.core.const import COMPONENTS, DATA_ZHA, DOMAIN
-from homeassistant.components.zha.core.gateway import ZHAGateway
-from homeassistant.components.zha.core.store import async_get_registry
-from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg
+import homeassistant.components.zha.core.const as zha_const
+import homeassistant.components.zha.core.device as zha_core_device
+import homeassistant.components.zha.core.registries as zha_regs
+from homeassistant.setup import async_setup_component
-from .common import async_setup_entry
+from .common import FakeDevice, FakeEndpoint, get_zha_gateway
+
+from tests.common import MockConfigEntry
FIXTURE_GRP_ID = 0x1001
FIXTURE_GRP_NAME = "fixture group"
+@pytest.fixture
+def zigpy_app_controller():
+ """Zigpy ApplicationController fixture."""
+ app = mock.MagicMock(spec_set=ControllerApplication)
+ app.startup = asynctest.CoroutineMock()
+ app.shutdown = asynctest.CoroutineMock()
+ groups = zigpy.group.Groups(app)
+ groups.add_group(FIXTURE_GRP_ID, FIXTURE_GRP_NAME, suppress_event=True)
+ app.configure_mock(groups=groups)
+ type(app).ieee = mock.PropertyMock()
+ app.ieee.return_value = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32")
+ type(app).nwk = mock.PropertyMock(return_value=zigpy.types.NWK(0x0000))
+ type(app).devices = mock.PropertyMock(return_value={})
+ return app
+
+
+@pytest.fixture
+def zigpy_radio():
+ """Zigpy radio mock."""
+ radio = mock.MagicMock()
+ radio.connect = asynctest.CoroutineMock()
+ return radio
+
+
@pytest.fixture(name="config_entry")
-def config_entry_fixture(hass):
+async def config_entry_fixture(hass):
"""Fixture representing a config entry."""
- config_entry = config_entries.ConfigEntry(
- 1,
- DOMAIN,
- "Mock Title",
- {},
- "test",
- config_entries.CONN_CLASS_LOCAL_PUSH,
- system_options={},
+ entry = MockConfigEntry(
+ version=1,
+ domain=zha_const.DOMAIN,
+ data={
+ zha_const.CONF_BAUDRATE: zha_const.DEFAULT_BAUDRATE,
+ zha_const.CONF_RADIO_TYPE: "MockRadio",
+ zha_const.CONF_USB_PATH: "/dev/ttyUSB0",
+ },
)
- return config_entry
-
-
-@pytest.fixture(name="zha_gateway")
-async def zha_gateway_fixture(hass, config_entry):
- """Fixture representing a zha gateway.
-
- Create a ZHAGateway object that can be used to interact with as if we
- had a real zigbee network running.
- """
- for component in COMPONENTS:
- hass.data[DATA_ZHA][component] = hass.data[DATA_ZHA].get(component, {})
- zha_storage = await async_get_registry(hass)
- dev_reg = await get_dev_reg(hass)
- gateway = ZHAGateway(hass, {}, config_entry)
- gateway.zha_storage = zha_storage
- gateway.ha_device_registry = dev_reg
- gateway.application_controller = mock.MagicMock(spec_set=ControllerApplication)
- groups = zigpy.group.Groups(gateway.application_controller)
- groups.listener_event = mock.MagicMock()
- groups.add_group(FIXTURE_GRP_ID, FIXTURE_GRP_NAME, suppress_event=True)
- gateway.application_controller.groups = groups
- return gateway
+ entry.add_to_hass(hass)
+ return entry
+
+
+@pytest.fixture
+def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio):
+ """Set up ZHA component."""
+ zha_config = {zha_const.CONF_ENABLE_QUIRKS: False}
+
+ radio_details = {
+ zha_const.ZHA_GW_RADIO: mock.MagicMock(return_value=zigpy_radio),
+ zha_const.CONTROLLER: mock.MagicMock(return_value=zigpy_app_controller),
+ zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio",
+ }
+
+ async def _setup(config=None):
+ config = config or {}
+ with mock.patch.dict(zha_regs.RADIO_TYPES, {"MockRadio": radio_details}):
+ status = await async_setup_component(
+ hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}}
+ )
+ assert status is True
+ await hass.async_block_till_done()
+
+ return _setup
+
+
+@pytest.fixture
+def channel():
+ """Channel mock factory fixture."""
+
+ def channel(name: str, cluster_id: int, endpoint_id: int = 1):
+ ch = mock.MagicMock()
+ ch.name = name
+ ch.generic_id = f"channel_0x{cluster_id:04x}"
+ ch.id = f"{endpoint_id}:0x{cluster_id:04x}"
+ ch.async_configure = asynctest.CoroutineMock()
+ ch.async_initialize = asynctest.CoroutineMock()
+ return ch
+
+ return channel
+
+@pytest.fixture
+def zigpy_device_mock(zigpy_app_controller):
+ """Make a fake device using the specified cluster classes."""
-@pytest.fixture(autouse=True)
-async def setup_zha(hass, config_entry):
- """Load the ZHA component.
+ def _mock_dev(
+ endpoints,
+ ieee="00:0d:6f:00:0a:90:69:e7",
+ manufacturer="FakeManufacturer",
+ model="FakeModel",
+ node_descriptor=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ ):
+ """Make a fake device using the specified cluster classes."""
+ device = FakeDevice(
+ zigpy_app_controller, ieee, manufacturer, model, node_descriptor
+ )
+ for epid, ep in endpoints.items():
+ endpoint = FakeEndpoint(manufacturer, model, epid)
+ endpoint.device = device
+ device.endpoints[epid] = endpoint
+ endpoint.device_type = ep["device_type"]
+ profile_id = ep.get("profile_id")
+ if profile_id:
+ endpoint.profile_id = profile_id
- This will init the ZHA component. It loads the component in HA so that
- we can test the domains that ZHA supports without actually having a zigbee
- network running.
- """
- # this prevents needing an actual radio and zigbee network available
- with patch("homeassistant.components.zha.async_setup_entry", async_setup_entry):
- hass.data[DATA_ZHA] = {}
+ for cluster_id in ep.get("in_clusters", []):
+ endpoint.add_input_cluster(cluster_id)
- # init ZHA
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
+ for cluster_id in ep.get("out_clusters", []):
+ endpoint.add_output_cluster(cluster_id)
+
+ return device
+
+ return _mock_dev
+
+
+@pytest.fixture
+def zha_device_joined(hass, setup_zha):
+ """Return a newly joined ZHA device."""
+
+ async def _zha_device(zigpy_dev):
+ await setup_zha()
+ zha_gateway = get_zha_gateway(hass)
+ await zha_gateway.async_device_initialized(zigpy_dev)
await hass.async_block_till_done()
+ return zha_gateway.get_device(zigpy_dev.ieee)
+
+ return _zha_device
+
+
+@pytest.fixture
+def zha_device_restored(hass, zigpy_app_controller, setup_zha):
+ """Return a restored ZHA device."""
+
+ async def _zha_device(zigpy_dev):
+ zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev
+ await setup_zha()
+ zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
+ await zha_gateway.async_load_devices()
+ return zha_gateway.get_device(zigpy_dev.ieee)
+
+ return _zha_device
+
+
+@pytest.fixture(params=["zha_device_joined", "zha_device_restored"])
+def zha_device_joined_restored(request):
+ """Join or restore ZHA device."""
+ return request.getfixturevalue(request.param)
+
+
+@pytest.fixture
+def zha_device_mock(hass, zigpy_device_mock):
+ """Return a zha Device factory."""
+
+ def _zha_device(
+ endpoints=None,
+ ieee="00:11:22:33:44:55:66:77",
+ manufacturer="mock manufacturer",
+ model="mock model",
+ node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ ):
+ if endpoints is None:
+ endpoints = {
+ 1: {
+ "in_clusters": [0, 1, 8, 768],
+ "out_clusters": [0x19],
+ "device_type": 0x0105,
+ },
+ 2: {
+ "in_clusters": [0],
+ "out_clusters": [6, 8, 0x19, 768],
+ "device_type": 0x0810,
+ },
+ }
+ zigpy_device = zigpy_device_mock(
+ endpoints, ieee, manufacturer, model, node_desc
+ )
+ zha_device = zha_core_device.ZHADevice(hass, zigpy_device, mock.MagicMock())
+ return zha_device
+
+ return _zha_device
diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py
index f01d27eb1670c4..b67a39cd3aba28 100644
--- a/tests/components/zha/test_api.py
+++ b/tests/components/zha/test_api.py
@@ -1,11 +1,9 @@
"""Test ZHA API."""
import pytest
-import zigpy
+import zigpy.profiles.zha
import zigpy.zcl.clusters.general as general
-from homeassistant.components.light import DOMAIN as light_domain
-from homeassistant.components.switch import DOMAIN
from homeassistant.components.websocket_api import const
from homeassistant.components.zha.api import ID, TYPE, async_load_api
from homeassistant.components.zha.core.const import (
@@ -23,50 +21,67 @@
GROUP_NAME,
)
-from .common import async_init_zigpy_device
from .conftest import FIXTURE_GRP_ID, FIXTURE_GRP_NAME
+IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7"
+IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
+
@pytest.fixture
-async def zha_client(hass, config_entry, zha_gateway, hass_ws_client):
+async def device_switch(hass, zigpy_device_mock, zha_device_joined):
"""Test zha switch platform."""
- # load the ZHA API
- async_load_api(hass)
-
- # create zigpy device
- await async_init_zigpy_device(
- hass,
- [general.OnOff.cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.OnOff.cluster_id, general.Basic.cluster_id],
+ "out_clusters": [],
+ "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
+ }
+ },
+ ieee=IEEE_SWITCH_DEVICE,
)
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_groupable(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
- await async_init_zigpy_device(
- hass,
- [general.OnOff.cluster_id, general.Basic.cluster_id, general.Groups.cluster_id],
- [],
- zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
- zha_gateway,
- manufacturer="FakeGroupManufacturer",
- model="FakeGroupModel",
- ieee="01:2d:6f:00:0a:90:69:e8",
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ general.Basic.cluster_id,
+ general.Groups.cluster_id,
+ ],
+ "out_clusters": [],
+ "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE,
)
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
- # load up switch domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
- await hass.config_entries.async_forward_entry_setup(config_entry, light_domain)
- await hass.async_block_till_done()
+@pytest.fixture
+async def zha_client(hass, hass_ws_client, device_switch, device_groupable):
+ """Test zha switch platform."""
+ # load the ZHA API
+ async_load_api(hass)
return await hass_ws_client(hass)
-async def test_device_clusters(hass, config_entry, zha_gateway, zha_client):
+async def test_device_clusters(hass, zha_client):
"""Test getting device cluster info."""
await zha_client.send_json(
- {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: "00:0d:6f:00:0a:90:69:e7"}
+ {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE}
)
msg = await zha_client.receive_json()
@@ -86,14 +101,14 @@ async def test_device_clusters(hass, config_entry, zha_gateway, zha_client):
assert cluster_info[ATTR_NAME] == "OnOff"
-async def test_device_cluster_attributes(hass, config_entry, zha_gateway, zha_client):
+async def test_device_cluster_attributes(zha_client):
"""Test getting device cluster attributes."""
await zha_client.send_json(
{
ID: 5,
TYPE: "zha/devices/clusters/attributes",
ATTR_ENDPOINT_ID: 1,
- ATTR_IEEE: "00:0d:6f:00:0a:90:69:e7",
+ ATTR_IEEE: IEEE_SWITCH_DEVICE,
ATTR_CLUSTER_ID: 6,
ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN,
}
@@ -109,14 +124,14 @@ async def test_device_cluster_attributes(hass, config_entry, zha_gateway, zha_cl
assert attribute[ATTR_NAME] is not None
-async def test_device_cluster_commands(hass, config_entry, zha_gateway, zha_client):
+async def test_device_cluster_commands(zha_client):
"""Test getting device cluster commands."""
await zha_client.send_json(
{
ID: 5,
TYPE: "zha/devices/clusters/commands",
ATTR_ENDPOINT_ID: 1,
- ATTR_IEEE: "00:0d:6f:00:0a:90:69:e7",
+ ATTR_IEEE: IEEE_SWITCH_DEVICE,
ATTR_CLUSTER_ID: 6,
ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN,
}
@@ -133,7 +148,7 @@ async def test_device_cluster_commands(hass, config_entry, zha_gateway, zha_clie
assert command[TYPE] is not None
-async def test_list_devices(hass, config_entry, zha_gateway, zha_client):
+async def test_list_devices(zha_client):
"""Test getting zha devices."""
await zha_client.send_json({ID: 5, TYPE: "zha/devices"})
@@ -164,7 +179,7 @@ async def test_list_devices(hass, config_entry, zha_gateway, zha_client):
assert device == device2
-async def test_device_not_found(hass, config_entry, zha_gateway, zha_client):
+async def test_device_not_found(zha_client):
"""Test not found response from get device API."""
await zha_client.send_json(
{ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"}
@@ -176,7 +191,7 @@ async def test_device_not_found(hass, config_entry, zha_gateway, zha_client):
assert msg["error"]["code"] == const.ERR_NOT_FOUND
-async def test_list_groups(hass, config_entry, zha_gateway, zha_client):
+async def test_list_groups(zha_client):
"""Test getting zha zigbee groups."""
await zha_client.send_json({ID: 7, TYPE: "zha/groups"})
@@ -193,7 +208,7 @@ async def test_list_groups(hass, config_entry, zha_gateway, zha_client):
assert group["members"] == []
-async def test_get_group(hass, config_entry, zha_gateway, zha_client):
+async def test_get_group(zha_client):
"""Test getting a specific zha zigbee group."""
await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID})
@@ -208,7 +223,7 @@ async def test_get_group(hass, config_entry, zha_gateway, zha_client):
assert group["members"] == []
-async def test_get_group_not_found(hass, config_entry, zha_gateway, zha_client):
+async def test_get_group_not_found(zha_client):
"""Test not found response from get group API."""
await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1234567})
@@ -220,14 +235,9 @@ async def test_get_group_not_found(hass, config_entry, zha_gateway, zha_client):
assert msg["error"]["code"] == const.ERR_NOT_FOUND
-async def test_list_groupable_devices(hass, config_entry, zha_gateway, zha_client):
+async def test_list_groupable_devices(zha_client, device_groupable):
"""Test getting zha devices that have a group cluster."""
- # Make device available
- zha_gateway.devices[
- zigpy.types.EUI64.convert("01:2d:6f:00:0a:90:69:e8")
- ].set_available(True)
-
await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"})
msg = await zha_client.receive_json()
@@ -251,9 +261,7 @@ async def test_list_groupable_devices(hass, config_entry, zha_gateway, zha_clien
# Make sure there are no groupable devices when the device is unavailable
# Make device unavailable
- zha_gateway.devices[
- zigpy.types.EUI64.convert("01:2d:6f:00:0a:90:69:e8")
- ].set_available(False)
+ device_groupable.set_available(False)
await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"})
@@ -265,7 +273,7 @@ async def test_list_groupable_devices(hass, config_entry, zha_gateway, zha_clien
assert len(devices) == 0
-async def test_add_group(hass, config_entry, zha_gateway, zha_client):
+async def test_add_group(zha_client):
"""Test adding and getting a new zha zigbee group."""
await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"})
@@ -291,7 +299,7 @@ async def test_add_group(hass, config_entry, zha_gateway, zha_client):
assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group"
-async def test_remove_group(hass, config_entry, zha_gateway, zha_client):
+async def test_remove_group(zha_client):
"""Test removing a new zha zigbee group."""
await zha_client.send_json({ID: 14, TYPE: "zha/groups"})
diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py
index 2765a465aced7d..a22bfa54daec77 100644
--- a/tests/components/zha/test_binary_sensor.py
+++ b/tests/components/zha/test_binary_sensor.py
@@ -1,5 +1,5 @@
"""Test zha binary sensor."""
-import zigpy.zcl.clusters.general as general
+import pytest
import zigpy.zcl.clusters.measurement as measurement
import zigpy.zcl.clusters.security as security
import zigpy.zcl.foundation as zcl_f
@@ -9,76 +9,28 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
)
+DEVICE_IAS = {
+ 1: {
+ "device_type": 1026,
+ "in_clusters": [security.IasZone.cluster_id],
+ "out_clusters": [],
+ }
+}
-async def test_binary_sensor(hass, config_entry, zha_gateway):
- """Test zha binary_sensor platform."""
-
- # create zigpy devices
- zigpy_device_zone = await async_init_zigpy_device(
- hass,
- [security.IasZone.cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
- ieee="00:0d:6f:11:9a:90:69:e6",
- )
-
- zigpy_device_occupancy = await async_init_zigpy_device(
- hass,
- [measurement.OccupancySensing.cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
- ieee="00:0d:6f:11:9a:90:69:e7",
- manufacturer="FakeOccupancy",
- model="FakeOccupancyModel",
- )
-
- # load up binary_sensor domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
-
- # on off binary_sensor
- zone_cluster = zigpy_device_zone.endpoints.get(1).ias_zone
- zone_zha_device = zha_gateway.get_device(zigpy_device_zone.ieee)
- zone_entity_id = await find_entity_id(DOMAIN, zone_zha_device, hass)
- assert zone_entity_id is not None
-
- # occupancy binary_sensor
- occupancy_cluster = zigpy_device_occupancy.endpoints.get(1).occupancy
- occupancy_zha_device = zha_gateway.get_device(zigpy_device_occupancy.ieee)
- occupancy_entity_id = await find_entity_id(DOMAIN, occupancy_zha_device, hass)
- assert occupancy_entity_id is not None
-
- # test that the sensors exist and are in the unavailable state
- assert hass.states.get(zone_entity_id).state == STATE_UNAVAILABLE
- assert hass.states.get(occupancy_entity_id).state == STATE_UNAVAILABLE
-
- await async_enable_traffic(
- hass, zha_gateway, [zone_zha_device, occupancy_zha_device]
- )
- # test that the sensors exist and are in the off state
- assert hass.states.get(zone_entity_id).state == STATE_OFF
- assert hass.states.get(occupancy_entity_id).state == STATE_OFF
-
- # test getting messages that trigger and reset the sensors
- await async_test_binary_sensor_on_off(hass, occupancy_cluster, occupancy_entity_id)
-
- # test IASZone binary sensors
- await async_test_iaszone_on_off(hass, zone_cluster, zone_entity_id)
-
- # test new sensor join
- await async_test_device_join(
- hass, zha_gateway, measurement.OccupancySensing.cluster_id, occupancy_entity_id
- )
+DEVICE_OCCUPANCY = {
+ 1: {
+ "device_type": 263,
+ "in_clusters": [measurement.OccupancySensing.cluster_id],
+ "out_clusters": [],
+ }
+}
async def async_test_binary_sensor_on_off(hass, cluster, entity_id):
@@ -109,3 +61,43 @@ async def async_test_iaszone_on_off(hass, cluster, entity_id):
cluster.listener_event("cluster_command", 1, 0, [0])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
+
+
+@pytest.mark.parametrize(
+ "device, on_off_test, cluster_name, reporting",
+ [
+ (DEVICE_IAS, async_test_iaszone_on_off, "ias_zone", (0,)),
+ (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)),
+ ],
+)
+async def test_binary_sensor(
+ hass,
+ zigpy_device_mock,
+ zha_device_joined_restored,
+ device,
+ on_off_test,
+ cluster_name,
+ reporting,
+):
+ """Test ZHA binary_sensor platform."""
+ zigpy_device = zigpy_device_mock(device)
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ entity_id = await find_entity_id(DOMAIN, zha_device, hass)
+
+ assert entity_id is not None
+
+ # test that the sensors exist and are in the unavailable state
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ await async_enable_traffic(hass, [zha_device])
+
+ # test that the sensors exist and are in the off state
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # test getting messages that trigger and reset the sensors
+ cluster = getattr(zigpy_device.endpoints[1], cluster_name)
+ await on_off_test(hass, cluster, entity_id)
+
+ # test rejoin
+ await async_test_rejoin(hass, zigpy_device, [cluster], reporting)
+ assert hass.states.get(entity_id).state == STATE_OFF
diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py
index 557cc0f2c5cdce..3f38108cf892de 100644
--- a/tests/components/zha/test_channels.py
+++ b/tests/components/zha/test_channels.py
@@ -1,12 +1,17 @@
"""Test ZHA Core channels."""
+import asyncio
+from unittest import mock
+
+import asynctest
import pytest
import zigpy.types as t
-import homeassistant.components.zha.core.channels as channels
-import homeassistant.components.zha.core.device as zha_device
+import homeassistant.components.zha.core.channels as zha_channels
+import homeassistant.components.zha.core.channels.base as base_channels
+import homeassistant.components.zha.core.const as zha_const
import homeassistant.components.zha.core.registries as registries
-from .common import make_device
+from .common import get_zha_gateway
@pytest.fixture
@@ -21,6 +26,22 @@ def nwk():
return t.NWK(0xBEEF)
+@pytest.fixture
+async def zha_gateway(hass, setup_zha):
+ """Return ZhaGateway fixture."""
+ await setup_zha()
+ return get_zha_gateway(hass)
+
+
+@pytest.fixture
+def channel_pool():
+ """Endpoint Channels fixture."""
+ ch_pool_mock = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ type(ch_pool_mock).skip_configuration = mock.PropertyMock(return_value=False)
+ ch_pool_mock.id = 1
+ return ch_pool_mock
+
+
@pytest.mark.parametrize(
"cluster_id, bind_count, attrs",
[
@@ -64,21 +85,22 @@ def nwk():
(0x1000, 1, {}),
],
)
-async def test_in_channel_config(cluster_id, bind_count, attrs, zha_gateway, hass):
+async def test_in_channel_config(
+ cluster_id, bind_count, attrs, channel_pool, zigpy_device_mock, zha_gateway
+):
"""Test ZHA core channel configuration for input clusters."""
- zigpy_dev = make_device(
+ zigpy_dev = zigpy_device_mock(
{1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}},
"00:11:22:33:44:55:66:77",
"test manufacturer",
"test model",
)
- zha_dev = zha_device.ZHADevice(hass, zigpy_dev, zha_gateway)
cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id]
channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(
- cluster_id, channels.AttributeListeningChannel
+ cluster_id, base_channels.AttributeListeningChannel
)
- channel = channel_class(cluster, zha_dev)
+ channel = channel_class(cluster, channel_pool)
await channel.async_configure()
@@ -120,22 +142,23 @@ async def test_in_channel_config(cluster_id, bind_count, attrs, zha_gateway, has
(0x1000, 1),
],
)
-async def test_out_channel_config(cluster_id, bind_count, zha_gateway, hass):
+async def test_out_channel_config(
+ cluster_id, bind_count, channel_pool, zigpy_device_mock, zha_gateway
+):
"""Test ZHA core channel configuration for output clusters."""
- zigpy_dev = make_device(
+ zigpy_dev = zigpy_device_mock(
{1: {"out_clusters": [cluster_id], "in_clusters": [], "device_type": 0x1234}},
"00:11:22:33:44:55:66:77",
"test manufacturer",
"test model",
)
- zha_dev = zha_device.ZHADevice(hass, zigpy_dev, zha_gateway)
cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id]
cluster.bind_only = True
channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(
- cluster_id, channels.AttributeListeningChannel
+ cluster_id, base_channels.AttributeListeningChannel
)
- channel = channel_class(cluster, zha_dev)
+ channel = channel_class(cluster, channel_pool)
await channel.async_configure()
@@ -148,4 +171,203 @@ def test_channel_registry():
for (cluster_id, channel) in registries.ZIGBEE_CHANNEL_REGISTRY.items():
assert isinstance(cluster_id, int)
assert 0 <= cluster_id <= 0xFFFF
- assert issubclass(channel, channels.ZigbeeChannel)
+ assert issubclass(channel, base_channels.ZigbeeChannel)
+
+
+def test_epch_unclaimed_channels(channel):
+ """Test unclaimed channels."""
+
+ ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_2 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_3 = channel(zha_const.CHANNEL_COLOR, 768)
+
+ ep_channels = zha_channels.ChannelPool(
+ mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep
+ )
+ all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3}
+ with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True):
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 in available
+ assert ch_2 in available
+ assert ch_3 in available
+
+ ep_channels.claimed_channels[ch_2.id] = ch_2
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 in available
+ assert ch_2 not in available
+ assert ch_3 in available
+
+ ep_channels.claimed_channels[ch_1.id] = ch_1
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 not in available
+ assert ch_2 not in available
+ assert ch_3 in available
+
+ ep_channels.claimed_channels[ch_3.id] = ch_3
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 not in available
+ assert ch_2 not in available
+ assert ch_3 not in available
+
+
+def test_epch_claim_channels(channel):
+ """Test channel claiming."""
+
+ ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_2 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_3 = channel(zha_const.CHANNEL_COLOR, 768)
+
+ ep_channels = zha_channels.ChannelPool(
+ mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep
+ )
+ all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3}
+ with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True):
+ assert ch_1.id not in ep_channels.claimed_channels
+ assert ch_2.id not in ep_channels.claimed_channels
+ assert ch_3.id not in ep_channels.claimed_channels
+
+ ep_channels.claim_channels([ch_2])
+ assert ch_1.id not in ep_channels.claimed_channels
+ assert ch_2.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_2.id] is ch_2
+ assert ch_3.id not in ep_channels.claimed_channels
+
+ ep_channels.claim_channels([ch_3, ch_1])
+ assert ch_1.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_1.id] is ch_1
+ assert ch_2.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_2.id] is ch_2
+ assert ch_3.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_3.id] is ch_3
+ assert "1:0x0300" in ep_channels.claimed_channels
+
+
+@mock.patch("homeassistant.components.zha.core.channels.ChannelPool.add_relay_channels")
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.PROBE.discover_entities",
+ mock.MagicMock(),
+)
+def test_ep_channels_all_channels(m1, zha_device_mock):
+ """Test EndpointChannels adding all channels."""
+ zha_device = zha_device_mock(
+ {
+ 1: {"in_clusters": [0, 1, 6, 8], "out_clusters": [], "device_type": 0x0000},
+ 2: {
+ "in_clusters": [0, 1, 6, 8, 768],
+ "out_clusters": [],
+ "device_type": 0x0000,
+ },
+ }
+ )
+ channels = zha_channels.Channels(zha_device)
+
+ ep_channels = zha_channels.ChannelPool.new(channels, 1)
+ assert "1:0x0000" in ep_channels.all_channels
+ assert "1:0x0001" in ep_channels.all_channels
+ assert "1:0x0006" in ep_channels.all_channels
+ assert "1:0x0008" in ep_channels.all_channels
+ assert "1:0x0300" not in ep_channels.all_channels
+ assert "2:0x0000" not in ep_channels.all_channels
+ assert "2:0x0001" not in ep_channels.all_channels
+ assert "2:0x0006" not in ep_channels.all_channels
+ assert "2:0x0008" not in ep_channels.all_channels
+ assert "2:0x0300" not in ep_channels.all_channels
+
+ channels = zha_channels.Channels(zha_device)
+ ep_channels = zha_channels.ChannelPool.new(channels, 2)
+ assert "1:0x0000" not in ep_channels.all_channels
+ assert "1:0x0001" not in ep_channels.all_channels
+ assert "1:0x0006" not in ep_channels.all_channels
+ assert "1:0x0008" not in ep_channels.all_channels
+ assert "1:0x0300" not in ep_channels.all_channels
+ assert "2:0x0000" in ep_channels.all_channels
+ assert "2:0x0001" in ep_channels.all_channels
+ assert "2:0x0006" in ep_channels.all_channels
+ assert "2:0x0008" in ep_channels.all_channels
+ assert "2:0x0300" in ep_channels.all_channels
+
+
+@mock.patch("homeassistant.components.zha.core.channels.ChannelPool.add_relay_channels")
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.PROBE.discover_entities",
+ mock.MagicMock(),
+)
+def test_channel_power_config(m1, zha_device_mock):
+ """Test that channels only get a single power channel."""
+ in_clusters = [0, 1, 6, 8]
+ zha_device = zha_device_mock(
+ {
+ 1: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000},
+ 2: {
+ "in_clusters": [*in_clusters, 768],
+ "out_clusters": [],
+ "device_type": 0x0000,
+ },
+ }
+ )
+ channels = zha_channels.Channels.new(zha_device)
+ pools = {pool.id: pool for pool in channels.pools}
+ assert "1:0x0000" in pools[1].all_channels
+ assert "1:0x0001" in pools[1].all_channels
+ assert "1:0x0006" in pools[1].all_channels
+ assert "1:0x0008" in pools[1].all_channels
+ assert "1:0x0300" not in pools[1].all_channels
+ assert "2:0x0000" in pools[2].all_channels
+ assert "2:0x0001" not in pools[2].all_channels
+ assert "2:0x0006" in pools[2].all_channels
+ assert "2:0x0008" in pools[2].all_channels
+ assert "2:0x0300" in pools[2].all_channels
+
+ zha_device = zha_device_mock(
+ {
+ 1: {"in_clusters": [], "out_clusters": [], "device_type": 0x0000},
+ 2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000},
+ }
+ )
+ channels = zha_channels.Channels.new(zha_device)
+ pools = {pool.id: pool for pool in channels.pools}
+ assert "1:0x0001" not in pools[1].all_channels
+ assert "2:0x0001" in pools[2].all_channels
+
+ zha_device = zha_device_mock(
+ {2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}}
+ )
+ channels = zha_channels.Channels.new(zha_device)
+ pools = {pool.id: pool for pool in channels.pools}
+ assert "2:0x0001" in pools[2].all_channels
+
+
+async def test_ep_channels_configure(channel):
+ """Test unclaimed channels."""
+
+ ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_2 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_3 = channel(zha_const.CHANNEL_COLOR, 768)
+ ch_3.async_configure = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+ ch_3.async_initialize = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+ ch_4 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_5 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_5.async_configure = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+ ch_5.async_initialize = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+
+ channels = mock.MagicMock(spec_set=zha_channels.Channels)
+ type(channels).semaphore = mock.PropertyMock(return_value=asyncio.Semaphore(3))
+ ep_channels = zha_channels.ChannelPool(channels, mock.sentinel.ep)
+
+ claimed = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3}
+ relay = {ch_4.id: ch_4, ch_5.id: ch_5}
+
+ with mock.patch.dict(ep_channels.claimed_channels, claimed, clear=True):
+ with mock.patch.dict(ep_channels.relay_channels, relay, clear=True):
+ await ep_channels.async_configure()
+ await ep_channels.async_initialize(mock.sentinel.from_cache)
+
+ for ch in [*claimed.values(), *relay.values()]:
+ assert ch.async_initialize.call_count == 1
+ assert ch.async_initialize.await_count == 1
+ assert ch.async_initialize.call_args[0][0] is mock.sentinel.from_cache
+ assert ch.async_configure.call_count == 1
+ assert ch.async_configure.await_count == 1
+
+ assert ch_3.warning.call_count == 2
+ assert ch_5.warning.call_count == 2
diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py
index 9d1c019c7183c6..4fbabf4485ad82 100644
--- a/tests/components/zha/test_cover.py
+++ b/tests/components/zha/test_cover.py
@@ -1,9 +1,10 @@
"""Test zha cover."""
from unittest.mock import MagicMock, call, patch
+import asynctest
+import pytest
import zigpy.types
import zigpy.zcl.clusters.closures as closures
-import zigpy.zcl.clusters.general as general
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.cover import DOMAIN
@@ -11,8 +12,7 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
@@ -21,33 +21,39 @@
from tests.common import mock_coro
-async def test_cover(hass, config_entry, zha_gateway):
- """Test zha cover platform."""
+@pytest.fixture
+def zigpy_cover_device(zigpy_device_mock):
+ """Zigpy cover device."""
+
+ endpoints = {
+ 1: {
+ "device_type": 1026,
+ "in_clusters": [closures.WindowCovering.cluster_id],
+ "out_clusters": [],
+ }
+ }
+ return zigpy_device_mock(endpoints)
+
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass,
- [closures.WindowCovering.cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
- )
+@asynctest.patch(
+ "homeassistant.components.zha.core.channels.closures.WindowCovering.async_initialize"
+)
+async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
+ """Test zha cover platform."""
async def get_chan_attr(*args, **kwargs):
return 100
with patch(
- "homeassistant.components.zha.core.channels.ZigbeeChannel.get_attribute_value",
+ "homeassistant.components.zha.core.channels.base.ZigbeeChannel.get_attribute_value",
new=MagicMock(side_effect=get_chan_attr),
) as get_attr_mock:
# load up cover domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
+ zha_device = await zha_device_joined_restored(zigpy_cover_device)
assert get_attr_mock.call_count == 2
assert get_attr_mock.call_args[0][0] == "current_position_lift_percentage"
- cluster = zigpy_device.endpoints.get(1).window_covering
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
+ cluster = zigpy_cover_device.endpoints.get(1).window_covering
entity_id = await find_entity_id(DOMAIN, zha_device, hass)
assert entity_id is not None
@@ -55,7 +61,7 @@ async def get_chan_attr(*args, **kwargs):
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
attr = make_attribute(8, 100)
@@ -124,6 +130,6 @@ async def get_chan_attr(*args, **kwargs):
False, 0x2, (), expect_reply=True, manufacturer=None
)
- await async_test_device_join(
- hass, zha_gateway, closures.WindowCovering.cluster_id, entity_id
- )
+ # test rejoin
+ await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,))
+ assert hass.states.get(entity_id).state == STATE_OPEN
diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py
index 62884fe72ae94a..c779dda6cf88bf 100644
--- a/tests/components/zha/test_device_action.py
+++ b/tests/components/zha/test_device_action.py
@@ -11,12 +11,9 @@
_async_get_device_automations as async_get_device_automations,
)
from homeassistant.components.zha import DOMAIN
-from homeassistant.components.zha.core.const import CHANNEL_ON_OFF
from homeassistant.helpers.device_registry import async_get_registry
from homeassistant.setup import async_setup_component
-from .common import async_enable_traffic, async_init_zigpy_device
-
from tests.common import async_mock_service, mock_coro
SHORT_PRESS = "remote_button_short_press"
@@ -25,33 +22,30 @@
@pytest.fixture
-def calls(hass):
- """Track calls to a mock serivce."""
- return async_mock_service(hass, "zha", "warning_device_warn")
-
-
-async def test_get_actions(hass, config_entry, zha_gateway):
- """Test we get the expected actions from a zha device."""
-
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass,
- [
- general.Basic.cluster_id,
- security.IasZone.cluster_id,
- security.IasWd.cluster_id,
- ],
- [],
- None,
- zha_gateway,
+async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored):
+ """IAS device fixture."""
+
+ clusters = [general.Basic, security.IasZone, security.IasWd]
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [c.cluster_id for c in clusters],
+ "out_clusters": [general.OnOff.cluster_id],
+ "device_type": 0,
+ }
+ },
)
- await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor")
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ zha_device.update_available(True)
await hass.async_block_till_done()
- hass.config_entries._entries.append(config_entry)
+ return zigpy_device, zha_device
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
- ieee_address = str(zha_device.ieee)
+
+async def test_get_actions(hass, device_ias):
+ """Test we get the expected actions from a zha device."""
+
+ ieee_address = str(device_ias[0].ieee)
ha_device_registry = await async_get_registry(hass)
reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}, set())
@@ -66,40 +60,19 @@ async def test_get_actions(hass, config_entry, zha_gateway):
assert actions == expected_actions
-async def test_action(hass, config_entry, zha_gateway, calls):
+async def test_action(hass, device_ias):
"""Test for executing a zha device action."""
-
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass,
- [
- general.Basic.cluster_id,
- security.IasZone.cluster_id,
- security.IasWd.cluster_id,
- ],
- [general.OnOff.cluster_id],
- None,
- zha_gateway,
- )
+ zigpy_device, zha_device = device_ias
zigpy_device.device_automation_triggers = {
(SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}
}
- await hass.config_entries.async_forward_entry_setup(config_entry, "switch")
- await hass.async_block_till_done()
-
- hass.config_entries._entries.append(config_entry)
-
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
ieee_address = str(zha_device.ieee)
ha_device_registry = await async_get_registry(hass)
reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}, set())
- # allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
-
with patch(
"zigpy.zcl.Cluster.request",
return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]),
@@ -128,9 +101,10 @@ async def test_action(hass, config_entry, zha_gateway, calls):
)
await hass.async_block_till_done()
+ calls = async_mock_service(hass, DOMAIN, "warning_device_warn")
- on_off_channel = zha_device.cluster_channels[CHANNEL_ON_OFF]
- on_off_channel.zha_send_event(on_off_channel.cluster, COMMAND_SINGLE, [])
+ channel = zha_device.channels.pools[0].relay_channels["1:0x0006"]
+ channel.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
assert len(calls) == 1
diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py
index bac338ae5e0e37..3782cdc09a7efd 100644
--- a/tests/components/zha/test_device_tracker.py
+++ b/tests/components/zha/test_device_tracker.py
@@ -2,6 +2,7 @@
from datetime import timedelta
import time
+import pytest
import zigpy.zcl.clusters.general as general
import zigpy.zcl.foundation as zcl_f
@@ -14,8 +15,7 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
@@ -24,43 +24,43 @@
from tests.common import async_fire_time_changed
-async def test_device_tracker(hass, config_entry, zha_gateway):
+@pytest.fixture
+def zigpy_device_dt(zigpy_device_mock):
+ """Device tracker zigpy device."""
+ endpoints = {
+ 1: {
+ "in_clusters": [
+ general.Basic.cluster_id,
+ general.PowerConfiguration.cluster_id,
+ general.Identify.cluster_id,
+ general.PollControl.cluster_id,
+ general.BinaryInput.cluster_id,
+ ],
+ "out_clusters": [general.Identify.cluster_id, general.Ota.cluster_id],
+ "device_type": SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
+ }
+ }
+ return zigpy_device_mock(endpoints)
+
+
+async def test_device_tracker(hass, zha_device_joined_restored, zigpy_device_dt):
"""Test zha device tracker platform."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass,
- [
- general.Basic.cluster_id,
- general.PowerConfiguration.cluster_id,
- general.Identify.cluster_id,
- general.PollControl.cluster_id,
- general.BinaryInput.cluster_id,
- ],
- [general.Identify.cluster_id, general.Ota.cluster_id],
- SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
- zha_gateway,
- )
-
- # load up device tracker domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
-
- cluster = zigpy_device.endpoints.get(1).power
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
+ zha_device = await zha_device_joined_restored(zigpy_device_dt)
+ cluster = zigpy_device_dt.endpoints.get(1).power
entity_id = await find_entity_id(DOMAIN, zha_device, hass)
assert entity_id is not None
# test that the device tracker was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
- zigpy_device.last_seen = time.time() - 120
+ zigpy_device_dt.last_seen = time.time() - 120
next_update = dt_util.utcnow() + timedelta(seconds=30)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to not home
assert hass.states.get(entity_id).state == STATE_NOT_HOME
@@ -73,7 +73,7 @@ async def test_device_tracker(hass, config_entry, zha_gateway):
attr = make_attribute(0x0021, 200)
cluster.handle_message(hdr, [[attr]])
- zigpy_device.last_seen = time.time() + 10
+ zigpy_device_dt.last_seen = time.time() + 10
next_update = dt_util.utcnow() + timedelta(seconds=30)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
@@ -87,10 +87,5 @@ async def test_device_tracker(hass, config_entry, zha_gateway):
assert entity.battery_level == 100
# test adding device tracker to the network and HA
- await async_test_device_join(
- hass,
- zha_gateway,
- general.PowerConfiguration.cluster_id,
- entity_id,
- SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
- )
+ await async_test_rejoin(hass, zigpy_device_dt, [cluster], (2,))
+ assert hass.states.get(entity_id).state == STATE_HOME
diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py
index 75e8538c5bf0d4..9b69ba06e4f439 100644
--- a/tests/components/zha/test_device_trigger.py
+++ b/tests/components/zha/test_device_trigger.py
@@ -3,13 +3,9 @@
import zigpy.zcl.clusters.general as general
import homeassistant.components.automation as automation
-from homeassistant.components.switch import DOMAIN
-from homeassistant.components.zha.core.const import CHANNEL_ON_OFF
from homeassistant.helpers.device_registry import async_get_registry
from homeassistant.setup import async_setup_component
-from .common import async_enable_traffic, async_init_zigpy_device
-
from tests.common import async_get_device_automations, async_mock_service
ON = 1
@@ -38,18 +34,35 @@ def _same_lists(list_a, list_b):
@pytest.fixture
def calls(hass):
- """Track calls to a mock serivce."""
+ """Track calls to a mock service."""
return async_mock_service(hass, "test", "automation")
-async def test_triggers(hass, config_entry, zha_gateway):
- """Test zha device triggers."""
+@pytest.fixture
+async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored):
+ """IAS device fixture."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass, [general.Basic.cluster_id], [general.OnOff.cluster_id], None, zha_gateway
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.Basic.cluster_id],
+ "out_clusters": [general.OnOff.cluster_id],
+ "device_type": 0,
+ }
+ },
)
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ zha_device.update_available(True)
+ await hass.async_block_till_done()
+ return zigpy_device, zha_device
+
+
+async def test_triggers(hass, mock_devices):
+ """Test zha device triggers."""
+
+ zigpy_device, zha_device = mock_devices
+
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
(DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE},
@@ -58,11 +71,6 @@ async def test_triggers(hass, config_entry, zha_gateway):
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
- hass.config_entries._entries.append(config_entry)
-
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
ieee_address = str(zha_device.ieee)
ha_device_registry = await async_get_registry(hass)
@@ -110,19 +118,10 @@ async def test_triggers(hass, config_entry, zha_gateway):
assert _same_lists(triggers, expected_triggers)
-async def test_no_triggers(hass, config_entry, zha_gateway):
+async def test_no_triggers(hass, mock_devices):
"""Test zha device with no triggers."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass, [general.Basic.cluster_id], [general.OnOff.cluster_id], None, zha_gateway
- )
-
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
- hass.config_entries._entries.append(config_entry)
-
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
+ _, zha_device = mock_devices
ieee_address = str(zha_device.ieee)
ha_device_registry = await async_get_registry(hass)
@@ -132,13 +131,10 @@ async def test_no_triggers(hass, config_entry, zha_gateway):
assert triggers == []
-async def test_if_fires_on_event(hass, config_entry, zha_gateway, calls):
+async def test_if_fires_on_event(hass, mock_devices, calls):
"""Test for remote triggers firing."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass, [general.Basic.cluster_id], [general.OnOff.cluster_id], None, zha_gateway
- )
+ zigpy_device, zha_device = mock_devices
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
@@ -148,15 +144,6 @@ async def test_if_fires_on_event(hass, config_entry, zha_gateway, calls):
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
- hass.config_entries._entries.append(config_entry)
-
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
-
- # allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
-
ieee_address = str(zha_device.ieee)
ha_device_registry = await async_get_registry(hass)
reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}, set())
@@ -185,30 +172,18 @@ async def test_if_fires_on_event(hass, config_entry, zha_gateway, calls):
await hass.async_block_till_done()
- on_off_channel = zha_device.cluster_channels[CHANNEL_ON_OFF]
- on_off_channel.zha_send_event(on_off_channel.cluster, COMMAND_SINGLE, [])
+ channel = zha_device.channels.pools[0].relay_channels["1:0x0006"]
+ channel.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["message"] == "service called"
-async def test_exception_no_triggers(hass, config_entry, zha_gateway, calls, caplog):
+async def test_exception_no_triggers(hass, mock_devices, calls, caplog):
"""Test for exception on event triggers firing."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass, [general.Basic.cluster_id], [general.OnOff.cluster_id], None, zha_gateway
- )
-
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
- hass.config_entries._entries.append(config_entry)
-
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
-
- # allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ _, zha_device = mock_devices
ieee_address = str(zha_device.ieee)
ha_device_registry = await async_get_registry(hass)
@@ -239,13 +214,10 @@ async def test_exception_no_triggers(hass, config_entry, zha_gateway, calls, cap
assert "Invalid config for [automation]" in caplog.text
-async def test_exception_bad_trigger(hass, config_entry, zha_gateway, calls, caplog):
+async def test_exception_bad_trigger(hass, mock_devices, calls, caplog):
"""Test for exception on event triggers firing."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass, [general.Basic.cluster_id], [general.OnOff.cluster_id], None, zha_gateway
- )
+ zigpy_device, zha_device = mock_devices
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
@@ -255,15 +227,6 @@ async def test_exception_bad_trigger(hass, config_entry, zha_gateway, calls, cap
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
- hass.config_entries._entries.append(config_entry)
-
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
-
- # allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
-
ieee_address = str(zha_device.ieee)
ha_device_registry = await async_get_registry(hass)
reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}, set())
diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py
index 91805acc448b19..c8f2eb0dd7cadc 100644
--- a/tests/components/zha/test_discover.py
+++ b/tests/components/zha/test_discover.py
@@ -1,55 +1,363 @@
"""Test zha device discovery."""
-import asyncio
+import re
from unittest import mock
import pytest
+import zigpy.quirks
+import zigpy.zcl.clusters.closures
+import zigpy.zcl.clusters.general
+import zigpy.zcl.clusters.security
-from homeassistant.components.zha.core.channels import EventRelayChannel
+import homeassistant.components.zha.binary_sensor
+import homeassistant.components.zha.core.channels as zha_channels
+import homeassistant.components.zha.core.channels.base as base_channels
import homeassistant.components.zha.core.const as zha_const
import homeassistant.components.zha.core.discovery as disc
-import homeassistant.components.zha.core.gateway as core_zha_gw
+import homeassistant.components.zha.core.registries as zha_regs
+import homeassistant.components.zha.cover
+import homeassistant.components.zha.device_tracker
+import homeassistant.components.zha.fan
+import homeassistant.components.zha.light
+import homeassistant.components.zha.lock
+import homeassistant.components.zha.sensor
+import homeassistant.components.zha.switch
+import homeassistant.helpers.entity_registry
-from .common import make_device
+from .common import get_zha_gateway
from .zha_devices_list import DEVICES
+NO_TAIL_ID = re.compile("_\\d$")
+
+
+@pytest.fixture
+def channels_mock(zha_device_mock):
+ """Channels mock factory."""
+
+ def _mock(
+ endpoints,
+ ieee="00:11:22:33:44:55:66:77",
+ manufacturer="mock manufacturer",
+ model="mock model",
+ node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ ):
+ zha_dev = zha_device_mock(endpoints, ieee, manufacturer, model, node_desc)
+ channels = zha_channels.Channels.new(zha_dev)
+ return channels
+
+ return _mock
+
@pytest.mark.parametrize("device", DEVICES)
-async def test_devices(device, zha_gateway: core_zha_gw.ZHAGateway, hass, config_entry):
+async def test_devices(
+ device, hass, zigpy_device_mock, monkeypatch, zha_device_joined_restored
+):
"""Test device discovery."""
- zigpy_device = make_device(
+ entity_registry = await homeassistant.helpers.entity_registry.async_get_registry(
+ hass
+ )
+
+ zigpy_device = zigpy_device_mock(
device["endpoints"],
"00:11:22:33:44:55:66:77",
device["manufacturer"],
device["model"],
+ node_descriptor=device["node_descriptor"],
)
- with mock.patch(
- "homeassistant.components.zha.core.discovery._async_create_cluster_channel",
- wraps=disc._async_create_cluster_channel,
- ) as cr_ch:
- await zha_gateway.async_device_restored(zigpy_device)
+ orig_new_entity = zha_channels.ChannelPool.async_new_entity
+ _dispatch = mock.MagicMock(wraps=orig_new_entity)
+ try:
+ zha_channels.ChannelPool.async_new_entity = lambda *a, **kw: _dispatch(*a, **kw)
+ zha_dev = await zha_device_joined_restored(zigpy_device)
await hass.async_block_till_done()
- tasks = [
- hass.config_entries.async_forward_entry_setup(config_entry, component)
- for component in zha_const.COMPONENTS
+ finally:
+ zha_channels.ChannelPool.async_new_entity = orig_new_entity
+
+ entity_ids = hass.states.async_entity_ids()
+ await hass.async_block_till_done()
+ zha_entity_ids = {
+ ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS
+ }
+
+ event_channels = {
+ ch.id for pool in zha_dev.channels.pools for ch in pool.relay_channels.values()
+ }
+
+ entity_map = device["entity_map"]
+ assert zha_entity_ids == set(
+ [
+ e["entity_id"]
+ for e in entity_map.values()
+ if not e.get("default_match", False)
]
- await asyncio.gather(*tasks)
+ )
+ assert event_channels == set(device["event_channels"])
- await hass.async_block_till_done()
+ for call in _dispatch.call_args_list:
+ _, component, entity_cls, unique_id, channels = call[0]
+ key = (component, unique_id)
+ entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id)
- entity_ids = hass.states.async_entity_ids()
- await hass.async_block_till_done()
- zha_entities = {
- ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS
- }
-
- event_channels = {
- arg[0].cluster_id
- for arg, kwarg in cr_ch.call_args_list
- if kwarg.get("channel_class") == EventRelayChannel
- }
-
- assert zha_entities == set(device["entities"])
- assert event_channels == set(device["event_channels"])
+ assert key in entity_map
+ assert entity_id is not None
+ no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"])
+ assert entity_id.startswith(no_tail_id)
+ assert set([ch.name for ch in channels]) == set(entity_map[key]["channels"])
+ assert entity_cls.__name__ == entity_map[key]["entity_class"]
+
+
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_device_type"
+)
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_cluster_id"
+)
+def test_discover_entities(m1, m2):
+ """Test discover endpoint class method."""
+ ep_channels = mock.MagicMock()
+ disc.PROBE.discover_entities(ep_channels)
+ assert m1.call_count == 1
+ assert m1.call_args[0][0] is ep_channels
+ assert m2.call_count == 1
+ assert m2.call_args[0][0] is ep_channels
+
+
+@pytest.mark.parametrize(
+ "device_type, component, hit",
+ [
+ (0x0100, zha_const.LIGHT, True),
+ (0x0108, zha_const.SWITCH, True),
+ (0x0051, zha_const.SWITCH, True),
+ (0xFFFF, None, False),
+ ],
+)
+def test_discover_by_device_type(device_type, component, hit):
+ """Test entity discovery by device type."""
+
+ ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ep_mock = mock.PropertyMock()
+ ep_mock.return_value.profile_id = 0x0104
+ ep_mock.return_value.device_type = device_type
+ type(ep_channels).endpoint = ep_mock
+
+ get_entity_mock = mock.MagicMock(
+ return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
+ )
+ with mock.patch(
+ "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
+ get_entity_mock,
+ ):
+ disc.PROBE.discover_by_device_type(ep_channels)
+ if hit:
+ assert get_entity_mock.call_count == 1
+ assert ep_channels.claim_channels.call_count == 1
+ assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed
+ assert ep_channels.async_new_entity.call_count == 1
+ assert ep_channels.async_new_entity.call_args[0][0] == component
+ assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
+
+
+def test_discover_by_device_type_override():
+ """Test entity discovery by device type overriding."""
+
+ ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ep_mock = mock.PropertyMock()
+ ep_mock.return_value.profile_id = 0x0104
+ ep_mock.return_value.device_type = 0x0100
+ type(ep_channels).endpoint = ep_mock
+
+ overrides = {ep_channels.unique_id: {"type": zha_const.SWITCH}}
+ get_entity_mock = mock.MagicMock(
+ return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
+ )
+ with mock.patch(
+ "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
+ get_entity_mock,
+ ):
+ with mock.patch.dict(disc.PROBE._device_configs, overrides, clear=True):
+ disc.PROBE.discover_by_device_type(ep_channels)
+ assert get_entity_mock.call_count == 1
+ assert ep_channels.claim_channels.call_count == 1
+ assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed
+ assert ep_channels.async_new_entity.call_count == 1
+ assert ep_channels.async_new_entity.call_args[0][0] == zha_const.SWITCH
+ assert (
+ ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
+ )
+
+
+def test_discover_probe_single_cluster():
+ """Test entity discovery by single cluster."""
+
+ ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ep_mock = mock.PropertyMock()
+ ep_mock.return_value.profile_id = 0x0104
+ ep_mock.return_value.device_type = 0x0100
+ type(ep_channels).endpoint = ep_mock
+
+ get_entity_mock = mock.MagicMock(
+ return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
+ )
+ channel_mock = mock.MagicMock(spec_set=base_channels.ZigbeeChannel)
+ with mock.patch(
+ "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
+ get_entity_mock,
+ ):
+ disc.PROBE.probe_single_cluster(zha_const.SWITCH, channel_mock, ep_channels)
+
+ assert get_entity_mock.call_count == 1
+ assert ep_channels.claim_channels.call_count == 1
+ assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed
+ assert ep_channels.async_new_entity.call_count == 1
+ assert ep_channels.async_new_entity.call_args[0][0] == zha_const.SWITCH
+ assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
+ assert ep_channels.async_new_entity.call_args[0][3] == mock.sentinel.claimed
+
+
+@pytest.mark.parametrize("device_info", DEVICES)
+async def test_discover_endpoint(device_info, channels_mock, hass):
+ """Test device discovery."""
+
+ with mock.patch(
+ "homeassistant.components.zha.core.channels.Channels.async_new_entity"
+ ) as new_ent:
+ channels = channels_mock(
+ device_info["endpoints"],
+ manufacturer=device_info["manufacturer"],
+ model=device_info["model"],
+ node_desc=device_info["node_descriptor"],
+ )
+
+ assert device_info["event_channels"] == sorted(
+ [ch.id for pool in channels.pools for ch in pool.relay_channels.values()]
+ )
+ assert new_ent.call_count == len(
+ [
+ device_info
+ for device_info in device_info["entity_map"].values()
+ if not device_info.get("default_match", False)
+ ]
+ )
+
+ for call_args in new_ent.call_args_list:
+ comp, ent_cls, unique_id, channels = call_args[0]
+ map_id = (comp, unique_id)
+ assert map_id in device_info["entity_map"]
+ entity_info = device_info["entity_map"][map_id]
+ assert set([ch.name for ch in channels]) == set(entity_info["channels"])
+ assert ent_cls.__name__ == entity_info["entity_class"]
+
+
+def _ch_mock(cluster):
+ """Return mock of a channel with a cluster."""
+ channel = mock.MagicMock()
+ type(channel).cluster = mock.PropertyMock(return_value=cluster(mock.MagicMock()))
+ return channel
+
+
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint"
+ ".handle_on_off_output_cluster_exception",
+ new=mock.MagicMock(),
+)
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint.probe_single_cluster"
+)
+def _test_single_input_cluster_device_class(probe_mock):
+ """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
+
+ door_ch = _ch_mock(zigpy.zcl.clusters.closures.DoorLock)
+ cover_ch = _ch_mock(zigpy.zcl.clusters.closures.WindowCovering)
+ multistate_ch = _ch_mock(zigpy.zcl.clusters.general.MultistateInput)
+
+ class QuirkedIAS(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.security.IasZone):
+ pass
+
+ ias_ch = _ch_mock(QuirkedIAS)
+
+ class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput):
+ pass
+
+ analog_ch = _ch_mock(_Analog)
+
+ ch_pool = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ch_pool.unclaimed_channels.return_value = [
+ door_ch,
+ cover_ch,
+ multistate_ch,
+ ias_ch,
+ analog_ch,
+ ]
+
+ disc.ProbeEndpoint().discover_by_cluster_id(ch_pool)
+ assert probe_mock.call_count == len(ch_pool.unclaimed_channels())
+ probes = (
+ (zha_const.LOCK, door_ch),
+ (zha_const.COVER, cover_ch),
+ (zha_const.SENSOR, multistate_ch),
+ (zha_const.BINARY_SENSOR, ias_ch),
+ (zha_const.SENSOR, analog_ch),
+ )
+ for call, details in zip(probe_mock.call_args_list, probes):
+ component, ch = details
+ assert call[0][0] == component
+ assert call[0][1] == ch
+
+
+def test_single_input_cluster_device_class():
+ """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
+ _test_single_input_cluster_device_class()
+
+
+def test_single_input_cluster_device_class_by_cluster_class():
+ """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
+ mock_reg = {
+ zigpy.zcl.clusters.closures.DoorLock.cluster_id: zha_const.LOCK,
+ zigpy.zcl.clusters.closures.WindowCovering.cluster_id: zha_const.COVER,
+ zigpy.zcl.clusters.general.AnalogInput: zha_const.SENSOR,
+ zigpy.zcl.clusters.general.MultistateInput: zha_const.SENSOR,
+ zigpy.zcl.clusters.security.IasZone: zha_const.BINARY_SENSOR,
+ }
+
+ with mock.patch.dict(
+ zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, mock_reg, clear=True
+ ):
+ _test_single_input_cluster_device_class()
+
+
+@pytest.mark.parametrize(
+ "override, entity_id",
+ [
+ (None, "light.manufacturer_model_77665544_level_light_color_on_off"),
+ ("switch", "switch.manufacturer_model_77665544_on_off"),
+ ],
+)
+async def test_device_override(hass, zigpy_device_mock, setup_zha, override, entity_id):
+ """Test device discovery override."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "device_type": 258,
+ "endpoint_id": 1,
+ "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513],
+ "out_clusters": [25],
+ "profile_id": 260,
+ }
+ },
+ "00:11:22:33:44:55:66:77",
+ "manufacturer",
+ "model",
+ )
+
+ if override is not None:
+ override = {"device_config": {"00:11:22:33:44:55:66:77-1": {"type": override}}}
+
+ await setup_zha(override)
+ assert hass.states.get(entity_id) is None
+ zha_gateway = get_zha_gateway(hass)
+ await zha_gateway.async_device_initialized(zigpy_device)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id) is not None
diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py
index 660bff2abac635..0cf3e3e954d4f1 100644
--- a/tests/components/zha/test_fan.py
+++ b/tests/components/zha/test_fan.py
@@ -1,7 +1,7 @@
"""Test zha fan."""
-from unittest.mock import call, patch
+from unittest.mock import call
-import zigpy.zcl.clusters.general as general
+import pytest
import zigpy.zcl.clusters.hvac as hvac
import zigpy.zcl.foundation as zcl_f
@@ -18,30 +18,27 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
)
-from tests.common import mock_coro
+@pytest.fixture
+def zigpy_device(zigpy_device_mock):
+ """Device tracker zigpy device."""
+ endpoints = {
+ 1: {"in_clusters": [hvac.Fan.cluster_id], "out_clusters": [], "device_type": 0}
+ }
+ return zigpy_device_mock(endpoints)
-async def test_fan(hass, config_entry, zha_gateway):
- """Test zha fan platform."""
-
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass, [hvac.Fan.cluster_id, general.Basic.cluster_id], [], None, zha_gateway
- )
- # load up fan domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
+async def test_fan(hass, zha_device_joined_restored, zigpy_device):
+ """Test zha fan platform."""
+ zha_device = await zha_device_joined_restored(zigpy_device)
cluster = zigpy_device.endpoints.get(1).fan
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
entity_id = await find_entity_id(DOMAIN, zha_device, hass)
assert entity_id is not None
@@ -49,7 +46,7 @@ async def test_fan(hass, config_entry, zha_gateway):
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
@@ -68,37 +65,25 @@ async def test_fan(hass, config_entry, zha_gateway):
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
- with patch(
- "zigpy.zcl.Cluster.write_attributes",
- return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]),
- ):
- # turn on via UI
- await async_turn_on(hass, entity_id)
- assert len(cluster.write_attributes.mock_calls) == 1
- assert cluster.write_attributes.call_args == call({"fan_mode": 2})
+ cluster.write_attributes.reset_mock()
+ await async_turn_on(hass, entity_id)
+ assert len(cluster.write_attributes.mock_calls) == 1
+ assert cluster.write_attributes.call_args == call({"fan_mode": 2})
# turn off from HA
- with patch(
- "zigpy.zcl.Cluster.write_attributes",
- return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]),
- ):
- # turn off via UI
- await async_turn_off(hass, entity_id)
- assert len(cluster.write_attributes.mock_calls) == 1
- assert cluster.write_attributes.call_args == call({"fan_mode": 0})
+ cluster.write_attributes.reset_mock()
+ await async_turn_off(hass, entity_id)
+ assert len(cluster.write_attributes.mock_calls) == 1
+ assert cluster.write_attributes.call_args == call({"fan_mode": 0})
# change speed from HA
- with patch(
- "zigpy.zcl.Cluster.write_attributes",
- return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]),
- ):
- # turn on via UI
- await async_set_speed(hass, entity_id, speed=fan.SPEED_HIGH)
- assert len(cluster.write_attributes.mock_calls) == 1
- assert cluster.write_attributes.call_args == call({"fan_mode": 3})
+ cluster.write_attributes.reset_mock()
+ await async_set_speed(hass, entity_id, speed=fan.SPEED_HIGH)
+ assert len(cluster.write_attributes.mock_calls) == 1
+ assert cluster.write_attributes.call_args == call({"fan_mode": 3})
# test adding new fan to the network and HA
- await async_test_device_join(hass, zha_gateway, hvac.Fan.cluster_id, entity_id)
+ await async_test_rejoin(hass, zigpy_device, [cluster], (1,))
async def async_turn_on(hass, entity_id, speed=None):
diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py
new file mode 100644
index 00000000000000..74aed6f5872a7b
--- /dev/null
+++ b/tests/components/zha/test_gateway.py
@@ -0,0 +1,39 @@
+"""Test ZHA Gateway."""
+import pytest
+import zigpy.zcl.clusters.general as general
+
+from .common import async_enable_traffic, get_zha_gateway
+
+
+@pytest.fixture
+def zigpy_dev_basic(zigpy_device_mock):
+ """Zigpy device with just a basic cluster."""
+ return zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.Basic.cluster_id],
+ "out_clusters": [],
+ "device_type": 0,
+ }
+ },
+ )
+
+
+@pytest.fixture
+async def zha_dev_basic(hass, zha_device_restored, zigpy_dev_basic):
+ """ZHA device with just a basic cluster."""
+
+ zha_device = await zha_device_restored(zigpy_dev_basic)
+ return zha_device
+
+
+async def test_device_left(hass, zigpy_dev_basic, zha_dev_basic):
+ """Device leaving the network should become unavailable."""
+
+ assert zha_dev_basic.available is False
+
+ await async_enable_traffic(hass, [zha_dev_basic])
+ assert zha_dev_basic.available is True
+
+ get_zha_gateway(hass).device_left(zigpy_dev_basic)
+ assert zha_dev_basic.available is False
diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py
index 53be188ae80401..e21c22d30cf5d6 100644
--- a/tests/components/zha/test_light.py
+++ b/tests/components/zha/test_light.py
@@ -1,10 +1,12 @@
"""Test zha light."""
-import asyncio
-from unittest.mock import MagicMock, call, patch, sentinel
+from unittest.mock import call, sentinel
+import asynctest
+import pytest
import zigpy.profiles.zha
import zigpy.types
import zigpy.zcl.clusters.general as general
+import zigpy.zcl.clusters.lighting as lighting
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.light import DOMAIN
@@ -12,121 +14,115 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
)
-from tests.common import mock_coro
-
ON = 1
OFF = 0
+LIGHT_ON_OFF = {
+ 1: {
+ "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
+ "in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id],
+ "out_clusters": [general.Ota.cluster_id],
+ }
+}
+
+LIGHT_LEVEL = {
+ 1: {
+ "device_type": zigpy.profiles.zha.DeviceType.DIMMABLE_LIGHT,
+ "in_clusters": [
+ general.Basic.cluster_id,
+ general.LevelControl.cluster_id,
+ general.OnOff.cluster_id,
+ ],
+ "out_clusters": [general.Ota.cluster_id],
+ }
+}
+
+LIGHT_COLOR = {
+ 1: {
+ "device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ "in_clusters": [
+ general.Basic.cluster_id,
+ general.LevelControl.cluster_id,
+ general.OnOff.cluster_id,
+ lighting.Color.cluster_id,
+ ],
+ "out_clusters": [general.Ota.cluster_id],
+ }
+}
+
-async def test_light(hass, config_entry, zha_gateway, monkeypatch):
+@asynctest.patch(
+ "zigpy.zcl.clusters.lighting.Color.request",
+ new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+)
+@asynctest.patch(
+ "zigpy.zcl.clusters.general.LevelControl.request",
+ new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+)
+@asynctest.patch(
+ "zigpy.zcl.clusters.general.OnOff.request",
+ new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+)
+@pytest.mark.parametrize(
+ "device, reporting",
+ [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 3))],
+)
+async def test_light(
+ hass, zigpy_device_mock, zha_device_joined_restored, device, reporting,
+):
"""Test zha light platform."""
# create zigpy devices
- zigpy_device_on_off = await async_init_zigpy_device(
- hass,
- [general.OnOff.cluster_id, general.Basic.cluster_id],
- [],
- zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
- zha_gateway,
- ieee="00:0d:6f:11:0a:90:69:e6",
- )
+ zigpy_device = zigpy_device_mock(device)
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ entity_id = await find_entity_id(DOMAIN, zha_device, hass)
- zigpy_device_level = await async_init_zigpy_device(
- hass,
- [
- general.OnOff.cluster_id,
- general.LevelControl.cluster_id,
- general.Basic.cluster_id,
- ],
- [],
- zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
- zha_gateway,
- ieee="00:0d:6f:11:0a:90:69:e7",
- manufacturer="FakeLevelManufacturer",
- model="FakeLevelModel",
- )
+ assert entity_id is not None
- # load up light domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
-
- # on off light
- on_off_device_on_off_cluster = zigpy_device_on_off.endpoints.get(1).on_off
- on_off_zha_device = zha_gateway.get_device(zigpy_device_on_off.ieee)
- on_off_entity_id = await find_entity_id(DOMAIN, on_off_zha_device, hass)
- assert on_off_entity_id is not None
-
- # dimmable light
- level_device_on_off_cluster = zigpy_device_level.endpoints.get(1).on_off
- level_device_level_cluster = zigpy_device_level.endpoints.get(1).level
- on_off_mock = MagicMock(
- side_effect=asyncio.coroutine(
- MagicMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS])
- )
- )
- level_mock = MagicMock(
- side_effect=asyncio.coroutine(
- MagicMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS])
- )
- )
- monkeypatch.setattr(level_device_on_off_cluster, "request", on_off_mock)
- monkeypatch.setattr(level_device_level_cluster, "request", level_mock)
- level_zha_device = zha_gateway.get_device(zigpy_device_level.ieee)
- level_entity_id = await find_entity_id(DOMAIN, level_zha_device, hass)
- assert level_entity_id is not None
+ cluster_on_off = zigpy_device.endpoints[1].on_off
+ cluster_level = getattr(zigpy_device.endpoints[1], "level", None)
+ cluster_color = getattr(zigpy_device.endpoints[1], "light_color", None)
# test that the lights were created and that they are unavailable
- assert hass.states.get(on_off_entity_id).state == STATE_UNAVAILABLE
- assert hass.states.get(level_entity_id).state == STATE_UNAVAILABLE
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [on_off_zha_device, level_zha_device])
+ await async_enable_traffic(hass, [zha_device])
# test that the lights were created and are off
- assert hass.states.get(on_off_entity_id).state == STATE_OFF
- assert hass.states.get(level_entity_id).state == STATE_OFF
+ assert hass.states.get(entity_id).state == STATE_OFF
# test turning the lights on and off from the light
- await async_test_on_off_from_light(
- hass, on_off_device_on_off_cluster, on_off_entity_id
- )
-
- await async_test_on_off_from_light(
- hass, level_device_on_off_cluster, level_entity_id
- )
+ await async_test_on_off_from_light(hass, cluster_on_off, entity_id)
# test turning the lights on and off from the HA
- await async_test_on_off_from_hass(
- hass, on_off_device_on_off_cluster, on_off_entity_id
- )
-
- await async_test_level_on_off_from_hass(
- hass, level_device_on_off_cluster, level_device_level_cluster, level_entity_id
- )
+ await async_test_on_off_from_hass(hass, cluster_on_off, entity_id)
- # test turning the lights on and off from the light
- await async_test_on_from_light(hass, level_device_on_off_cluster, level_entity_id)
+ if cluster_level:
+ await async_test_level_on_off_from_hass(
+ hass, cluster_on_off, cluster_level, entity_id
+ )
- # test getting a brightness change from the network
- await async_test_dimmer_from_light(
- hass, level_device_level_cluster, level_entity_id, 150, STATE_ON
- )
+ # test getting a brightness change from the network
+ await async_test_on_from_light(hass, cluster_on_off, entity_id)
+ await async_test_dimmer_from_light(
+ hass, cluster_level, entity_id, 150, STATE_ON
+ )
- # test adding a new light to the network and HA
- await async_test_device_join(
- hass,
- zha_gateway,
- general.OnOff.cluster_id,
- on_off_entity_id,
- device_type=zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
- )
+ # test rejoin
+ await async_test_off_from_hass(hass, cluster_on_off, entity_id)
+ clusters = [cluster_on_off]
+ if cluster_level:
+ clusters.append(cluster_level)
+ if cluster_color:
+ clusters.append(cluster_color)
+ await async_test_rejoin(hass, zigpy_device, clusters, reporting)
async def async_test_on_off_from_light(hass, cluster, entity_id):
@@ -157,36 +153,33 @@ async def async_test_on_from_light(hass, cluster, entity_id):
async def async_test_on_off_from_hass(hass, cluster, entity_id):
"""Test on off functionality from hass."""
- with patch(
- "zigpy.zcl.Cluster.request",
- return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]),
- ):
- # turn on via UI
- await hass.services.async_call(
- DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
- )
- assert cluster.request.call_count == 1
- assert cluster.request.call_args == call(
- False, ON, (), expect_reply=True, manufacturer=None
- )
+ # turn on via UI
+ cluster.request.reset_mock()
+ await hass.services.async_call(
+ DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
+ )
+ assert cluster.request.call_count == 1
+ assert cluster.request.await_count == 1
+ assert cluster.request.call_args == call(
+ False, ON, (), expect_reply=True, manufacturer=None
+ )
await async_test_off_from_hass(hass, cluster, entity_id)
async def async_test_off_from_hass(hass, cluster, entity_id):
"""Test turning off the light from Home Assistant."""
- with patch(
- "zigpy.zcl.Cluster.request",
- return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]),
- ):
- # turn off via UI
- await hass.services.async_call(
- DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
- )
- assert cluster.request.call_count == 1
- assert cluster.request.call_args == call(
- False, OFF, (), expect_reply=True, manufacturer=None
- )
+
+ # turn off via UI
+ cluster.request.reset_mock()
+ await hass.services.async_call(
+ DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
+ )
+ assert cluster.request.call_count == 1
+ assert cluster.request.await_count == 1
+ assert cluster.request.call_args == call(
+ False, OFF, (), expect_reply=True, manufacturer=None
+ )
async def async_test_level_on_off_from_hass(
@@ -194,12 +187,15 @@ async def async_test_level_on_off_from_hass(
):
"""Test on off functionality from hass."""
+ on_off_cluster.request.reset_mock()
# turn on via UI
await hass.services.async_call(
DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert on_off_cluster.request.call_count == 1
+ assert on_off_cluster.request.await_count == 1
assert level_cluster.request.call_count == 0
+ assert level_cluster.request.await_count == 0
assert on_off_cluster.request.call_args == call(
False, 1, (), expect_reply=True, manufacturer=None
)
@@ -210,7 +206,9 @@ async def async_test_level_on_off_from_hass(
DOMAIN, "turn_on", {"entity_id": entity_id, "transition": 10}, blocking=True
)
assert on_off_cluster.request.call_count == 1
+ assert on_off_cluster.request.await_count == 1
assert level_cluster.request.call_count == 1
+ assert level_cluster.request.await_count == 1
assert on_off_cluster.request.call_args == call(
False, 1, (), expect_reply=True, manufacturer=None
)
@@ -230,7 +228,9 @@ async def async_test_level_on_off_from_hass(
DOMAIN, "turn_on", {"entity_id": entity_id, "brightness": 10}, blocking=True
)
assert on_off_cluster.request.call_count == 1
+ assert on_off_cluster.request.await_count == 1
assert level_cluster.request.call_count == 1
+ assert level_cluster.request.await_count == 1
assert on_off_cluster.request.call_args == call(
False, 1, (), expect_reply=True, manufacturer=None
)
diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py
index 1daef317fed7a4..0442ea497d75e3 100644
--- a/tests/components/zha/test_lock.py
+++ b/tests/components/zha/test_lock.py
@@ -1,6 +1,8 @@
"""Test zha lock."""
from unittest.mock import patch
+import pytest
+import zigpy.profiles.zha
import zigpy.zcl.clusters.closures as closures
import zigpy.zcl.clusters.general as general
import zigpy.zcl.foundation as zcl_f
@@ -10,7 +12,6 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
find_entity_id,
make_attribute,
make_zcl_header,
@@ -22,24 +23,28 @@
UNLOCK_DOOR = 1
-async def test_lock(hass, config_entry, zha_gateway):
- """Test zha lock platform."""
+@pytest.fixture
+async def lock(hass, zigpy_device_mock, zha_device_joined_restored):
+ """Lock cluster fixture."""
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass,
- [closures.DoorLock.cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [closures.DoorLock.cluster_id, general.Basic.cluster_id],
+ "out_clusters": [],
+ "device_type": zigpy.profiles.zha.DeviceType.DOOR_LOCK,
+ }
+ },
)
- # load up lock domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ return zha_device, zigpy_device.endpoints[1].door_lock
+
+
+async def test_lock(hass, lock):
+ """Test zha lock platform."""
- cluster = zigpy_device.endpoints.get(1).door_lock
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
+ zha_device, cluster = lock
entity_id = await find_entity_id(DOMAIN, zha_device, hass)
assert entity_id is not None
@@ -47,7 +52,7 @@ async def test_lock(hass, config_entry, zha_gateway):
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to unlocked
assert hass.states.get(entity_id).state == STATE_UNLOCKED
diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py
index 9f77330dd55901..fc41a4095185e9 100644
--- a/tests/components/zha/test_registries.py
+++ b/tests/components/zha/test_registries.py
@@ -19,16 +19,10 @@ def zha_device():
@pytest.fixture
-def channels():
+def channels(channel):
"""Return a mock of channels."""
- def channel(name, chan_id):
- ch = mock.MagicMock()
- ch.name = name
- ch.generic_id = chan_id
- return ch
-
- return [channel("level", "channel_0x0008"), channel("on_off", "channel_0x0006")]
+ return [channel("level", 8), channel("on_off", 6)]
@pytest.mark.parametrize(
@@ -61,8 +55,20 @@ def channel(name, chan_id):
# manufacturer matching
(registries.MatchRule(manufacturers="no match"), False),
(registries.MatchRule(manufacturers=MANUFACTURER), True),
+ (
+ registries.MatchRule(manufacturers="no match", aux_channels="aux_channel"),
+ False,
+ ),
+ (
+ registries.MatchRule(
+ manufacturers=MANUFACTURER, aux_channels="aux_channel"
+ ),
+ True,
+ ),
(registries.MatchRule(models=MODEL), True),
(registries.MatchRule(models="no match"), False),
+ (registries.MatchRule(models=MODEL, aux_channels="aux_channel"), True),
+ (registries.MatchRule(models="no match", aux_channels="aux_channel"), False),
# match everything
(
registries.MatchRule(
@@ -119,10 +125,9 @@ def channel(name, chan_id):
),
],
)
-def test_registry_matching(rule, matched, zha_device, channels):
+def test_registry_matching(rule, matched, channels):
"""Test strict rule matching."""
- reg = registries.ZHAEntityRegistry()
- assert reg._strict_matched(zha_device, channels, rule) is matched
+ assert rule.strict_matched(MANUFACTURER, MODEL, channels) is matched
@pytest.mark.parametrize(
@@ -203,7 +208,49 @@ def test_registry_matching(rule, matched, zha_device, channels):
),
],
)
-def test_registry_loose_matching(rule, matched, zha_device, channels):
+def test_registry_loose_matching(rule, matched, channels):
"""Test loose rule matching."""
- reg = registries.ZHAEntityRegistry()
- assert reg._loose_matched(zha_device, channels, rule) is matched
+ assert rule.loose_matched(MANUFACTURER, MODEL, channels) is matched
+
+
+def test_match_rule_claim_channels_color(channel):
+ """Test channel claiming."""
+ ch_color = channel("color", 0x300)
+ ch_level = channel("level", 8)
+ ch_onoff = channel("on_off", 6)
+
+ rule = registries.MatchRule(channel_names="on_off", aux_channels={"color", "level"})
+ claimed = rule.claim_channels([ch_color, ch_level, ch_onoff])
+ assert {"color", "level", "on_off"} == set([ch.name for ch in claimed])
+
+
+@pytest.mark.parametrize(
+ "rule, match",
+ [
+ (registries.MatchRule(channel_names={"level"}), {"level"}),
+ (registries.MatchRule(channel_names={"level", "no match"}), {"level"}),
+ (registries.MatchRule(channel_names={"on_off"}), {"on_off"}),
+ (registries.MatchRule(generic_ids="channel_0x0000"), {"basic"}),
+ (
+ registries.MatchRule(channel_names="level", generic_ids="channel_0x0000"),
+ {"basic", "level"},
+ ),
+ (registries.MatchRule(channel_names={"level", "power"}), {"level", "power"}),
+ (
+ registries.MatchRule(
+ channel_names={"level", "on_off"}, aux_channels={"basic", "power"}
+ ),
+ {"basic", "level", "on_off", "power"},
+ ),
+ (registries.MatchRule(channel_names={"color"}), set()),
+ ],
+)
+def test_match_rule_claim_channels(rule, match, channel, channels):
+ """Test channel claiming."""
+ ch_basic = channel("basic", 0)
+ channels.append(ch_basic)
+ ch_power = channel("power", 1)
+ channels.append(ch_power)
+
+ claimed = rule.claim_channels(channels)
+ assert match == set([ch.name for ch in claimed])
diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py
index 4c913e100340a1..b81e8f02c12294 100644
--- a/tests/components/zha/test_sensor.py
+++ b/tests/components/zha/test_sensor.py
@@ -11,7 +11,6 @@
from homeassistant.components.sensor import DOMAIN
import homeassistant.config as config_util
from homeassistant.const import (
- ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_UNIT_SYSTEM,
CONF_UNIT_SYSTEM_IMPERIAL,
@@ -26,160 +25,44 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
)
-async def test_sensor(hass, config_entry, zha_gateway):
- """Test zha sensor platform."""
-
- # list of cluster ids to create devices and sensor entities for
- cluster_ids = [
- measurement.RelativeHumidity.cluster_id,
- measurement.TemperatureMeasurement.cluster_id,
- measurement.PressureMeasurement.cluster_id,
- measurement.IlluminanceMeasurement.cluster_id,
- smartenergy.Metering.cluster_id,
- homeautomation.ElectricalMeasurement.cluster_id,
- ]
-
- # devices that were created from cluster_ids list above
- zigpy_device_infos = await async_build_devices(
- hass, zha_gateway, config_entry, cluster_ids
- )
-
- # ensure the sensor entity was created for each id in cluster_ids
- for cluster_id in cluster_ids:
- zigpy_device_info = zigpy_device_infos[cluster_id]
- entity_id = zigpy_device_info[ATTR_ENTITY_ID]
- assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
-
- # allow traffic to flow through the gateway and devices
- await async_enable_traffic(
- hass,
- zha_gateway,
- [
- zigpy_device_info["zha_device"]
- for zigpy_device_info in zigpy_device_infos.values()
- ],
- )
-
- # test that the sensors now have a state of unknown
- for cluster_id in cluster_ids:
- zigpy_device_info = zigpy_device_infos[cluster_id]
- entity_id = zigpy_device_info[ATTR_ENTITY_ID]
- assert hass.states.get(entity_id).state == STATE_UNKNOWN
-
- # get the humidity device info and test the associated sensor logic
- device_info = zigpy_device_infos[measurement.RelativeHumidity.cluster_id]
- await async_test_humidity(hass, device_info)
-
- # get the temperature device info and test the associated sensor logic
- device_info = zigpy_device_infos[measurement.TemperatureMeasurement.cluster_id]
- await async_test_temperature(hass, device_info)
-
- # get the pressure device info and test the associated sensor logic
- device_info = zigpy_device_infos[measurement.PressureMeasurement.cluster_id]
- await async_test_pressure(hass, device_info)
-
- # get the illuminance device info and test the associated sensor logic
- device_info = zigpy_device_infos[measurement.IlluminanceMeasurement.cluster_id]
- await async_test_illuminance(hass, device_info)
-
- # get the metering device info and test the associated sensor logic
- device_info = zigpy_device_infos[smartenergy.Metering.cluster_id]
- await async_test_metering(hass, device_info)
-
- # get the electrical_measurement device info and test the associated
- # sensor logic
- device_info = zigpy_device_infos[homeautomation.ElectricalMeasurement.cluster_id]
- await async_test_electrical_measurement(hass, device_info)
-
- # test joining a new temperature sensor to the network
- await async_test_device_join(
- hass, zha_gateway, measurement.TemperatureMeasurement.cluster_id, entity_id
- )
-
-
-async def async_build_devices(hass, zha_gateway, config_entry, cluster_ids):
- """Build a zigpy device for each cluster id.
-
- This will build devices for all cluster ids that exist in cluster_ids.
- They get added to the network and then the sensor component is loaded
- which will cause sensor entites to get created for each device.
- A dict containing relevant device info for testing is returned. It contains
- the entity id, zigpy device, and the zigbee cluster for the sensor.
- """
-
- device_infos = {}
- counter = 0
- for cluster_id in cluster_ids:
- # create zigpy device
- device_infos[cluster_id] = {"zigpy_device": None}
- device_infos[cluster_id]["zigpy_device"] = await async_init_zigpy_device(
- hass,
- [cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
- ieee=f"00:15:8d:00:02:32:4f:0{counter}",
- manufacturer=f"Fake{cluster_id}",
- model=f"FakeModel{cluster_id}",
- )
-
- counter += 1
-
- # load up sensor domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
-
- # put the other relevant info in the device info dict
- for cluster_id in cluster_ids:
- device_info = device_infos[cluster_id]
- zigpy_device = device_info["zigpy_device"]
- device_info["cluster"] = zigpy_device.endpoints.get(1).in_clusters[cluster_id]
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
- device_info["zha_device"] = zha_device
- device_info[ATTR_ENTITY_ID] = await find_entity_id(DOMAIN, zha_device, hass)
- await hass.async_block_till_done()
- return device_infos
-
-
-async def async_test_humidity(hass, device_info):
+async def async_test_humidity(hass, cluster, entity_id):
"""Test humidity sensor."""
- await send_attribute_report(hass, device_info["cluster"], 0, 1000)
- assert_state(hass, device_info, "10.0", "%")
+ await send_attribute_report(hass, cluster, 0, 1000)
+ assert_state(hass, entity_id, "10.0", "%")
-async def async_test_temperature(hass, device_info):
+async def async_test_temperature(hass, cluster, entity_id):
"""Test temperature sensor."""
- await send_attribute_report(hass, device_info["cluster"], 0, 2900)
- assert_state(hass, device_info, "29.0", "°C")
+ await send_attribute_report(hass, cluster, 0, 2900)
+ assert_state(hass, entity_id, "29.0", "°C")
-async def async_test_pressure(hass, device_info):
+async def async_test_pressure(hass, cluster, entity_id):
"""Test pressure sensor."""
- await send_attribute_report(hass, device_info["cluster"], 0, 1000)
- assert_state(hass, device_info, "1000", "hPa")
+ await send_attribute_report(hass, cluster, 0, 1000)
+ assert_state(hass, entity_id, "1000", "hPa")
-async def async_test_illuminance(hass, device_info):
+async def async_test_illuminance(hass, cluster, entity_id):
"""Test illuminance sensor."""
- await send_attribute_report(hass, device_info["cluster"], 0, 10)
- assert_state(hass, device_info, "1.0", "lx")
+ await send_attribute_report(hass, cluster, 0, 10)
+ assert_state(hass, entity_id, "1.0", "lx")
-async def async_test_metering(hass, device_info):
+async def async_test_metering(hass, cluster, entity_id):
"""Test metering sensor."""
- await send_attribute_report(hass, device_info["cluster"], 1024, 12345)
- assert_state(hass, device_info, "12345.0", "unknown")
+ await send_attribute_report(hass, cluster, 1024, 12345)
+ assert_state(hass, entity_id, "12345.0", "unknown")
-async def async_test_electrical_measurement(hass, device_info):
+async def async_test_electrical_measurement(hass, cluster, entity_id):
"""Test electrical measurement sensor."""
with mock.patch(
(
@@ -189,18 +72,72 @@ async def async_test_electrical_measurement(hass, device_info):
new_callable=mock.PropertyMock,
) as divisor_mock:
divisor_mock.return_value = 1
- await send_attribute_report(hass, device_info["cluster"], 1291, 100)
- assert_state(hass, device_info, "100", "W")
+ await send_attribute_report(hass, cluster, 1291, 100)
+ assert_state(hass, entity_id, "100", "W")
- await send_attribute_report(hass, device_info["cluster"], 1291, 99)
- assert_state(hass, device_info, "99", "W")
+ await send_attribute_report(hass, cluster, 1291, 99)
+ assert_state(hass, entity_id, "99", "W")
divisor_mock.return_value = 10
- await send_attribute_report(hass, device_info["cluster"], 1291, 1000)
- assert_state(hass, device_info, "100", "W")
+ await send_attribute_report(hass, cluster, 1291, 1000)
+ assert_state(hass, entity_id, "100", "W")
+
+ await send_attribute_report(hass, cluster, 1291, 99)
+ assert_state(hass, entity_id, "9.9", "W")
- await send_attribute_report(hass, device_info["cluster"], 1291, 99)
- assert_state(hass, device_info, "9.9", "W")
+
+@pytest.mark.parametrize(
+ "cluster_id, test_func, report_count",
+ (
+ (measurement.RelativeHumidity.cluster_id, async_test_humidity, 1),
+ (measurement.TemperatureMeasurement.cluster_id, async_test_temperature, 1),
+ (measurement.PressureMeasurement.cluster_id, async_test_pressure, 1),
+ (measurement.IlluminanceMeasurement.cluster_id, async_test_illuminance, 1),
+ (smartenergy.Metering.cluster_id, async_test_metering, 1),
+ (
+ homeautomation.ElectricalMeasurement.cluster_id,
+ async_test_electrical_measurement,
+ 1,
+ ),
+ ),
+)
+async def test_sensor(
+ hass,
+ zigpy_device_mock,
+ zha_device_joined_restored,
+ cluster_id,
+ test_func,
+ report_count,
+):
+ """Test zha sensor platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [cluster_id, general.Basic.cluster_id],
+ "out_cluster": [],
+ "device_type": 0x0000,
+ }
+ }
+ )
+ cluster = zigpy_device.endpoints[1].in_clusters[cluster_id]
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ entity_id = await find_entity_id(DOMAIN, zha_device, hass)
+
+ # ensure the sensor entity was created
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and devices
+ await async_enable_traffic(hass, [zha_device])
+
+ # test that the sensor now have a state of unknown
+ assert hass.states.get(entity_id).state == STATE_UNKNOWN
+
+ # test sensor associated logic
+ await test_func(hass, cluster, entity_id)
+
+ # test rejoin
+ await async_test_rejoin(hass, zigpy_device, [cluster], (report_count,))
async def send_attribute_report(hass, cluster, attrid, value):
@@ -215,13 +152,13 @@ async def send_attribute_report(hass, cluster, attrid, value):
await hass.async_block_till_done()
-def assert_state(hass, device_info, state, unit_of_measurement):
+def assert_state(hass, entity_id, state, unit_of_measurement):
"""Check that the state is what is expected.
This is used to ensure that the logic in each sensor class handled the
attribute report it received correctly.
"""
- hass_state = hass.states.get(device_info[ATTR_ENTITY_ID])
+ hass_state = hass.states.get(entity_id)
assert hass_state.state == state
assert hass_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement
@@ -282,7 +219,14 @@ def _storage(entity_id, uom, state):
],
)
async def test_temp_uom(
- uom, raw_temp, expected, restore, hass_ms, config_entry, zha_gateway, core_rs
+ uom,
+ raw_temp,
+ expected,
+ restore,
+ hass_ms,
+ core_rs,
+ zigpy_device_mock,
+ zha_device_restored,
):
"""Test zha temperature sensor unit of measurement."""
@@ -294,28 +238,33 @@ async def test_temp_uom(
CONF_UNIT_SYSTEM_METRIC if uom == TEMP_CELSIUS else CONF_UNIT_SYSTEM_IMPERIAL
)
- # list of cluster ids to create devices and sensor entities for
- temp_cluster = measurement.TemperatureMeasurement
- cluster_ids = [temp_cluster.cluster_id]
-
- # devices that were created from cluster_ids list above
- zigpy_device_infos = await async_build_devices(
- hass, zha_gateway, config_entry, cluster_ids
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ measurement.TemperatureMeasurement.cluster_id,
+ general.Basic.cluster_id,
+ ],
+ "out_cluster": [],
+ "device_type": 0x0000,
+ }
+ }
)
+ cluster = zigpy_device.endpoints[1].temperature
+ zha_device = await zha_device_restored(zigpy_device)
+ entity_id = await find_entity_id(DOMAIN, zha_device, hass)
- zigpy_device_info = zigpy_device_infos[temp_cluster.cluster_id]
- zha_device = zigpy_device_info["zha_device"]
if not restore:
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and devices
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ await async_enable_traffic(hass, [zha_device])
# test that the sensors now have a state of unknown
if not restore:
assert hass.states.get(entity_id).state == STATE_UNKNOWN
- await send_attribute_report(hass, zigpy_device_info["cluster"], 0, raw_temp)
+ await send_attribute_report(hass, cluster, 0, raw_temp)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py
index 11a0b8f3481831..a088283834b6bd 100644
--- a/tests/components/zha/test_switch.py
+++ b/tests/components/zha/test_switch.py
@@ -1,6 +1,7 @@
"""Test zha switch."""
from unittest.mock import call, patch
+import pytest
import zigpy.zcl.clusters.general as general
import zigpy.zcl.foundation as zcl_f
@@ -9,8 +10,7 @@
from .common import (
async_enable_traffic,
- async_init_zigpy_device,
- async_test_device_join,
+ async_test_rejoin,
find_entity_id,
make_attribute,
make_zcl_header,
@@ -22,24 +22,24 @@
OFF = 0
-async def test_switch(hass, config_entry, zha_gateway):
- """Test zha switch platform."""
+@pytest.fixture
+def zigpy_device(zigpy_device_mock):
+ """Device tracker zigpy device."""
+ endpoints = {
+ 1: {
+ "in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id],
+ "out_clusters": [],
+ "device_type": 0,
+ }
+ }
+ return zigpy_device_mock(endpoints)
- # create zigpy device
- zigpy_device = await async_init_zigpy_device(
- hass,
- [general.OnOff.cluster_id, general.Basic.cluster_id],
- [],
- None,
- zha_gateway,
- )
-
- # load up switch domain
- await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
- await hass.async_block_till_done()
+async def test_switch(hass, zha_device_joined_restored, zigpy_device):
+ """Test zha switch platform."""
+
+ zha_device = await zha_device_joined_restored(zigpy_device)
cluster = zigpy_device.endpoints.get(1).on_off
- zha_device = zha_gateway.get_device(zigpy_device.ieee)
entity_id = await find_entity_id(DOMAIN, zha_device, hass)
assert entity_id is not None
@@ -47,7 +47,7 @@ async def test_switch(hass, config_entry, zha_gateway):
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
- await async_enable_traffic(hass, zha_gateway, [zha_device])
+ await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
@@ -94,4 +94,4 @@ async def test_switch(hass, config_entry, zha_gateway):
)
# test joining a new switch to the network and HA
- await async_test_device_join(hass, zha_gateway, general.OnOff.cluster_id, entity_id)
+ await async_test_rejoin(hass, zigpy_device, [cluster], (1,))
diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py
index d5875edc9e2ac6..a3dc4f1d7801a3 100644
--- a/tests/components/zha/zha_devices_list.py
+++ b/tests/components/zha/zha_devices_list.py
@@ -2,8 +2,9 @@
DEVICES = [
{
+ "device_no": 0,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2080,
"endpoint_id": 1,
"in_clusters": [0, 3, 4096, 64716],
@@ -12,13 +13,17 @@
}
},
"entities": [],
- "event_channels": [6, 8],
+ "entity_map": {},
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "ADUROLIGHT",
"model": "Adurolight_NCC",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ "zha_quirks": "AdurolightNCC",
},
{
+ "device_no": 1,
"endpoints": {
- "5": {
+ 5: {
"device_type": 1026,
"endpoint_id": 5,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
@@ -31,13 +36,32 @@
"sensor.bosch_isw_zpr1_wp13_77665544_power",
"sensor.bosch_isw_zpr1_wp13_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-5-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.bosch_isw_zpr1_wp13_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-5-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.bosch_isw_zpr1_wp13_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "Bosch",
"model": "ISW-ZPR1-WP13",
+ "node_descriptor": b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00",
},
{
+ "device_no": 2,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 2821],
@@ -45,17 +69,24 @@
"profile_id": 260,
}
},
- "entities": [
- "binary_sensor.centralite_3130_77665544_on_off",
- "sensor.centralite_3130_77665544_power",
- ],
- "event_channels": [6, 8],
+ "entities": ["sensor.centralite_3130_77665544_power"],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.centralite_3130_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "CentraLite",
"model": "3130",
+ "node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLite3130",
},
{
+ "device_no": 3,
"endpoints": {
- "1": {
+ 1: {
"device_type": 81,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515],
@@ -64,17 +95,36 @@
}
},
"entities": [
- "sensor.centralite_3210_l_77665544_smartenergy_metering",
"sensor.centralite_3210_l_77665544_electrical_measurement",
+ "sensor.centralite_3210_l_77665544_smartenergy_metering",
"switch.centralite_3210_l_77665544_on_off",
],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.centralite_3210_l_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.centralite_3210_l_77665544_smartenergy_metering",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.centralite_3210_l_77665544_electrical_measurement",
+ },
+ },
"event_channels": [],
"manufacturer": "CentraLite",
"model": "3210-L",
+ "node_descriptor": b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 4,
"endpoints": {
- "1": {
+ 1: {
"device_type": 770,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 2821, 64581],
@@ -83,24 +133,44 @@
}
},
"entities": [
+ "sensor.centralite_3310_s_77665544_manufacturer_specific",
"sensor.centralite_3310_s_77665544_power",
"sensor.centralite_3310_s_77665544_temperature",
- "sensor.centralite_3310_s_77665544_manufacturer_specific",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.centralite_3310_s_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.centralite_3310_s_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-64581"): {
+ "channels": ["manufacturer_specific"],
+ "entity_class": "Humidity",
+ "entity_id": "sensor.centralite_3310_s_77665544_manufacturer_specific",
+ },
+ },
"event_channels": [],
"manufacturer": "CentraLite",
"model": "3310-S",
+ "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLite3310S",
},
{
+ "device_no": 5,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
"out_clusters": [25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 12,
"endpoint_id": 2,
"in_clusters": [0, 3, 2821, 64527],
@@ -110,23 +180,43 @@
},
"entities": [
"binary_sensor.centralite_3315_s_77665544_ias_zone",
- "sensor.centralite_3315_s_77665544_temperature",
"sensor.centralite_3315_s_77665544_power",
+ "sensor.centralite_3315_s_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.centralite_3315_s_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.centralite_3315_s_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.centralite_3315_s_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "CentraLite",
"model": "3315-S",
+ "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLiteIASSensor",
},
{
+ "device_no": 6,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
"out_clusters": [25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 12,
"endpoint_id": 2,
"in_clusters": [0, 3, 2821, 64527],
@@ -136,23 +226,43 @@
},
"entities": [
"binary_sensor.centralite_3320_l_77665544_ias_zone",
- "sensor.centralite_3320_l_77665544_temperature",
"sensor.centralite_3320_l_77665544_power",
+ "sensor.centralite_3320_l_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.centralite_3320_l_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.centralite_3320_l_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.centralite_3320_l_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "CentraLite",
"model": "3320-L",
+ "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLiteIASSensor",
},
{
+ "device_no": 7,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
"out_clusters": [25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 263,
"endpoint_id": 2,
"in_clusters": [0, 3, 2821, 64582],
@@ -162,23 +272,43 @@
},
"entities": [
"binary_sensor.centralite_3326_l_77665544_ias_zone",
- "sensor.centralite_3326_l_77665544_temperature",
"sensor.centralite_3326_l_77665544_power",
+ "sensor.centralite_3326_l_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.centralite_3326_l_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.centralite_3326_l_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.centralite_3326_l_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "CentraLite",
"model": "3326-L",
+ "node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLiteMotionSensor",
},
{
+ "device_no": 8,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
"out_clusters": [25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 263,
"endpoint_id": 2,
"in_clusters": [0, 3, 1030, 2821],
@@ -187,25 +317,50 @@
},
},
"entities": [
- "binary_sensor.centralite_motion_sensor_a_77665544_occupancy",
"binary_sensor.centralite_motion_sensor_a_77665544_ias_zone",
- "sensor.centralite_motion_sensor_a_77665544_temperature",
+ "binary_sensor.centralite_motion_sensor_a_77665544_occupancy",
"sensor.centralite_motion_sensor_a_77665544_power",
+ "sensor.centralite_motion_sensor_a_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.centralite_motion_sensor_a_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.centralite_motion_sensor_a_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): {
+ "channels": ["occupancy"],
+ "entity_class": "Occupancy",
+ "entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_occupancy",
+ },
+ },
"event_channels": [],
"manufacturer": "CentraLite",
"model": "Motion Sensor-A",
+ "node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLite3305S",
},
{
+ "device_no": 9,
"endpoints": {
- "1": {
+ 1: {
"device_type": 81,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 1794],
"out_clusters": [0],
"profile_id": 260,
},
- "4": {
+ 4: {
"device_type": 9,
"endpoint_id": 4,
"in_clusters": [],
@@ -217,13 +372,27 @@
"sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering",
"switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off",
],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering",
+ },
+ },
"event_channels": [],
"manufacturer": "ClimaxTechnology",
"model": "PSMP5_00.00.02.02TC",
+ "node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00",
},
{
+ "device_no": 10,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 3, 1280, 1282],
@@ -234,13 +403,22 @@
"entities": [
"binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone"
],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone",
+ }
+ },
"event_channels": [],
"manufacturer": "ClimaxTechnology",
"model": "SD8SC_00.00.03.12TC",
+ "node_descriptor": b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00",
},
{
+ "device_no": 11,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 3, 1280],
@@ -251,20 +429,29 @@
"entities": [
"binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone"
],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone",
+ }
+ },
"event_channels": [],
"manufacturer": "ClimaxTechnology",
"model": "WS15_00.00.03.03TC",
+ "node_descriptor": b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00",
},
{
+ "device_no": 12,
"endpoints": {
- "11": {
+ 11: {
"device_type": 528,
"endpoint_id": 11,
"in_clusters": [0, 3, 4, 5, 6, 8, 768],
"out_clusters": [],
"profile_id": 49246,
},
- "13": {
+ 13: {
"device_type": 57694,
"endpoint_id": 13,
"in_clusters": [4096],
@@ -275,13 +462,78 @@
"entities": [
"light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-11"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "Feibit Inc co.",
"model": "FB56-ZCW08KU1.1",
+ "node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00",
+ },
+ {
+ "device_no": 13,
+ "endpoints": {
+ 1: {
+ "device_type": 1026,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 1280, 1282],
+ "out_clusters": [25],
+ "profile_id": 260,
+ }
+ },
+ "entities": [
+ "binary_sensor.heiman_smokesensor_em_77665544_ias_zone",
+ "sensor.heiman_smokesensor_em_77665544_power",
+ ],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.heiman_smokesensor_em_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.heiman_smokesensor_em_77665544_ias_zone",
+ },
+ },
+ "event_channels": [],
+ "manufacturer": "HEIMAN",
+ "model": "SmokeSensor-EM",
+ "node_descriptor": b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00",
+ },
+ {
+ "device_no": 14,
+ "endpoints": {
+ 1: {
+ "device_type": 1026,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 9, 1280],
+ "out_clusters": [25],
+ "profile_id": 260,
+ }
+ },
+ "entities": ["binary_sensor.heiman_co_v16_77665544_ias_zone"],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.heiman_co_v16_77665544_ias_zone",
+ }
+ },
+ "event_channels": [],
+ "manufacturer": "Heiman",
+ "model": "CO_V16",
+ "node_descriptor": b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
},
{
+ "device_no": 15,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1027,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 4, 9, 1280, 1282],
@@ -289,17 +541,23 @@
"profile_id": 260,
}
},
- "entities": [
- "binary_sensor.heiman_warningdevice_77665544_ias_zone",
- "sensor.heiman_warningdevice_77665544_power",
- ],
+ "entities": ["binary_sensor.heiman_warningdevice_77665544_ias_zone"],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.heiman_warningdevice_77665544_ias_zone",
+ }
+ },
"event_channels": [],
"manufacturer": "Heiman",
"model": "WarningDevice",
+ "node_descriptor": b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 16,
"endpoints": {
- "6": {
+ 6: {
"device_type": 1026,
"endpoint_id": 6,
"in_clusters": [0, 1, 3, 32, 1024, 1026, 1280],
@@ -308,25 +566,50 @@
}
},
"entities": [
- "sensor.hivehome_com_mot003_77665544_temperature",
- "sensor.hivehome_com_mot003_77665544_power",
- "sensor.hivehome_com_mot003_77665544_illuminance",
"binary_sensor.hivehome_com_mot003_77665544_ias_zone",
+ "sensor.hivehome_com_mot003_77665544_illuminance",
+ "sensor.hivehome_com_mot003_77665544_power",
+ "sensor.hivehome_com_mot003_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-6-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.hivehome_com_mot003_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-6-1024"): {
+ "channels": ["illuminance"],
+ "entity_class": "Illuminance",
+ "entity_id": "sensor.hivehome_com_mot003_77665544_illuminance",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-6-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.hivehome_com_mot003_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.hivehome_com_mot003_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "HiveHome.com",
"model": "MOT003",
+ "node_descriptor": b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00",
+ "zha_quirks": "MOT003",
},
{
+ "device_no": 17,
"endpoints": {
- "1": {
+ 1: {
"device_type": 268,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 4096, 64636],
"out_clusters": [5, 25, 32, 4096],
"profile_id": 260,
},
- "242": {
+ 242: {
"device_type": 97,
"endpoint_id": 242,
"in_clusters": [33],
@@ -337,13 +620,22 @@
"entities": [
"light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E12 WS opal 600lm",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00",
},
{
+ "device_no": 18,
"endpoints": {
- "1": {
+ 1: {
"device_type": 512,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 4096],
@@ -354,13 +646,22 @@
"entities": [
"light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 CWS opal 600lm",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 19,
"endpoints": {
- "1": {
+ 1: {
"device_type": 256,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 2821, 4096],
@@ -371,13 +672,22 @@
"entities": [
"light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 W opal 1000lm",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 20,
"endpoints": {
- "1": {
+ 1: {
"device_type": 544,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 4096],
@@ -388,13 +698,22 @@
"entities": [
"light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 WS opal 980lm",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 21,
"endpoints": {
- "1": {
+ 1: {
"device_type": 256,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 2821, 4096],
@@ -405,13 +724,22 @@
"entities": [
"light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 opal 1000lm",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 22,
"endpoints": {
- "1": {
+ 1: {
"device_type": 266,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 64636],
@@ -420,13 +748,23 @@
}
},
"entities": ["switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off"],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI control outlet",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00",
+ "zha_quirks": "TradfriPlug",
},
{
+ "device_no": 23,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2128,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 9, 2821, 4096],
@@ -438,13 +776,28 @@
"binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off",
"sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power",
],
- "event_channels": [6],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off",
+ },
+ },
+ "event_channels": ["1:0x0006"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI motion sensor",
+ "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "IkeaTradfriMotion",
},
{
+ "device_no": 24,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2080,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 9, 32, 4096, 64636],
@@ -453,13 +806,23 @@
}
},
"entities": ["sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power"],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI on/off switch",
+ "node_descriptor": b"\x02@\x80|\x11RR\x00\x00,R\x00\x00",
+ "zha_quirks": "IkeaTradfriRemote2Btn",
},
{
+ "device_no": 25,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2096,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 9, 2821, 4096],
@@ -468,20 +831,30 @@
}
},
"entities": ["sensor.ikea_of_sweden_tradfri_remote_control_77665544_power"],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI remote control",
+ "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "IkeaTradfriRemote",
},
{
+ "device_no": 26,
"endpoints": {
- "1": {
+ 1: {
"device_type": 8,
"endpoint_id": 1,
"in_clusters": [0, 3, 9, 2821, 4096, 64636],
"out_clusters": [25, 32, 4096],
"profile_id": 260,
},
- "242": {
+ 242: {
"device_type": 97,
"endpoint_id": 242,
"in_clusters": [33],
@@ -490,13 +863,16 @@
},
},
"entities": [],
+ "entity_map": {},
"event_channels": [],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI signal repeater",
+ "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00",
},
{
+ "device_no": 27,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2064,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 9, 2821, 4096],
@@ -505,20 +881,29 @@
}
},
"entities": ["sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power"],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI wireless dimmer",
+ "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 28,
"endpoints": {
- "1": {
+ 1: {
"device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821],
"out_clusters": [10, 25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 260,
"endpoint_id": 2,
"in_clusters": [0, 3, 2821],
@@ -527,23 +912,37 @@
},
},
"entities": [
- "sensor.jasco_products_45852_77665544_smartenergy_metering",
"light.jasco_products_45852_77665544_level_on_off",
+ "sensor.jasco_products_45852_77665544_smartenergy_metering",
],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.jasco_products_45852_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.jasco_products_45852_77665544_smartenergy_metering",
+ },
+ },
+ "event_channels": ["2:0x0006", "2:0x0008"],
"manufacturer": "Jasco Products",
"model": "45852",
+ "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00",
},
{
+ "device_no": 29,
"endpoints": {
- "1": {
+ 1: {
"device_type": 256,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 1794, 2821],
"out_clusters": [10, 25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 259,
"endpoint_id": 2,
"in_clusters": [0, 3, 2821],
@@ -552,24 +951,37 @@
},
},
"entities": [
- "sensor.jasco_products_45856_77665544_smartenergy_metering",
- "switch.jasco_products_45856_77665544_on_off",
"light.jasco_products_45856_77665544_on_off",
+ "sensor.jasco_products_45856_77665544_smartenergy_metering",
],
- "event_channels": [6],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.jasco_products_45856_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.jasco_products_45856_77665544_smartenergy_metering",
+ },
+ },
+ "event_channels": ["2:0x0006"],
"manufacturer": "Jasco Products",
"model": "45856",
+ "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00",
},
{
+ "device_no": 30,
"endpoints": {
- "1": {
+ 1: {
"device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821],
"out_clusters": [10, 25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 260,
"endpoint_id": 2,
"in_clusters": [0, 3, 2821],
@@ -578,16 +990,30 @@
},
},
"entities": [
- "sensor.jasco_products_45857_77665544_smartenergy_metering",
"light.jasco_products_45857_77665544_level_on_off",
+ "sensor.jasco_products_45857_77665544_smartenergy_metering",
],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.jasco_products_45857_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.jasco_products_45857_77665544_smartenergy_metering",
+ },
+ },
+ "event_channels": ["2:0x0006", "2:0x0008"],
"manufacturer": "Jasco Products",
"model": "45857",
+ "node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00",
},
{
+ "device_no": 31,
"endpoints": {
- "1": {
+ 1: {
"device_type": 3,
"endpoint_id": 1,
"in_clusters": [
@@ -610,19 +1036,42 @@
}
},
"entities": [
- "binary_sensor.keen_home_inc_sv02_610_mp_1_3_77665544_manufacturer_specific",
+ "light.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off",
+ "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power",
"sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure",
"sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature",
- "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power",
- "light.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1027"): {
+ "channels": ["pressure"],
+ "entity_class": "Pressure",
+ "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure",
+ },
+ },
"event_channels": [],
"manufacturer": "Keen Home Inc",
"model": "SV02-610-MP-1.3",
+ "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00",
},
{
+ "device_no": 32,
"endpoints": {
- "1": {
+ 1: {
"device_type": 3,
"endpoint_id": 1,
"in_clusters": [
@@ -645,19 +1094,42 @@
}
},
"entities": [
- "binary_sensor.keen_home_inc_sv02_612_mp_1_2_77665544_manufacturer_specific",
- "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature",
+ "light.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off",
"sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power",
"sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure",
- "light.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off",
+ "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1027"): {
+ "channels": ["pressure"],
+ "entity_class": "Pressure",
+ "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure",
+ },
+ },
"event_channels": [],
"manufacturer": "Keen Home Inc",
"model": "SV02-612-MP-1.2",
+ "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00",
},
{
+ "device_no": 33,
"endpoints": {
- "1": {
+ 1: {
"device_type": 3,
"endpoint_id": 1,
"in_clusters": [
@@ -680,20 +1152,44 @@
}
},
"entities": [
- "binary_sensor.keen_home_inc_sv02_612_mp_1_3_77665544_manufacturer_specific",
- "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure",
+ "light.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off",
"sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power",
+ "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure",
"sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature",
- "light.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1027"): {
+ "channels": ["pressure"],
+ "entity_class": "Pressure",
+ "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure",
+ },
+ },
"event_channels": [],
"manufacturer": "Keen Home Inc",
"model": "SV02-612-MP-1.3",
+ "node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00",
+ "zha_quirks": "KeenHomeSmartVent",
},
{
+ "device_no": 34,
"endpoints": {
- "1": {
- "device_type": 14,
+ 1: {
+ "device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 514],
"out_clusters": [3, 25],
@@ -702,15 +1198,55 @@
},
"entities": [
"fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan",
- "switch.king_of_fans_inc_hbuniversalcfremote_77665544_on_off",
+ "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off",
+ },
+ ("fan", "00:11:22:33:44:55:66:77-1-514"): {
+ "channels": ["fan"],
+ "entity_class": "ZhaFan",
+ "entity_id": "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan",
+ },
+ },
"event_channels": [],
"manufacturer": "King Of Fans, Inc.",
"model": "HBUniversalCFRemote",
+ "node_descriptor": b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CeilingFan",
+ },
+ {
+ "device_no": 35,
+ "endpoints": {
+ 1: {
+ "device_type": 2048,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 4096, 64769],
+ "out_clusters": [3, 4, 6, 8, 25, 768, 4096],
+ "profile_id": 260,
+ }
+ },
+ "entities": ["sensor.lds_zbt_cctswitch_d0001_77665544_power"],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lds_zbt_cctswitch_d0001_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"],
+ "manufacturer": "LDS",
+ "model": "ZBT-CCTSwitch-D0001",
+ "node_descriptor": b"\x02@\x80h\x11RR\x00\x00,R\x00\x00",
+ "zha_quirks": "CCTSwitch",
},
{
+ "device_no": 36,
"endpoints": {
- "1": {
+ 1: {
"device_type": 258,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513],
@@ -719,13 +1255,22 @@
}
},
"entities": ["light.ledvance_a19_rgbw_77665544_level_light_color_on_off"],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ledvance_a19_rgbw_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "LEDVANCE",
"model": "A19 RGBW",
+ "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 37,
"endpoints": {
- "1": {
+ 1: {
"device_type": 258,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513],
@@ -734,13 +1279,22 @@
}
},
"entities": ["light.ledvance_flex_rgbw_77665544_level_light_color_on_off"],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ledvance_flex_rgbw_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "LEDVANCE",
"model": "FLEX RGBW",
+ "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 38,
"endpoints": {
- "1": {
+ 1: {
"device_type": 81,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 2821, 64513, 64520],
@@ -749,13 +1303,22 @@
}
},
"entities": ["switch.ledvance_plug_77665544_on_off"],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.ledvance_plug_77665544_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "LEDVANCE",
"model": "PLUG",
+ "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 39,
"endpoints": {
- "1": {
+ 1: {
"device_type": 258,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513],
@@ -764,62 +1327,95 @@
}
},
"entities": ["light.ledvance_rt_rgbw_77665544_level_light_color_on_off"],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.ledvance_rt_rgbw_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "LEDVANCE",
"model": "RT RGBW",
+ "node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 40,
"endpoints": {
- "1": {
+ 1: {
"device_type": 81,
"endpoint_id": 1,
"in_clusters": [0, 1, 2, 3, 4, 5, 6, 10, 16, 2820],
"out_clusters": [10, 25],
"profile_id": 260,
},
- "100": {
- "device_type": 263,
- "endpoint_id": 100,
- "in_clusters": [15],
- "out_clusters": [4, 15],
- "profile_id": 260,
- },
- "2": {
+ 2: {
"device_type": 9,
"endpoint_id": 2,
"in_clusters": [12],
"out_clusters": [4, 12],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": 83,
"endpoint_id": 3,
"in_clusters": [12],
"out_clusters": [12],
"profile_id": 260,
},
+ 100: {
+ "device_type": 263,
+ "endpoint_id": 100,
+ "in_clusters": [15],
+ "out_clusters": [4, 15],
+ "profile_id": 260,
+ },
},
"entities": [
- "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement",
"sensor.lumi_lumi_plug_maus01_77665544_analog_input",
"sensor.lumi_lumi_plug_maus01_77665544_analog_input_2",
- "sensor.lumi_lumi_plug_maus01_77665544_power",
+ "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement",
"switch.lumi_lumi_plug_maus01_77665544_on_off",
],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.lumi_lumi_plug_maus01_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-2-12"): {
+ "channels": ["analog_input"],
+ "entity_class": "AnalogInput",
+ "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-12"): {
+ "channels": ["analog_input"],
+ "entity_class": "AnalogInput",
+ "entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.plug.maus01",
+ "node_descriptor": b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "Plug",
},
{
+ "device_no": 41,
"endpoints": {
- "1": {
+ 1: {
"device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 1, 2, 3, 4, 5, 6, 10, 12, 16, 2820],
"out_clusters": [10, 25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 257,
"endpoint_id": 2,
"in_clusters": [4, 5, 6, 16],
@@ -828,33 +1424,57 @@
},
},
"entities": [
- "sensor.lumi_lumi_relay_c2acn01_77665544_analog_input",
- "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement",
- "sensor.lumi_lumi_relay_c2acn01_77665544_power",
"light.lumi_lumi_relay_c2acn01_77665544_on_off",
"light.lumi_lumi_relay_c2acn01_77665544_on_off_2",
+ "sensor.lumi_lumi_relay_c2acn01_77665544_analog_input",
+ "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-12"): {
+ "channels": ["analog_input"],
+ "entity_class": "AnalogInput",
+ "entity_id": "sensor.lumi_lumi_relay_c2acn01_77665544_analog_input",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement",
+ },
+ ("light", "00:11:22:33:44:55:66:77-2"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off_2",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.relay.c2acn01",
+ "node_descriptor": b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "Relay",
},
{
+ "device_no": 42,
"endpoints": {
- "1": {
+ 1: {
"device_type": 24321,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 18, 25, 65535],
"out_clusters": [0, 3, 4, 5, 18, 25, 65535],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 24322,
"endpoint_id": 2,
"in_clusters": [3, 18],
"out_clusters": [3, 4, 5, 18],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": 24323,
"endpoint_id": 3,
"in_clusters": [3, 18],
@@ -864,31 +1484,56 @@
},
"entities": [
"sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input",
- "sensor.lumi_lumi_remote_b186acn01_77665544_power",
"sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input_2",
"sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input_3",
+ "sensor.lumi_lumi_remote_b186acn01_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input_2",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-2-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input_3",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.remote.b186acn01",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "RemoteB186ACN01",
},
{
+ "device_no": 43,
"endpoints": {
- "1": {
+ 1: {
"device_type": 24321,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 18, 25, 65535],
"out_clusters": [0, 3, 4, 5, 18, 25, 65535],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 24322,
"endpoint_id": 2,
"in_clusters": [3, 18],
"out_clusters": [3, 4, 5, 18],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": 24323,
"endpoint_id": 3,
"in_clusters": [3, 18],
@@ -898,52 +1543,77 @@
},
"entities": [
"sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input",
- "sensor.lumi_lumi_remote_b286acn01_77665544_power",
"sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input_2",
"sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input_3",
+ "sensor.lumi_lumi_remote_b286acn01_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input_3",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-2-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input_2",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.remote.b286acn01",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "RemoteB286ACN01",
},
{
+ "device_no": 44,
"endpoints": {
- "1": {
+ 1: {
"device_type": 261,
"endpoint_id": 1,
"in_clusters": [0, 1, 3],
"out_clusters": [3, 6, 8, 768],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": -1,
"endpoint_id": 2,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "3": {
+ 3: {
"device_type": -1,
"endpoint_id": 3,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "4": {
+ 4: {
"device_type": -1,
"endpoint_id": 4,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "5": {
+ 5: {
"device_type": -1,
"endpoint_id": 5,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "6": {
+ 6: {
"device_type": -1,
"endpoint_id": 6,
"in_clusters": [],
@@ -951,49 +1621,52 @@
"profile_id": -1,
},
},
- "entities": ["sensor.lumi_lumi_remote_b286opcn01_77665544_power"],
- "event_channels": [6, 8, 768],
+ "entities": [],
+ "entity_map": {},
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"],
"manufacturer": "LUMI",
"model": "lumi.remote.b286opcn01",
+ "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
},
{
+ "device_no": 45,
"endpoints": {
- "1": {
+ 1: {
"device_type": 261,
"endpoint_id": 1,
"in_clusters": [0, 1, 3],
"out_clusters": [3, 6, 8, 768],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 259,
"endpoint_id": 2,
"in_clusters": [3],
"out_clusters": [3, 6],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": -1,
"endpoint_id": 3,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "4": {
+ 4: {
"device_type": -1,
"endpoint_id": 4,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "5": {
+ 5: {
"device_type": -1,
"endpoint_id": 5,
"in_clusters": [],
"out_clusters": [],
"profile_id": -1,
},
- "6": {
+ 6: {
"device_type": -1,
"endpoint_id": 6,
"in_clusters": [],
@@ -1001,52 +1674,70 @@
"profile_id": -1,
},
},
- "entities": [
- "sensor.lumi_lumi_remote_b486opcn01_77665544_power",
- "switch.lumi_lumi_remote_b486opcn01_77665544_on_off",
- ],
- "event_channels": [6, 8, 768, 6],
+ "entities": [],
+ "entity_map": {},
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"],
"manufacturer": "LUMI",
"model": "lumi.remote.b486opcn01",
+ "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
+ },
+ {
+ "device_no": 46,
+ "endpoints": {
+ 1: {
+ "device_type": 261,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3],
+ "out_clusters": [3, 6, 8, 768],
+ "profile_id": 260,
+ }
+ },
+ "entities": [],
+ "entity_map": {},
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"],
+ "manufacturer": "LUMI",
+ "model": "lumi.remote.b686opcn01",
+ "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
},
{
+ "device_no": 47,
"endpoints": {
- "1": {
+ 1: {
"device_type": 261,
"endpoint_id": 1,
"in_clusters": [0, 1, 3],
"out_clusters": [3, 6, 8, 768],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 259,
"endpoint_id": 2,
"in_clusters": [3],
"out_clusters": [3, 6],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": None,
"endpoint_id": 3,
"in_clusters": [],
"out_clusters": [],
"profile_id": None,
},
- "4": {
+ 4: {
"device_type": None,
"endpoint_id": 4,
"in_clusters": [],
"out_clusters": [],
"profile_id": None,
},
- "5": {
+ 5: {
"device_type": None,
"endpoint_id": 5,
"in_clusters": [],
"out_clusters": [],
"profile_id": None,
},
- "6": {
+ 6: {
"device_type": None,
"endpoint_id": 6,
"in_clusters": [],
@@ -1054,46 +1745,210 @@
"profile_id": None,
},
},
- "entities": [
- "sensor.lumi_lumi_remote_b686opcn01_77665544_power",
- "switch.lumi_lumi_remote_b686opcn01_77665544_on_off",
- ],
- "event_channels": [6, 8, 768, 6],
+ "entities": [],
+ "entity_map": {},
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"],
"manufacturer": "LUMI",
"model": "lumi.remote.b686opcn01",
+ "node_descriptor": b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
},
{
+ "device_no": 48,
"endpoints": {
- "8": {
+ 8: {
"device_type": 256,
"endpoint_id": 8,
- "in_clusters": [0, 6, 11, 17],
+ "in_clusters": [0, 6],
"out_clusters": [0, 6],
"profile_id": 260,
}
},
- "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"],
- "event_channels": [6],
+ "entities": [
+ "binary_sensor.lumi_lumi_router_77665544_on_off",
+ "light.lumi_lumi_router_77665544_on_off",
+ ],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off",
+ },
+ ("light", "00:11:22:33:44:55:66:77-8"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.lumi_lumi_router_77665544_on_off",
+ },
+ },
+ "event_channels": ["8:0x0006"],
"manufacturer": "LUMI",
"model": "lumi.router",
+ "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00",
},
{
+ "device_no": 49,
"endpoints": {
- "1": {
- "device_type": 28417,
- "endpoint_id": 1,
- "in_clusters": [0, 1, 3, 25],
- "out_clusters": [0, 3, 4, 5, 18, 25],
- "profile_id": 260,
+ 8: {
+ "device_type": 256,
+ "endpoint_id": 8,
+ "in_clusters": [0, 6, 11, 17],
+ "out_clusters": [0, 6],
+ "profile_id": 260,
+ }
+ },
+ "entities": [
+ "binary_sensor.lumi_lumi_router_77665544_on_off",
+ "light.lumi_lumi_router_77665544_on_off",
+ ],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off",
+ },
+ ("light", "00:11:22:33:44:55:66:77-8"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.lumi_lumi_router_77665544_on_off",
+ },
+ },
+ "event_channels": ["8:0x0006"],
+ "manufacturer": "LUMI",
+ "model": "lumi.router",
+ "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00",
+ },
+ {
+ "device_no": 50,
+ "endpoints": {
+ 8: {
+ "device_type": 256,
+ "endpoint_id": 8,
+ "in_clusters": [0, 6, 17],
+ "out_clusters": [0, 6],
+ "profile_id": 260,
+ }
+ },
+ "entities": [
+ "binary_sensor.lumi_lumi_router_77665544_on_off",
+ "light.lumi_lumi_router_77665544_on_off",
+ ],
+ "entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off",
+ },
+ ("light", "00:11:22:33:44:55:66:77-8"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.lumi_lumi_router_77665544_on_off",
+ },
+ },
+ "event_channels": ["8:0x0006"],
+ "manufacturer": "LUMI",
+ "model": "lumi.router",
+ "node_descriptor": b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00",
+ },
+ {
+ "device_no": 51,
+ "endpoints": {
+ 1: {
+ "device_type": 262,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 1024],
+ "out_clusters": [3],
+ "profile_id": 260,
+ }
+ },
+ "entities": ["sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance"],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1024"): {
+ "channels": ["illuminance"],
+ "entity_class": "Illuminance",
+ "entity_id": "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance",
+ }
+ },
+ "event_channels": [],
+ "manufacturer": "LUMI",
+ "model": "lumi.sen_ill.mgl01",
+ "node_descriptor": b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00",
+ },
+ {
+ "device_no": 52,
+ "endpoints": {
+ 1: {
+ "device_type": 24321,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 18, 25, 65535],
+ "out_clusters": [0, 3, 4, 5, 18, 25, 65535],
+ "profile_id": 260,
+ },
+ 2: {
+ "device_type": 24322,
+ "endpoint_id": 2,
+ "in_clusters": [3, 18],
+ "out_clusters": [3, 4, 5, 18],
+ "profile_id": 260,
+ },
+ 3: {
+ "device_type": 24323,
+ "endpoint_id": 3,
+ "in_clusters": [3, 18],
+ "out_clusters": [3, 4, 5, 12, 18],
+ "profile_id": 260,
+ },
+ },
+ "entities": [
+ "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input",
+ "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input_2",
+ "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input_3",
+ "sensor.lumi_lumi_sensor_86sw1_77665544_power",
+ ],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_power",
},
- "2": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input_3",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-2-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input_2",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input",
+ },
+ },
+ "event_channels": [],
+ "manufacturer": "LUMI",
+ "model": "lumi.sensor_86sw1",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "RemoteB186ACN01",
+ },
+ {
+ "device_no": 53,
+ "endpoints": {
+ 1: {
+ "device_type": 28417,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 25],
+ "out_clusters": [0, 3, 4, 5, 18, 25],
+ "profile_id": 260,
+ },
+ 2: {
"device_type": 28418,
"endpoint_id": 2,
"in_clusters": [3, 18],
"out_clusters": [3, 4, 5, 18],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": 28419,
"endpoint_id": 3,
"in_clusters": [3, 12],
@@ -1106,27 +1961,47 @@
"sensor.lumi_lumi_sensor_cube_aqgl01_77665544_multistate_input",
"sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-2-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_multistate_input",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-12"): {
+ "channels": ["analog_input"],
+ "entity_class": "AnalogInput",
+ "entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_analog_input",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.sensor_cube.aqgl01",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "CubeAQGL01",
},
{
+ "device_no": 54,
"endpoints": {
- "1": {
+ 1: {
"device_type": 24322,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 25, 1026, 1029, 65535],
"out_clusters": [0, 3, 4, 5, 18, 25, 65535],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 24322,
"endpoint_id": 2,
"in_clusters": [3],
"out_clusters": [3, 4, 5, 18],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": 24323,
"endpoint_id": 3,
"in_clusters": [3],
@@ -1135,17 +2010,37 @@
},
},
"entities": [
+ "sensor.lumi_lumi_sensor_ht_77665544_humidity",
"sensor.lumi_lumi_sensor_ht_77665544_power",
"sensor.lumi_lumi_sensor_ht_77665544_temperature",
- "sensor.lumi_lumi_sensor_ht_77665544_humidity",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1029"): {
+ "channels": ["humidity"],
+ "entity_class": "Humidity",
+ "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_humidity",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.sensor_ht",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "Weather",
},
{
+ "device_no": 55,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2128,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 25, 65535],
@@ -1154,16 +2049,31 @@
}
},
"entities": [
- "sensor.lumi_lumi_sensor_magnet_77665544_power",
"binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off",
+ "sensor.lumi_lumi_sensor_magnet_77665544_power",
],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_magnet_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off",
+ },
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "LUMI",
"model": "lumi.sensor_magnet",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "Magnet",
},
{
+ "device_no": 56,
"endpoints": {
- "1": {
+ 1: {
"device_type": 24321,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 65535],
@@ -1175,13 +2085,28 @@
"binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off",
"sensor.lumi_lumi_sensor_magnet_aq2_77665544_power",
],
- "event_channels": [6],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off",
+ },
+ },
+ "event_channels": ["1:0x0006"],
"manufacturer": "LUMI",
"model": "lumi.sensor_magnet.aq2",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "MagnetAQ2",
},
{
+ "device_no": 57,
"endpoints": {
- "1": {
+ 1: {
"device_type": 263,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 1024, 1030, 1280, 65535],
@@ -1190,18 +2115,88 @@
}
},
"entities": [
- "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy",
"binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone",
+ "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy",
"sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance",
"sensor.lumi_lumi_sensor_motion_aq2_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_motion_aq2_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1024"): {
+ "channels": ["illuminance"],
+ "entity_class": "Illuminance",
+ "entity_id": "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): {
+ "channels": ["occupancy"],
+ "entity_class": "Occupancy",
+ "entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.sensor_motion.aq2",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "MotionAQ2",
+ },
+ {
+ "device_no": 58,
+ "endpoints": {
+ 1: {
+ "device_type": 1026,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 12, 18, 1280],
+ "out_clusters": [25],
+ "profile_id": 260,
+ }
+ },
+ "entities": [
+ "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone",
+ "sensor.lumi_lumi_sensor_smoke_77665544_analog_input",
+ "sensor.lumi_lumi_sensor_smoke_77665544_multistate_input",
+ "sensor.lumi_lumi_sensor_smoke_77665544_power",
+ ],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_smoke_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-12"): {
+ "channels": ["analog_input"],
+ "entity_class": "AnalogInput",
+ "entity_id": "sensor.lumi_lumi_sensor_smoke_77665544_analog_input",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_sensor_smoke_77665544_multistate_input",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone",
+ },
+ },
+ "event_channels": [],
+ "manufacturer": "LUMI",
+ "model": "lumi.sensor_smoke",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "MijiaHoneywellSmokeDetectorSensor",
},
{
+ "device_no": 59,
"endpoints": {
- "1": {
+ 1: {
"device_type": 6,
"endpoint_id": 1,
"in_clusters": [0, 1, 3],
@@ -1210,13 +2205,23 @@
}
},
"entities": ["sensor.lumi_lumi_sensor_switch_77665544_power"],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_switch_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "LUMI",
"model": "lumi.sensor_switch",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "MijaButton",
},
{
+ "device_no": 60,
"endpoints": {
- "1": {
+ 1: {
"device_type": 6,
"endpoint_id": 1,
"in_clusters": [0, 1, 65535],
@@ -1225,13 +2230,23 @@
}
},
"entities": ["sensor.lumi_lumi_sensor_switch_aq2_77665544_power"],
- "event_channels": [6],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_switch_aq2_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006"],
"manufacturer": "LUMI",
"model": "lumi.sensor_switch.aq2",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "SwitchAQ2",
},
{
+ "device_no": 61,
"endpoints": {
- "1": {
+ 1: {
"device_type": 6,
"endpoint_id": 1,
"in_clusters": [0, 1, 18],
@@ -1243,13 +2258,28 @@
"sensor.lumi_lumi_sensor_switch_aq3_77665544_multistate_input",
"sensor.lumi_lumi_sensor_switch_aq3_77665544_power",
],
- "event_channels": [6],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_switch_aq3_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-18"): {
+ "channels": ["multistate_input"],
+ "entity_class": "Text",
+ "entity_id": "sensor.lumi_lumi_sensor_switch_aq3_77665544_multistate_input",
+ },
+ },
+ "event_channels": ["1:0x0006"],
"manufacturer": "LUMI",
"model": "lumi.sensor_switch.aq3",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "SwitchAQ3",
},
{
+ "device_no": 62,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 1280],
@@ -1261,20 +2291,35 @@
"binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone",
"sensor.lumi_lumi_sensor_wleak_aq1_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.sensor_wleak.aq1",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "LeakAQ1",
},
{
+ "device_no": 63,
"endpoints": {
- "1": {
+ 1: {
"device_type": 10,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 25, 257, 1280],
"out_clusters": [0, 3, 4, 5, 25],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 24322,
"endpoint_id": 2,
"in_clusters": [3],
@@ -1284,16 +2329,36 @@
},
"entities": [
"binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone",
- "sensor.lumi_lumi_vibration_aq1_77665544_power",
"lock.lumi_lumi_vibration_aq1_77665544_door_lock",
+ "sensor.lumi_lumi_vibration_aq1_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_vibration_aq1_77665544_power",
+ },
+ ("lock", "00:11:22:33:44:55:66:77-1-257"): {
+ "channels": ["door_lock"],
+ "entity_class": "ZhaDoorLock",
+ "entity_id": "lock.lumi_lumi_vibration_aq1_77665544_door_lock",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.vibration.aq1",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "VibrationAQ1",
},
{
+ "device_no": 64,
"endpoints": {
- "1": {
+ 1: {
"device_type": 24321,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 1026, 1027, 1029, 65535],
@@ -1302,18 +2367,43 @@
}
},
"entities": [
- "sensor.lumi_lumi_weather_77665544_temperature",
- "sensor.lumi_lumi_weather_77665544_power",
"sensor.lumi_lumi_weather_77665544_humidity",
+ "sensor.lumi_lumi_weather_77665544_power",
"sensor.lumi_lumi_weather_77665544_pressure",
+ "sensor.lumi_lumi_weather_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.lumi_lumi_weather_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.lumi_lumi_weather_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1027"): {
+ "channels": ["pressure"],
+ "entity_class": "Pressure",
+ "entity_id": "sensor.lumi_lumi_weather_77665544_pressure",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1029"): {
+ "channels": ["humidity"],
+ "entity_class": "Humidity",
+ "entity_id": "sensor.lumi_lumi_weather_77665544_humidity",
+ },
+ },
"event_channels": [],
"manufacturer": "LUMI",
"model": "lumi.weather",
+ "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
+ "zha_quirks": "Weather",
},
{
+ "device_no": 65,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1280],
@@ -1325,13 +2415,27 @@
"binary_sensor.nyce_3010_77665544_ias_zone",
"sensor.nyce_3010_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.nyce_3010_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.nyce_3010_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "NYCE",
"model": "3010",
+ "node_descriptor": b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 66,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1280],
@@ -1343,13 +2447,70 @@
"binary_sensor.nyce_3014_77665544_ias_zone",
"sensor.nyce_3014_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.nyce_3014_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.nyce_3014_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "NYCE",
"model": "3014",
+ "node_descriptor": b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00",
+ },
+ {
+ "device_no": 67,
+ "endpoints": {
+ 1: {
+ "device_type": 5,
+ "endpoint_id": 1,
+ "in_clusters": [10, 25],
+ "out_clusters": [1280],
+ "profile_id": 260,
+ },
+ 242: {
+ "device_type": 100,
+ "endpoint_id": 242,
+ "in_clusters": [],
+ "out_clusters": [33],
+ "profile_id": 41440,
+ },
+ },
+ "entities": [],
+ "entity_map": {},
+ "event_channels": [],
+ "manufacturer": None,
+ "model": None,
+ "node_descriptor": b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00",
+ },
+ {
+ "device_no": 68,
+ "endpoints": {
+ 1: {
+ "device_type": 48879,
+ "endpoint_id": 1,
+ "in_clusters": [],
+ "out_clusters": [1280],
+ "profile_id": 260,
+ }
+ },
+ "entities": [],
+ "entity_map": {},
+ "event_channels": [],
+ "manufacturer": None,
+ "model": None,
+ "node_descriptor": b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00",
},
{
+ "device_no": 69,
"endpoints": {
- "3": {
+ 3: {
"device_type": 258,
"endpoint_id": 3,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 64527],
@@ -1358,13 +2519,23 @@
}
},
"entities": ["light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off"],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-3"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "OSRAM",
"model": "LIGHTIFY A19 RGBW",
+ "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
+ "zha_quirks": "LIGHTIFYA19RGBW",
},
{
+ "device_no": 70,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 2821],
@@ -1372,17 +2543,24 @@
"profile_id": 260,
}
},
- "entities": [
- "binary_sensor.osram_lightify_dimming_switch_77665544_on_off",
- "sensor.osram_lightify_dimming_switch_77665544_power",
- ],
- "event_channels": [6, 8],
+ "entities": ["sensor.osram_lightify_dimming_switch_77665544_power"],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.osram_lightify_dimming_switch_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "OSRAM",
"model": "LIGHTIFY Dimming Switch",
+ "node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "CentraLite3130",
},
{
+ "device_no": 71,
"endpoints": {
- "3": {
+ 3: {
"device_type": 258,
"endpoint_id": 3,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 64527],
@@ -1393,13 +2571,23 @@
"entities": [
"light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off"
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-3"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "OSRAM",
"model": "LIGHTIFY Flex RGBW",
+ "node_descriptor": b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
+ "zha_quirks": "FlexRGBW",
},
{
+ "device_no": 72,
"endpoints": {
- "3": {
+ 3: {
"device_type": 258,
"endpoint_id": 3,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 2820, 64527],
@@ -1408,16 +2596,31 @@
}
},
"entities": [
- "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement",
"light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off",
+ "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-3"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement",
+ },
+ },
"event_channels": [],
"manufacturer": "OSRAM",
"model": "LIGHTIFY RT Tunable White",
+ "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
+ "zha_quirks": "A19TunableWhite",
},
{
+ "device_no": 73,
"endpoints": {
- "3": {
+ 3: {
"device_type": 16,
"endpoint_id": 3,
"in_clusters": [0, 3, 4, 5, 6, 2820, 4096, 64527],
@@ -1429,48 +2632,62 @@
"sensor.osram_plug_01_77665544_electrical_measurement",
"switch.osram_plug_01_77665544_on_off",
],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-3"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.osram_plug_01_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-3-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.osram_plug_01_77665544_electrical_measurement",
+ },
+ },
"event_channels": [],
"manufacturer": "OSRAM",
"model": "Plug 01",
+ "node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
},
{
+ "device_no": 74,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2064,
"endpoint_id": 1,
"in_clusters": [0, 1, 32, 4096, 64768],
"out_clusters": [3, 4, 5, 6, 8, 25, 768, 4096],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 2064,
"endpoint_id": 2,
"in_clusters": [0, 4096, 64768],
"out_clusters": [3, 4, 5, 6, 8, 768, 4096],
"profile_id": 260,
},
- "3": {
+ 3: {
"device_type": 2064,
"endpoint_id": 3,
"in_clusters": [0, 4096, 64768],
"out_clusters": [3, 4, 5, 6, 8, 768, 4096],
"profile_id": 260,
},
- "4": {
+ 4: {
"device_type": 2064,
"endpoint_id": 4,
"in_clusters": [0, 4096, 64768],
"out_clusters": [3, 4, 5, 6, 8, 768, 4096],
"profile_id": 260,
},
- "5": {
+ 5: {
"device_type": 2064,
"endpoint_id": 5,
"in_clusters": [0, 4096, 64768],
"out_clusters": [3, 4, 5, 6, 8, 768, 4096],
"profile_id": 260,
},
- "6": {
+ 6: {
"device_type": 2064,
"endpoint_id": 6,
"in_clusters": [0, 4096, 64768],
@@ -1479,39 +2696,49 @@
},
},
"entities": ["sensor.osram_switch_4x_lightify_77665544_power"],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.osram_switch_4x_lightify_77665544_power",
+ }
+ },
"event_channels": [
- 6,
- 8,
- 768,
- 6,
- 8,
- 768,
- 6,
- 8,
- 768,
- 6,
- 8,
- 768,
- 6,
- 8,
- 768,
- 6,
- 8,
- 768,
+ "1:0x0006",
+ "1:0x0008",
+ "1:0x0300",
+ "2:0x0006",
+ "2:0x0008",
+ "2:0x0300",
+ "3:0x0006",
+ "3:0x0008",
+ "3:0x0300",
+ "4:0x0006",
+ "4:0x0008",
+ "4:0x0300",
+ "5:0x0006",
+ "5:0x0008",
+ "5:0x0300",
+ "6:0x0006",
+ "6:0x0008",
+ "6:0x0300",
],
"manufacturer": "OSRAM",
"model": "Switch 4x-LIGHTIFY",
+ "node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "LightifyX4",
},
{
+ "device_no": 75,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2096,
"endpoint_id": 1,
"in_clusters": [0],
"out_clusters": [0, 3, 4, 5, 6, 8],
"profile_id": 49246,
},
- "2": {
+ 2: {
"device_type": 12,
"endpoint_id": 2,
"in_clusters": [0, 1, 3, 15, 64512],
@@ -1520,13 +2747,23 @@
},
},
"entities": ["sensor.philips_rwl020_77665544_power"],
- "event_channels": [6, 8],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-2-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.philips_rwl020_77665544_power",
+ }
+ },
+ "event_channels": ["1:0x0006", "1:0x0008"],
"manufacturer": "Philips",
"model": "RWL020",
+ "node_descriptor": b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00",
+ "zha_quirks": "PhilipsRWL021",
},
{
+ "device_no": 76,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
@@ -1536,16 +2773,36 @@
},
"entities": [
"binary_sensor.samjin_button_77665544_ias_zone",
- "sensor.samjin_button_77665544_temperature",
"sensor.samjin_button_77665544_power",
+ "sensor.samjin_button_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.samjin_button_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.samjin_button_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.samjin_button_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "Samjin",
"model": "button",
+ "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00",
+ "zha_quirks": "SamjinButton",
},
{
+ "device_no": 77,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 64514],
@@ -1554,18 +2811,44 @@
}
},
"entities": [
- "sensor.samjin_multi_77665544_power",
- "sensor.samjin_multi_77665544_temperature",
"binary_sensor.samjin_multi_77665544_ias_zone",
"binary_sensor.samjin_multi_77665544_manufacturer_specific",
+ "sensor.samjin_multi_77665544_power",
+ "sensor.samjin_multi_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.samjin_multi_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.samjin_multi_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.samjin_multi_77665544_ias_zone",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): {
+ "channels": ["manufacturer_specific"],
+ "entity_class": "BinarySensor",
+ "entity_id": "binary_sensor.samjin_multi_77665544_manufacturer_specific",
+ "default_match": True,
+ },
+ },
"event_channels": [],
"manufacturer": "Samjin",
"model": "multi",
+ "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00",
+ "zha_quirks": "SmartthingsMultiPurposeSensor",
},
{
+ "device_no": 78,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280],
@@ -1578,13 +2861,32 @@
"sensor.samjin_water_77665544_power",
"sensor.samjin_water_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.samjin_water_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.samjin_water_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.samjin_water_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "Samjin",
"model": "water",
+ "node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00",
},
{
+ "device_no": 79,
"endpoints": {
- "1": {
+ 1: {
"device_type": 0,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 4, 5, 6, 2820, 2821],
@@ -1593,18 +2895,30 @@
}
},
"entities": [
- "binary_sensor.securifi_ltd_unk_model_77665544_on_off",
"sensor.securifi_ltd_unk_model_77665544_electrical_measurement",
- "sensor.securifi_ltd_unk_model_77665544_power",
"switch.securifi_ltd_unk_model_77665544_on_off",
],
- "event_channels": [6],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.securifi_ltd_unk_model_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.securifi_ltd_unk_model_77665544_electrical_measurement",
+ },
+ },
+ "event_channels": ["1:0x0006"],
"manufacturer": "Securifi Ltd.",
"model": None,
+ "node_descriptor": b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 80,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
@@ -1617,20 +2931,39 @@
"sensor.sercomm_corp_sz_dws04n_sf_77665544_power",
"sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.sercomm_corp_sz_dws04n_sf_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "Sercomm Corp.",
"model": "SZ-DWS04N_SF",
+ "node_descriptor": b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00",
},
{
+ "device_no": 81,
"endpoints": {
- "1": {
+ 1: {
"device_type": 256,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 4, 5, 6, 1794, 2820, 2821],
"out_clusters": [3, 10, 25, 2821],
"profile_id": 260,
},
- "2": {
+ 2: {
"device_type": 259,
"endpoint_id": 2,
"in_clusters": [0, 1, 3],
@@ -1639,20 +2972,36 @@
},
},
"entities": [
- "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering",
- "sensor.sercomm_corp_sz_esw01_77665544_power",
- "sensor.sercomm_corp_sz_esw01_77665544_power_2",
- "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement",
- "switch.sercomm_corp_sz_esw01_77665544_on_off",
"light.sercomm_corp_sz_esw01_77665544_on_off",
+ "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement",
+ "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering",
],
- "event_channels": [6],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.sercomm_corp_sz_esw01_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement",
+ },
+ },
+ "event_channels": ["2:0x0006"],
"manufacturer": "Sercomm Corp.",
"model": "SZ-ESW01",
+ "node_descriptor": b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 82,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1024, 1026, 1280, 2821],
@@ -1662,17 +3011,41 @@
},
"entities": [
"binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone",
- "sensor.sercomm_corp_sz_pir04_77665544_temperature",
"sensor.sercomm_corp_sz_pir04_77665544_illuminance",
"sensor.sercomm_corp_sz_pir04_77665544_power",
+ "sensor.sercomm_corp_sz_pir04_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1024"): {
+ "channels": ["illuminance"],
+ "entity_class": "Illuminance",
+ "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_illuminance",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.sercomm_corp_sz_pir04_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "Sercomm Corp.",
"model": "SZ-PIR04",
+ "node_descriptor": b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 83,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 2820, 2821, 65281],
@@ -1684,20 +3057,74 @@
"sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement",
"switch.sinope_technologies_rm3250zb_77665544_on_off",
],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.sinope_technologies_rm3250zb_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement",
+ },
+ },
"event_channels": [],
"manufacturer": "Sinope Technologies",
"model": "RM3250ZB",
+ "node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00",
},
{
+ "device_no": 84,
"endpoints": {
- "1": {
+ 1: {
"device_type": 769,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281],
"out_clusters": [25, 65281],
"profile_id": 260,
},
- "196": {
+ 196: {
+ "device_type": 769,
+ "endpoint_id": 196,
+ "in_clusters": [1],
+ "out_clusters": [],
+ "profile_id": 49757,
+ },
+ },
+ "entities": [
+ "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement",
+ "sensor.sinope_technologies_th1123zb_77665544_temperature",
+ ],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.sinope_technologies_th1123zb_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement",
+ },
+ },
+ "event_channels": [],
+ "manufacturer": "Sinope Technologies",
+ "model": "TH1123ZB",
+ "node_descriptor": b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00",
+ "zha_quirks": "SinopeTechnologiesThermostat",
+ },
+ {
+ "device_no": 85,
+ "endpoints": {
+ 1: {
+ "device_type": 769,
+ "endpoint_id": 1,
+ "in_clusters": [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281],
+ "out_clusters": [25, 65281],
+ "profile_id": 260,
+ },
+ 196: {
"device_type": 769,
"endpoint_id": 196,
"in_clusters": [1],
@@ -1706,17 +3133,31 @@
},
},
"entities": [
- "sensor.sinope_technologies_th1124zb_77665544_temperature",
- "sensor.sinope_technologies_th1124zb_77665544_power",
"sensor.sinope_technologies_th1124zb_77665544_electrical_measurement",
+ "sensor.sinope_technologies_th1124zb_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.sinope_technologies_th1124zb_77665544_temperature",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement",
+ },
+ },
"event_channels": [],
"manufacturer": "Sinope Technologies",
"model": "TH1124ZB",
+ "node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00",
+ "zha_quirks": "SinopeTechnologiesThermostat",
},
{
+ "device_no": 86,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 9, 15, 2820],
@@ -1728,13 +3169,27 @@
"sensor.smartthings_outletv4_77665544_electrical_measurement",
"switch.smartthings_outletv4_77665544_on_off",
],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.smartthings_outletv4_77665544_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): {
+ "channels": ["electrical_measurement"],
+ "entity_class": "ElectricalMeasurement",
+ "entity_id": "sensor.smartthings_outletv4_77665544_electrical_measurement",
+ },
+ },
"event_channels": [],
"manufacturer": "SmartThings",
"model": "outletv4",
+ "node_descriptor": b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 87,
"endpoints": {
- "1": {
+ 1: {
"device_type": 32768,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 15, 32],
@@ -1743,13 +3198,23 @@
}
},
"entities": ["device_tracker.smartthings_tagv4_77665544_power"],
+ "entity_map": {
+ ("device_tracker", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["power"],
+ "entity_class": "ZHADeviceScannerEntity",
+ "entity_id": "device_tracker.smartthings_tagv4_77665544_power",
+ }
+ },
"event_channels": [],
"manufacturer": "SmartThings",
"model": "tagv4",
+ "node_descriptor": b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "SmartThingsTagV4",
},
{
+ "device_no": 88,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 25],
@@ -1758,13 +3223,22 @@
}
},
"entities": ["switch.third_reality_inc_3rss007z_77665544_on_off"],
+ "entity_map": {
+ ("switch", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.third_reality_inc_3rss007z_77665544_on_off",
+ }
+ },
"event_channels": [],
"manufacturer": "Third Reality, Inc",
"model": "3RSS007Z",
+ "node_descriptor": b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00",
},
{
+ "device_no": 89,
"endpoints": {
- "1": {
+ 1: {
"device_type": 2,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 4, 5, 6, 25],
@@ -1776,13 +3250,28 @@
"sensor.third_reality_inc_3rss008z_77665544_power",
"switch.third_reality_inc_3rss008z_77665544_on_off",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.third_reality_inc_3rss008z_77665544_power",
+ },
+ ("switch", "00:11:22:33:44:55:66:77-1-6"): {
+ "channels": ["on_off"],
+ "entity_class": "Switch",
+ "entity_id": "switch.third_reality_inc_3rss008z_77665544_on_off",
+ },
+ },
"event_channels": [],
"manufacturer": "Third Reality, Inc",
"model": "3RSS008Z",
+ "node_descriptor": b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00",
+ "zha_quirks": "Switch",
},
{
+ "device_no": 90,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 32, 1026, 1280, 2821],
@@ -1792,16 +3281,133 @@
},
"entities": [
"binary_sensor.visonic_mct_340_e_77665544_ias_zone",
- "sensor.visonic_mct_340_e_77665544_temperature",
"sensor.visonic_mct_340_e_77665544_power",
+ "sensor.visonic_mct_340_e_77665544_temperature",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.visonic_mct_340_e_77665544_power",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
+ "channels": ["temperature"],
+ "entity_class": "Temperature",
+ "entity_id": "sensor.visonic_mct_340_e_77665544_temperature",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.visonic_mct_340_e_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "Visonic",
"model": "MCT-340 E",
+ "node_descriptor": b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "MCT340E",
+ },
+ {
+ "device_no": 91,
+ "endpoints": {
+ 1: {
+ "device_type": 769,
+ "endpoint_id": 1,
+ "in_clusters": [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821],
+ "out_clusters": [10, 25],
+ "profile_id": 260,
+ }
+ },
+ "entities": [
+ "fan.zen_within_zen_01_77665544_fan",
+ "sensor.zen_within_zen_01_77665544_power",
+ ],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.zen_within_zen_01_77665544_power",
+ },
+ ("fan", "00:11:22:33:44:55:66:77-1-514"): {
+ "channels": ["fan"],
+ "entity_class": "ZhaFan",
+ "entity_id": "fan.zen_within_zen_01_77665544_fan",
+ },
+ },
+ "event_channels": [],
+ "manufacturer": "Zen Within",
+ "model": "Zen-01",
+ "node_descriptor": b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00",
+ },
+ {
+ "device_no": 92,
+ "endpoints": {
+ 1: {
+ "device_type": 256,
+ "endpoint_id": 1,
+ "in_clusters": [0, 4, 5, 6, 10],
+ "out_clusters": [25],
+ "profile_id": 260,
+ },
+ 2: {
+ "device_type": 256,
+ "endpoint_id": 2,
+ "in_clusters": [4, 5, 6],
+ "out_clusters": [],
+ "profile_id": 260,
+ },
+ 3: {
+ "device_type": 256,
+ "endpoint_id": 3,
+ "in_clusters": [4, 5, 6],
+ "out_clusters": [],
+ "profile_id": 260,
+ },
+ 4: {
+ "device_type": 256,
+ "endpoint_id": 4,
+ "in_clusters": [4, 5, 6],
+ "out_clusters": [],
+ "profile_id": 260,
+ },
+ },
+ "entities": [
+ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off",
+ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2",
+ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3",
+ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4",
+ ],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4",
+ },
+ ("light", "00:11:22:33:44:55:66:77-2"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3",
+ },
+ ("light", "00:11:22:33:44:55:66:77-3"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off",
+ },
+ ("light", "00:11:22:33:44:55:66:77-4"): {
+ "channels": ["on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2",
+ },
+ },
+ "event_channels": [],
+ "manufacturer": "_TYZB01_ns1ndbww",
+ "model": "TS0004",
+ "node_descriptor": b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00",
},
{
+ "device_no": 93,
"endpoints": {
- "1": {
+ 1: {
"device_type": 1026,
"endpoint_id": 1,
"in_clusters": [0, 1, 3, 21, 32, 1280, 2821],
@@ -1813,13 +3419,28 @@
"binary_sensor.netvox_z308e3ed_77665544_ias_zone",
"sensor.netvox_z308e3ed_77665544_power",
],
+ "entity_map": {
+ ("sensor", "00:11:22:33:44:55:66:77-1-1"): {
+ "channels": ["power"],
+ "entity_class": "Battery",
+ "entity_id": "sensor.netvox_z308e3ed_77665544_power",
+ },
+ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
+ "channels": ["ias_zone"],
+ "entity_class": "IASZone",
+ "entity_id": "binary_sensor.netvox_z308e3ed_77665544_ias_zone",
+ },
+ },
"event_channels": [],
"manufacturer": "netvox",
"model": "Z308E3ED",
+ "node_descriptor": b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00",
+ "zha_quirks": "Z308E3ED",
},
{
+ "device_no": 94,
"endpoints": {
- "1": {
+ 1: {
"device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821],
@@ -1831,13 +3452,27 @@
"light.sengled_e11_g13_77665544_level_on_off",
"sensor.sengled_e11_g13_77665544_smartenergy_metering",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.sengled_e11_g13_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.sengled_e11_g13_77665544_smartenergy_metering",
+ },
+ },
"event_channels": [],
"manufacturer": "sengled",
"model": "E11-G13",
+ "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 95,
"endpoints": {
- "1": {
+ 1: {
"device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 1794, 2821],
@@ -1846,16 +3481,30 @@
}
},
"entities": [
- "sensor.sengled_e12_n14_77665544_smartenergy_metering",
"light.sengled_e12_n14_77665544_level_on_off",
+ "sensor.sengled_e12_n14_77665544_smartenergy_metering",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.sengled_e12_n14_77665544_level_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.sengled_e12_n14_77665544_smartenergy_metering",
+ },
+ },
"event_channels": [],
"manufacturer": "sengled",
"model": "E12-N14",
+ "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00",
},
{
+ "device_no": 96,
"endpoints": {
- "1": {
+ 1: {
"device_type": 257,
"endpoint_id": 1,
"in_clusters": [0, 3, 4, 5, 6, 8, 768, 1794, 2821],
@@ -1864,11 +3513,24 @@
}
},
"entities": [
- "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering",
"light.sengled_z01_a19nae26_77665544_level_light_color_on_off",
+ "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering",
],
+ "entity_map": {
+ ("light", "00:11:22:33:44:55:66:77-1"): {
+ "channels": ["level", "light_color", "on_off"],
+ "entity_class": "Light",
+ "entity_id": "light.sengled_z01_a19nae26_77665544_level_light_color_on_off",
+ },
+ ("sensor", "00:11:22:33:44:55:66:77-1-1794"): {
+ "channels": ["smartenergy_metering"],
+ "entity_class": "SmartEnergyMetering",
+ "entity_id": "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering",
+ },
+ },
"event_channels": [],
"manufacturer": "sengled",
"model": "Z01-A19NAE26",
+ "node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00",
},
]
diff --git a/tests/components/zwave/test_climate.py b/tests/components/zwave/test_climate.py
index 631bf0a0ce886b..3586c992c08363 100644
--- a/tests/components/zwave/test_climate.py
+++ b/tests/components/zwave/test_climate.py
@@ -342,7 +342,7 @@ def test_get_device_detects_single_setpoint_device(device_single_setpoint):
def test_default_hvac_modes():
- """Test wether all hvac modes are included in default_hvac_modes."""
+ """Test whether all hvac modes are included in default_hvac_modes."""
for hvac_mode in HVAC_MODES:
assert hvac_mode in DEFAULT_HVAC_MODES
diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py
index 540a3f966042ae..a8f72d2105cfad 100644
--- a/tests/components/zwave/test_init.py
+++ b/tests/components/zwave/test_init.py
@@ -100,7 +100,7 @@ async def test_network_key_validation(hass, mock_openzwave):
async def test_erronous_network_key_fails_validation(hass, mock_openzwave):
- """Test failing erronous network key validation."""
+ """Test failing erroneous network key validation."""
test_values = [
(
"0x 01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, "
diff --git a/tests/conftest.py b/tests/conftest.py
index 5a3b24941587d1..04e584cb15864e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -39,7 +39,7 @@ def check_real(func):
"""Force a function to require a keyword _test_real to be passed in."""
@functools.wraps(func)
- def guard_func(*args, **kwargs):
+ async def guard_func(*args, **kwargs):
real = kwargs.pop("_test_real", None)
if not real:
@@ -47,7 +47,7 @@ def guard_func(*args, **kwargs):
'Forgot to mock or pass "_test_real=True" to %s', func.__name__
)
- return func(*args, **kwargs)
+ return await func(*args, **kwargs)
return guard_func
diff --git a/tests/fixtures/august/get_activity.doorbell_motion.json b/tests/fixtures/august/get_activity.doorbell_motion.json
new file mode 100644
index 00000000000000..bd9c07afa26c6b
--- /dev/null
+++ b/tests/fixtures/august/get_activity.doorbell_motion.json
@@ -0,0 +1,58 @@
+[
+ {
+ "otherUser" : {
+ "FirstName" : "Unknown",
+ "UserName" : "deleteduser",
+ "LastName" : "User",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "dateTime" : 1582663119959,
+ "deviceID" : "K98GiDT45GUL",
+ "info" : {
+ "videoUploadProgress" : "in_progress",
+ "image" : {
+ "resource_type" : "image",
+ "etag" : "fdsf",
+ "created_at" : "2020-02-25T20:38:39Z",
+ "type" : "upload",
+ "format" : "jpg",
+ "version" : 1582663119,
+ "secure_url" : "https://res.cloudinary.com/updated_image.jpg",
+ "signature" : "fdfdfd",
+ "url" : "http://res.cloudinary.com/updated_image.jpg",
+ "bytes" : 48545,
+ "placeholder" : false,
+ "original_filename" : "file",
+ "width" : 720,
+ "tags" : [],
+ "public_id" : "xnsj5gphpzij9brifpf4",
+ "height" : 576
+ },
+ "dvrID" : "dvr",
+ "videoAvailable" : false,
+ "hasSubscription" : false
+ },
+ "callingUser" : {
+ "LastName" : "User",
+ "UserName" : "deleteduser",
+ "FirstName" : "Unknown",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "house" : {
+ "houseName" : "K98GiDT45GUL",
+ "houseID" : "na"
+ },
+ "action" : "doorbell_motion_detected",
+ "deviceType" : "doorbell",
+ "entities" : {
+ "otherUser" : "deleted",
+ "house" : "na",
+ "device" : "K98GiDT45GUL",
+ "activity" : "de5585cfd4eae900bb5ba3dc",
+ "callingUser" : "deleted"
+ },
+ "deviceName" : "Front Door"
+ }
+]
diff --git a/tests/fixtures/august/get_doorbell.json b/tests/fixtures/august/get_doorbell.json
new file mode 100644
index 00000000000000..abe6e37b1e3bc3
--- /dev/null
+++ b/tests/fixtures/august/get_doorbell.json
@@ -0,0 +1,83 @@
+{
+ "status_timestamp" : 1512811834532,
+ "appID" : "august-iphone",
+ "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA",
+ "recentImage" : {
+ "original_filename" : "file",
+ "placeholder" : false,
+ "bytes" : 24476,
+ "height" : 640,
+ "format" : "jpg",
+ "width" : 480,
+ "version" : 1512892814,
+ "resource_type" : "image",
+ "etag" : "54966926be2e93f77d498a55f247661f",
+ "tags" : [],
+ "public_id" : "qqqqt4ctmxwsysylaaaa",
+ "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "created_at" : "2017-12-10T08:01:35Z",
+ "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da",
+ "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "type" : "upload"
+ },
+ "settings" : {
+ "keepEncoderRunning" : true,
+ "videoResolution" : "640x480",
+ "minACNoScaling" : 40,
+ "irConfiguration" : 8448272,
+ "directLink" : true,
+ "overlayEnabled" : true,
+ "notify_when_offline" : true,
+ "micVolume" : 100,
+ "bitrateCeiling" : 512000,
+ "initialBitrate" : 384000,
+ "IVAEnabled" : false,
+ "turnOffCamera" : false,
+ "ringSoundEnabled" : true,
+ "JPGQuality" : 70,
+ "motion_notifications" : true,
+ "speakerVolume" : 92,
+ "buttonpush_notifications" : true,
+ "ABREnabled" : true,
+ "debug" : false,
+ "batteryLowThreshold" : 3.1,
+ "batteryRun" : false,
+ "IREnabled" : true,
+ "batteryUseThreshold" : 3.4
+ },
+ "doorbellServerURL" : "https://doorbells.august.com",
+ "name" : "Front Door",
+ "createdAt" : "2016-11-26T22:27:11.176Z",
+ "installDate" : "2016-11-26T22:27:11.176Z",
+ "serialNumber" : "tBXZR0Z35E",
+ "dvrSubscriptionSetupDone" : true,
+ "caps" : [
+ "reconnect"
+ ],
+ "doorbellID" : "K98GiDT45GUL",
+ "HouseID" : "3dd2accaea08",
+ "telemetry" : {
+ "signal_level" : -56,
+ "date" : "2017-12-10 08:05:12",
+ "battery_soc" : 96,
+ "battery" : 4.061763,
+ "steady_ac_in" : 22.196405,
+ "BSSID" : "88:ee:00:dd:aa:11",
+ "SSID" : "foo_ssid",
+ "updated_at" : "2017-12-10T08:05:13.650Z",
+ "temperature" : 28.25,
+ "wifi_freq" : 5745,
+ "load_average" : "0.50 0.47 0.35 1/154 9345",
+ "link_quality" : 54,
+ "battery_soh" : 95,
+ "uptime" : "16168.75 13830.49",
+ "ip_addr" : "10.0.1.11",
+ "doorbell_low_battery" : false,
+ "ac_in" : 23.856874
+ },
+ "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777",
+ "status" : "doorbell_call_status_online",
+ "firmwareVersion" : "2.3.0-RC153+201711151527",
+ "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc",
+ "updatedAt" : "2017-12-10T08:05:13.650Z"
+}
diff --git a/tests/fixtures/august/get_doorbell.nobattery.json b/tests/fixtures/august/get_doorbell.nobattery.json
new file mode 100644
index 00000000000000..e2a93a086ccb8d
--- /dev/null
+++ b/tests/fixtures/august/get_doorbell.nobattery.json
@@ -0,0 +1,80 @@
+{
+ "status_timestamp" : 1512811834532,
+ "appID" : "august-iphone",
+ "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA",
+ "recentImage" : {
+ "original_filename" : "file",
+ "placeholder" : false,
+ "bytes" : 24476,
+ "height" : 640,
+ "format" : "jpg",
+ "width" : 480,
+ "version" : 1512892814,
+ "resource_type" : "image",
+ "etag" : "54966926be2e93f77d498a55f247661f",
+ "tags" : [],
+ "public_id" : "qqqqt4ctmxwsysylaaaa",
+ "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "created_at" : "2017-12-10T08:01:35Z",
+ "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da",
+ "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "type" : "upload"
+ },
+ "settings" : {
+ "keepEncoderRunning" : true,
+ "videoResolution" : "640x480",
+ "minACNoScaling" : 40,
+ "irConfiguration" : 8448272,
+ "directLink" : true,
+ "overlayEnabled" : true,
+ "notify_when_offline" : true,
+ "micVolume" : 100,
+ "bitrateCeiling" : 512000,
+ "initialBitrate" : 384000,
+ "IVAEnabled" : false,
+ "turnOffCamera" : false,
+ "ringSoundEnabled" : true,
+ "JPGQuality" : 70,
+ "motion_notifications" : true,
+ "speakerVolume" : 92,
+ "buttonpush_notifications" : true,
+ "ABREnabled" : true,
+ "debug" : false,
+ "batteryLowThreshold" : 3.1,
+ "batteryRun" : false,
+ "IREnabled" : true,
+ "batteryUseThreshold" : 3.4
+ },
+ "doorbellServerURL" : "https://doorbells.august.com",
+ "name" : "Front Door",
+ "createdAt" : "2016-11-26T22:27:11.176Z",
+ "installDate" : "2016-11-26T22:27:11.176Z",
+ "serialNumber" : "tBXZR0Z35E",
+ "dvrSubscriptionSetupDone" : true,
+ "caps" : [
+ "reconnect"
+ ],
+ "doorbellID" : "K98GiDT45GUL",
+ "HouseID" : "3dd2accaea08",
+ "telemetry" : {
+ "signal_level" : -56,
+ "date" : "2017-12-10 08:05:12",
+ "steady_ac_in" : 22.196405,
+ "BSSID" : "88:ee:00:dd:aa:11",
+ "SSID" : "foo_ssid",
+ "updated_at" : "2017-12-10T08:05:13.650Z",
+ "temperature" : 28.25,
+ "wifi_freq" : 5745,
+ "load_average" : "0.50 0.47 0.35 1/154 9345",
+ "link_quality" : 54,
+ "uptime" : "16168.75 13830.49",
+ "ip_addr" : "10.0.1.11",
+ "doorbell_low_battery" : false,
+ "ac_in" : 23.856874
+ },
+ "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777",
+ "status" : "doorbell_call_status_online",
+ "firmwareVersion" : "2.3.0-RC153+201711151527",
+ "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc",
+ "updatedAt" : "2017-12-10T08:05:13.650Z"
+}
diff --git a/tests/fixtures/august/get_doorbell.offline.json b/tests/fixtures/august/get_doorbell.offline.json
new file mode 100644
index 00000000000000..dec94374355a03
--- /dev/null
+++ b/tests/fixtures/august/get_doorbell.offline.json
@@ -0,0 +1,130 @@
+{
+ "recentImage" : {
+ "tags" : [],
+ "height" : 576,
+ "public_id" : "fdsfds",
+ "bytes" : 50013,
+ "resource_type" : "image",
+ "original_filename" : "file",
+ "version" : 1582242766,
+ "format" : "jpg",
+ "signature" : "fdsfdsf",
+ "created_at" : "2020-02-20T23:52:46Z",
+ "type" : "upload",
+ "placeholder" : false,
+ "url" : "http://res.cloudinary.com/august-com/image/upload/ccc/ccccc.jpg",
+ "secure_url" : "https://res.cloudinary.com/august-com/image/upload/cc/cccc.jpg",
+ "etag" : "zds",
+ "width" : 720
+ },
+ "firmwareVersion" : "3.1.0-HYDRC75+201909251139",
+ "doorbellServerURL" : "https://doorbells.august.com",
+ "installUserID" : "mock",
+ "caps" : [
+ "reconnect",
+ "webrtc",
+ "tcp_wakeup"
+ ],
+ "messagingProtocol" : "pubnub",
+ "createdAt" : "2020-02-12T03:52:28.719Z",
+ "invitations" : [],
+ "appID" : "august-iphone-v5",
+ "HouseID" : "houseid1",
+ "doorbellID" : "tmt100",
+ "name" : "Front Door",
+ "settings" : {
+ "batteryUseThreshold" : 3.4,
+ "brightness" : 50,
+ "batteryChargeCurrent" : 60,
+ "overCurrentThreshold" : -250,
+ "irLedBrightness" : 40,
+ "videoResolution" : "720x576",
+ "pirPulseCounter" : 1,
+ "contrast" : 50,
+ "micVolume" : 50,
+ "directLink" : true,
+ "auto_contrast_mode" : 0,
+ "saturation" : 50,
+ "motion_notifications" : true,
+ "pirSensitivity" : 20,
+ "pirBlindTime" : 7,
+ "notify_when_offline" : false,
+ "nightModeAlsThreshold" : 10,
+ "minACNoScaling" : 40,
+ "DVRRecordingTimeout" : 15,
+ "turnOffCamera" : false,
+ "debug" : false,
+ "keepEncoderRunning" : true,
+ "pirWindowTime" : 0,
+ "bitrateCeiling" : 2000000,
+ "backlight_comp" : false,
+ "buttonpush_notifications" : true,
+ "buttonpush_notifications_partners" : false,
+ "minimumSnapshotInterval" : 30,
+ "pirConfiguration" : 272,
+ "batteryLowThreshold" : 3.1,
+ "sharpness" : 50,
+ "ABREnabled" : true,
+ "hue" : 50,
+ "initialBitrate" : 1000000,
+ "ringSoundEnabled" : true,
+ "IVAEnabled" : false,
+ "overlayEnabled" : true,
+ "speakerVolume" : 92,
+ "ringRepetitions" : 3,
+ "powerProfilePreset" : -1,
+ "irConfiguration" : 16836880,
+ "JPGQuality" : 70,
+ "IREnabled" : true
+ },
+ "updatedAt" : "2020-02-20T23:58:21.580Z",
+ "serialNumber" : "abc",
+ "installDate" : "2019-02-12T03:52:28.719Z",
+ "dvrSubscriptionSetupDone" : true,
+ "pubsubChannel" : "mock",
+ "chimes" : [
+ {
+ "updatedAt" : "2020-02-12T03:55:38.805Z",
+ "_id" : "cccc",
+ "type" : 1,
+ "serialNumber" : "ccccc",
+ "doorbellID" : "tmt100",
+ "name" : "Living Room",
+ "chimeID" : "cccc",
+ "createdAt" : "2020-02-12T03:55:38.805Z",
+ "firmware" : "3.1.16"
+ }
+ ],
+ "telemetry" : {
+ "battery" : 3.985,
+ "battery_soc" : 81,
+ "load_average" : "0.45 0.18 0.07 4/98 831",
+ "ip_addr" : "192.168.100.174",
+ "BSSID" : "snp",
+ "uptime" : "96.55 70.59",
+ "SSID" : "bob",
+ "updated_at" : "2020-02-20T23:53:09.586Z",
+ "dtim_period" : 0,
+ "wifi_freq" : 2462,
+ "date" : "2020-02-20 11:47:36",
+ "BSSIDManufacturer" : "Ubiquiti - Ubiquiti Networks Inc.",
+ "battery_temp" : 22,
+ "battery_avg_cur" : -291,
+ "beacon_interval" : 0,
+ "signal_level" : -49,
+ "battery_soh" : 95,
+ "doorbell_low_battery" : false
+ },
+ "secChipCertSerial" : "",
+ "tcpKeepAlive" : {
+ "keepAliveUUID" : "mock",
+ "wakeUp" : {
+ "token" : "wakemeup",
+ "lastUpdated" : 1582242723931
+ }
+ },
+ "statusUpdatedAtMs" : 1582243101579,
+ "status" : "doorbell_offline",
+ "type" : "hydra1",
+ "HouseName" : "housename"
+}
diff --git a/tests/fixtures/august/get_lock.doorsense_init.json b/tests/fixtures/august/get_lock.doorsense_init.json
new file mode 100644
index 00000000000000..be60bbe6236d1b
--- /dev/null
+++ b/tests/fixtures/august/get_lock.doorsense_init.json
@@ -0,0 +1,103 @@
+{
+ "LockName": "Front Door Lock",
+ "Type": 2,
+ "Created": "2017-12-10T03:12:09.210Z",
+ "Updated": "2017-12-10T03:12:09.210Z",
+ "LockID": "A6697750D607098BAE8D6BAA11EF8063",
+ "HouseID": "000000000000",
+ "HouseName": "My House",
+ "Calibrated": false,
+ "skuNumber": "AUG-SL02-M02-S02",
+ "timeZone": "America/Vancouver",
+ "battery": 0.88,
+ "SerialNumber": "X2FSW05DGA",
+ "LockStatus": {
+ "status": "locked",
+ "doorState": "init",
+ "dateTime": "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged": false,
+ "valid": true
+ },
+ "currentFirmwareVersion": "109717e9-3.0.44-3.0.30",
+ "homeKitEnabled": false,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "aaacab87f7efxa0015884999",
+ "mfgBridgeID": "AAGPP102XX",
+ "deviceModel": "august-doorbell",
+ "firmwareVersion": "2.3.0-RC153+201711151527",
+ "operative": true
+ },
+ "keypad": {
+ "_id": "5bc65c24e6ef2a263e1450a8",
+ "serialNumber": "K1GXB0054Z",
+ "lockID": "92412D1B44004595B5DEB134E151A8D3",
+ "currentFirmwareVersion": "2.27.0",
+ "battery": {},
+ "batteryLevel": "Medium",
+ "batteryRaw": 170
+ },
+ "OfflineKeys": {
+ "created": [],
+ "loaded": [
+ {
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "slot": 1,
+ "key": "kkk01d4300c1dcxxx1c330f794941111",
+ "created": "2017-12-10T03:12:09.215Z",
+ "loaded": "2017-12-10T03:12:54.391Z"
+ }
+ ],
+ "deleted": [],
+ "loadedhk": [
+ {
+ "key": "kkk01d4300c1dcxxx1c330f794941222",
+ "slot": 256,
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "created": "2017-12-10T03:12:09.218Z",
+ "loaded": "2017-12-10T03:12:55.563Z"
+ }
+ ]
+ },
+ "parametersToSet": {},
+ "users": {
+ "cccca94e-373e-aaaa-bbbb-333396827777": {
+ "UserType": "superuser",
+ "FirstName": "Foo",
+ "LastName": "Bar",
+ "identifiers": [
+ "email:foo@bar.com",
+ "phone:+177777777777"
+ ],
+ "imageInfo": {
+ "original": {
+ "width": 948,
+ "height": 949,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ },
+ "thumbnail": {
+ "width": 128,
+ "height": 128,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ }
+ }
+ }
+ },
+ "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333",
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.offline.json b/tests/fixtures/august/get_lock.offline.json
new file mode 100644
index 00000000000000..502a78674e999b
--- /dev/null
+++ b/tests/fixtures/august/get_lock.offline.json
@@ -0,0 +1,68 @@
+{
+ "Calibrated" : false,
+ "Created" : "2000-00-00T00:00:00.447Z",
+ "HouseID" : "houseid",
+ "HouseName" : "MockName",
+ "LockID" : "ABC",
+ "LockName" : "Test",
+ "LockStatus" : {
+ "status" : "unknown"
+ },
+ "OfflineKeys" : {
+ "created" : [],
+ "createdhk" : [
+ {
+ "UserID" : "mock-user-id",
+ "created" : "2000-00-00T00:00:00.447Z",
+ "key" : "mockkey",
+ "slot" : 12
+ }
+ ],
+ "deleted" : [],
+ "loaded" : [
+ {
+ "UserID" : "userid",
+ "created" : "2000-00-00T00:00:00.447Z",
+ "key" : "key",
+ "loaded" : "2000-00-00T00:00:00.447Z",
+ "slot" : 1
+ }
+ ]
+ },
+ "SerialNumber" : "ABC",
+ "Type" : 3,
+ "Updated" : "2000-00-00T00:00:00.447Z",
+ "battery" : -1,
+ "cameras" : [],
+ "currentFirmwareVersion" : "undefined-1.59.0-1.13.2",
+ "geofenceLimits" : {
+ "ios" : {
+ "debounceInterval" : 90,
+ "gpsAccuracyMultiplier" : 2.5,
+ "maximumGeofence" : 5000,
+ "minGPSAccuracyRequired" : 80,
+ "minimumGeofence" : 100
+ }
+ },
+ "homeKitEnabled" : false,
+ "isGalileo" : false,
+ "macAddress" : "a:b:c",
+ "parametersToSet" : {},
+ "pubsubChannel" : "mockpubsub",
+ "ruleHash" : {},
+ "skuNumber" : "AUG-X",
+ "supportsEntryCodes" : false,
+ "users" : {
+ "mockuserid" : {
+ "FirstName" : "MockName",
+ "LastName" : "House",
+ "UserType" : "superuser",
+ "identifiers" : [
+ "phone:+15558675309",
+ "email:mockme@mock.org"
+ ]
+ }
+ },
+ "zWaveDSK" : "1-2-3-4",
+ "zWaveEnabled" : true
+}
diff --git a/tests/fixtures/august/get_lock.online.json b/tests/fixtures/august/get_lock.online.json
new file mode 100644
index 00000000000000..8003359e589c3e
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online.json
@@ -0,0 +1,103 @@
+{
+ "LockName": "Front Door Lock",
+ "Type": 2,
+ "Created": "2017-12-10T03:12:09.210Z",
+ "Updated": "2017-12-10T03:12:09.210Z",
+ "LockID": "A6697750D607098BAE8D6BAA11EF8063",
+ "HouseID": "000000000000",
+ "HouseName": "My House",
+ "Calibrated": false,
+ "skuNumber": "AUG-SL02-M02-S02",
+ "timeZone": "America/Vancouver",
+ "battery": 0.88,
+ "SerialNumber": "X2FSW05DGA",
+ "LockStatus": {
+ "status": "locked",
+ "doorState": "closed",
+ "dateTime": "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged": true,
+ "valid": true
+ },
+ "currentFirmwareVersion": "109717e9-3.0.44-3.0.30",
+ "homeKitEnabled": false,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "aaacab87f7efxa0015884999",
+ "mfgBridgeID": "AAGPP102XX",
+ "deviceModel": "august-doorbell",
+ "firmwareVersion": "2.3.0-RC153+201711151527",
+ "operative": true
+ },
+ "keypad": {
+ "_id": "5bc65c24e6ef2a263e1450a8",
+ "serialNumber": "K1GXB0054Z",
+ "lockID": "92412D1B44004595B5DEB134E151A8D3",
+ "currentFirmwareVersion": "2.27.0",
+ "battery": {},
+ "batteryLevel": "Medium",
+ "batteryRaw": 170
+ },
+ "OfflineKeys": {
+ "created": [],
+ "loaded": [
+ {
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "slot": 1,
+ "key": "kkk01d4300c1dcxxx1c330f794941111",
+ "created": "2017-12-10T03:12:09.215Z",
+ "loaded": "2017-12-10T03:12:54.391Z"
+ }
+ ],
+ "deleted": [],
+ "loadedhk": [
+ {
+ "key": "kkk01d4300c1dcxxx1c330f794941222",
+ "slot": 256,
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "created": "2017-12-10T03:12:09.218Z",
+ "loaded": "2017-12-10T03:12:55.563Z"
+ }
+ ]
+ },
+ "parametersToSet": {},
+ "users": {
+ "cccca94e-373e-aaaa-bbbb-333396827777": {
+ "UserType": "superuser",
+ "FirstName": "Foo",
+ "LastName": "Bar",
+ "identifiers": [
+ "email:foo@bar.com",
+ "phone:+177777777777"
+ ],
+ "imageInfo": {
+ "original": {
+ "width": 948,
+ "height": 949,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ },
+ "thumbnail": {
+ "width": 128,
+ "height": 128,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ }
+ }
+ }
+ },
+ "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333",
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.online.unknown_state.json b/tests/fixtures/august/get_lock.online.unknown_state.json
new file mode 100644
index 00000000000000..ad4556559025db
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online.unknown_state.json
@@ -0,0 +1,59 @@
+{
+ "LockName": "Side Door",
+ "Type": 1001,
+ "Created": "2019-10-07T01:49:06.831Z",
+ "Updated": "2019-10-07T01:49:06.831Z",
+ "LockID": "BROKENID",
+ "HouseID": "abc",
+ "HouseName": "dog",
+ "Calibrated": false,
+ "timeZone": "America/Chicago",
+ "battery": 0.9524716174964851,
+ "hostLockInfo": {
+ "serialNumber": "YR",
+ "manufacturer": "yale",
+ "productID": 1536,
+ "productTypeID": 32770
+ },
+ "supportsEntryCodes": true,
+ "skuNumber": "AUG-MD01",
+ "macAddress": "MAC",
+ "SerialNumber": "M1FXZ00EZ9",
+ "LockStatus": {
+ "status": "unknown_error_during_connect",
+ "dateTime": "2020-02-22T02:48:11.741Z",
+ "isLockStatusChanged": true,
+ "valid": true,
+ "doorState": "closed"
+ },
+ "currentFirmwareVersion": "undefined-4.3.0-1.8.14",
+ "homeKitEnabled": true,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "id",
+ "mfgBridgeID": "id",
+ "deviceModel": "august-connect",
+ "firmwareVersion": "2.2.1",
+ "operative": true,
+ "status": {
+ "current": "online",
+ "updated": "2020-02-21T15:06:47.001Z",
+ "lastOnline": "2020-02-21T15:06:47.001Z",
+ "lastOffline": "2020-02-06T17:33:21.265Z"
+ },
+ "hyperBridge": true
+ },
+ "parametersToSet": {},
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.online_missing_doorsense.json b/tests/fixtures/august/get_lock.online_missing_doorsense.json
new file mode 100644
index 00000000000000..46971c3bbd2b2a
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online_missing_doorsense.json
@@ -0,0 +1,50 @@
+{
+ "Bridge" : {
+ "_id" : "bridgeid",
+ "deviceModel" : "august-connect",
+ "firmwareVersion" : "2.2.1",
+ "hyperBridge" : true,
+ "mfgBridgeID" : "C5WY200WSH",
+ "operative" : true,
+ "status" : {
+ "current" : "online",
+ "lastOffline" : "2000-00-00T00:00:00.447Z",
+ "lastOnline" : "2000-00-00T00:00:00.447Z",
+ "updated" : "2000-00-00T00:00:00.447Z"
+ }
+ },
+ "Calibrated" : false,
+ "Created" : "2000-00-00T00:00:00.447Z",
+ "HouseID" : "123",
+ "HouseName" : "Test",
+ "LockID" : "missing_doorsense_id",
+ "LockName" : "Online door missing doorsense",
+ "LockStatus" : {
+ "dateTime" : "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged" : false,
+ "status" : "locked",
+ "valid" : true
+ },
+ "SerialNumber" : "XY",
+ "Type" : 1001,
+ "Updated" : "2000-00-00T00:00:00.447Z",
+ "battery" : 0.922,
+ "currentFirmwareVersion" : "undefined-4.3.0-1.8.14",
+ "homeKitEnabled" : true,
+ "hostLockInfo" : {
+ "manufacturer" : "yale",
+ "productID" : 1536,
+ "productTypeID" : 32770,
+ "serialNumber" : "ABC"
+ },
+ "isGalileo" : false,
+ "macAddress" : "12:22",
+ "pins" : {
+ "created" : [],
+ "loaded" : []
+ },
+ "skuNumber" : "AUG-MD01",
+ "supportsEntryCodes" : true,
+ "timeZone" : "Pacific/Hawaii",
+ "zWaveEnabled" : false
+}
diff --git a/tests/fixtures/august/get_lock.online_with_doorsense.json b/tests/fixtures/august/get_lock.online_with_doorsense.json
new file mode 100644
index 00000000000000..f737657048231f
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online_with_doorsense.json
@@ -0,0 +1,51 @@
+{
+ "Bridge" : {
+ "_id" : "bridgeid",
+ "deviceModel" : "august-connect",
+ "firmwareVersion" : "2.2.1",
+ "hyperBridge" : true,
+ "mfgBridgeID" : "C5WY200WSH",
+ "operative" : true,
+ "status" : {
+ "current" : "online",
+ "lastOffline" : "2000-00-00T00:00:00.447Z",
+ "lastOnline" : "2000-00-00T00:00:00.447Z",
+ "updated" : "2000-00-00T00:00:00.447Z"
+ }
+ },
+ "Calibrated" : false,
+ "Created" : "2000-00-00T00:00:00.447Z",
+ "HouseID" : "123",
+ "HouseName" : "Test",
+ "LockID" : "online_with_doorsense",
+ "LockName" : "Online door with doorsense",
+ "LockStatus" : {
+ "dateTime" : "2017-12-10T04:48:30.272Z",
+ "doorState" : "open",
+ "isLockStatusChanged" : false,
+ "status" : "locked",
+ "valid" : true
+ },
+ "SerialNumber" : "XY",
+ "Type" : 1001,
+ "Updated" : "2000-00-00T00:00:00.447Z",
+ "battery" : 0.922,
+ "currentFirmwareVersion" : "undefined-4.3.0-1.8.14",
+ "homeKitEnabled" : true,
+ "hostLockInfo" : {
+ "manufacturer" : "yale",
+ "productID" : 1536,
+ "productTypeID" : 32770,
+ "serialNumber" : "ABC"
+ },
+ "isGalileo" : false,
+ "macAddress" : "12:22",
+ "pins" : {
+ "created" : [],
+ "loaded" : []
+ },
+ "skuNumber" : "AUG-MD01",
+ "supportsEntryCodes" : true,
+ "timeZone" : "Pacific/Hawaii",
+ "zWaveEnabled" : false
+}
diff --git a/tests/fixtures/august/get_locks.json b/tests/fixtures/august/get_locks.json
new file mode 100644
index 00000000000000..3fab55f82c9602
--- /dev/null
+++ b/tests/fixtures/august/get_locks.json
@@ -0,0 +1,16 @@
+{
+ "A6697750D607098BAE8D6BAA11EF8063": {
+ "LockName": "Front Door Lock",
+ "UserType": "superuser",
+ "macAddress": "2E:BA:C4:14:3F:09",
+ "HouseID": "000000000000",
+ "HouseName": "A House"
+ },
+ "A6697750D607098BAE8D6BAA11EF9999": {
+ "LockName": "Back Door Lock",
+ "UserType": "user",
+ "macAddress": "2E:BA:C4:14:3F:88",
+ "HouseID": "000000000011",
+ "HouseName": "A House"
+ }
+}
diff --git a/tests/fixtures/august/lock_open.json b/tests/fixtures/august/lock_open.json
new file mode 100644
index 00000000000000..67e3ccfbf159b4
--- /dev/null
+++ b/tests/fixtures/august/lock_open.json
@@ -0,0 +1,26 @@
+{
+ "status" : "kAugLockState_Locked",
+ "resultsFromOperationCache" : false,
+ "retryCount" : 1,
+ "info" : {
+ "wlanRSSI" : -54,
+ "lockType" : "lock_version_1001",
+ "lockStatusChanged" : false,
+ "serialNumber" : "ABC",
+ "serial" : "123",
+ "action" : "lock",
+ "context" : {
+ "startDate" : "2020-02-19T01:59:39.516Z",
+ "retryCount" : 1,
+ "transactionID" : "mock"
+ },
+ "bridgeID" : "mock",
+ "wlanSNR" : 41,
+ "startTime" : "2020-02-19T01:59:39.517Z",
+ "duration" : 5149,
+ "lockID" : "ABC",
+ "rssi" : -77
+ },
+ "totalTime" : 5162,
+ "doorState" : "kAugDoorState_Open"
+}
diff --git a/tests/fixtures/august/unlock_closed.json b/tests/fixtures/august/unlock_closed.json
new file mode 100644
index 00000000000000..57b712f55e170a
--- /dev/null
+++ b/tests/fixtures/august/unlock_closed.json
@@ -0,0 +1,26 @@
+{
+ "status" : "kAugLockState_Unlocked",
+ "resultsFromOperationCache" : false,
+ "retryCount" : 1,
+ "info" : {
+ "wlanRSSI" : -54,
+ "lockType" : "lock_version_1001",
+ "lockStatusChanged" : false,
+ "serialNumber" : "ABC",
+ "serial" : "123",
+ "action" : "lock",
+ "context" : {
+ "startDate" : "2020-02-19T01:59:39.516Z",
+ "retryCount" : 1,
+ "transactionID" : "mock"
+ },
+ "bridgeID" : "mock",
+ "wlanSNR" : 41,
+ "startTime" : "2020-02-19T01:59:39.517Z",
+ "duration" : 5149,
+ "lockID" : "ABC",
+ "rssi" : -77
+ },
+ "totalTime" : 5162,
+ "doorState" : "kAugDoorState_Closed"
+}
diff --git a/tests/fixtures/homekit_controller/ecobee_occupancy.json b/tests/fixtures/homekit_controller/ecobee_occupancy.json
new file mode 100644
index 00000000000000..78c985999617a1
--- /dev/null
+++ b/tests/fixtures/homekit_controller/ecobee_occupancy.json
@@ -0,0 +1,236 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "characteristics": [
+ {
+ "format": "string",
+ "iid": 2,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ },
+ {
+ "format": "string",
+ "iid": 3,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000020-0000-1000-8000-0026BB765291",
+ "value": "ecobee Inc."
+ },
+ {
+ "format": "string",
+ "iid": 4,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000030-0000-1000-8000-0026BB765291",
+ "value": "111111111111"
+ },
+ {
+ "format": "string",
+ "iid": 5,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000021-0000-1000-8000-0026BB765291",
+ "value": "ecobee Switch+"
+ },
+ {
+ "format": "bool",
+ "iid": 6,
+ "perms": [
+ "pw"
+ ],
+ "type": "00000014-0000-1000-8000-0026BB765291"
+ },
+ {
+ "format": "string",
+ "iid": 8,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000052-0000-1000-8000-0026BB765291",
+ "value": "4.5.130201"
+ },
+ {
+ "format": "uint32",
+ "iid": 9,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "000000A6-0000-1000-8000-0026BB765291",
+ "value": 0
+ }
+ ],
+ "iid": 1,
+ "stype": "accessory-information",
+ "type": "0000003E-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "string",
+ "iid": 31,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000037-0000-1000-8000-0026BB765291",
+ "value": "1.1.0"
+ }
+ ],
+ "iid": 30,
+ "stype": "service",
+ "type": "000000A2-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "bool",
+ "iid": 17,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "00000025-0000-1000-8000-0026BB765291",
+ "value": false
+ },
+ {
+ "format": "string",
+ "iid": 18,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 16,
+ "primary": true,
+ "stype": "switch",
+ "type": "00000049-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "float",
+ "iid": 20,
+ "maxValue": 100000,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "0000006B-0000-1000-8000-0026BB765291",
+ "unit": "lux",
+ "value": 0
+ },
+ {
+ "format": "string",
+ "iid": 21,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 27,
+ "stype": "light",
+ "type": "00000084-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "bool",
+ "iid": 66,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "00000022-0000-1000-8000-0026BB765291",
+ "value": false
+ },
+ {
+ "format": "string",
+ "iid": 28,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 56,
+ "stype": "motion",
+ "type": "00000085-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "uint8",
+ "iid": 65,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "00000071-0000-1000-8000-0026BB765291",
+ "value": 0
+ },
+ {
+ "format": "string",
+ "iid": 29,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 57,
+ "stype": "occupancy",
+ "type": "00000086-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "float",
+ "iid": 19,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "00000011-0000-1000-8000-0026BB765291",
+ "unit": "celsius",
+ "value": 25.6
+ },
+ {
+ "format": "string",
+ "iid": 22,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 55,
+ "stype": "temperature",
+ "type": "0000008A-0000-1000-8000-0026BB765291"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json
index 5ca5f2810d3f4b..a97b1247e2ce0c 100644
--- a/tests/fixtures/homematicip_cloud.json
+++ b/tests/fixtures/homematicip_cloud.json
@@ -14,6 +14,203 @@
}
},
"devices": {
+ "3014F711000BBBB000000000": {
+ "availableFirmwareVersion": "2.0.2",
+ "firmwareVersion": "2.0.2",
+ "firmwareVersionInteger": 131074,
+ "functionalChannels": {
+ "0": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F711000BBBB000000000",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": false,
+ "functionalChannelType": "DEVICE_OPERATIONLOCK",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": false,
+ "operationLockActive": false,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": -45,
+ "rssiPeerValue": -54,
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceOverheated": false,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": false
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "actualTemperature": 23.0,
+ "deviceId": "3014F711000BBBB000000000",
+ "display": "ACTUAL",
+ "functionalChannelType": "WALL_MOUNTED_THERMOSTAT_PRO_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "humidity": 52,
+ "index": 1,
+ "label": "",
+ "setPointTemperature": 20.0,
+ "temperatureOffset": 0.0,
+ "vaporAmount": 10.662700840292974
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F711000BBBB000000000",
+ "label": "Raumbedienger\u00e4t",
+ "lastStatusUpdate": 1579383507353,
+ "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
+ "manufacturerCode": 1,
+ "modelId": 282,
+ "modelType": "ALPHA-IP-RBG",
+ "oem": "M\u00f6hlenhoff",
+ "permanentlyReachable": true,
+ "serializedGlobalTradeItemNumber": "3014F711000BBBB000000000",
+ "type": "ROOM_CONTROL_DEVICE",
+ "updateState": "UP_TO_DATE"
+ },
+ "3014F711000000BBBB000005": {
+ "availableFirmwareVersion": "1.0.16",
+ "firmwareVersion": "1.0.12",
+ "firmwareVersionInteger": 65548,
+ "functionalChannels": {
+ "0": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F711000000BBBB000005",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": false,
+ "functionalChannelType": "DEVICE_BASE",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": false,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": -41,
+ "rssiPeerValue": -29,
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceOverheated": false,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": false
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "actualTemperature": 23.3,
+ "deviceId": "3014F711000000BBBB000005",
+ "functionalChannelType": "ANALOG_ROOM_CONTROL_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "index": 1,
+ "label": "",
+ "setPointTemperature": 23.0,
+ "temperatureOffset": 0.0
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F711000000BBBB000005",
+ "label": "Raumbedienger\u00e4t Analog",
+ "lastStatusUpdate": 1579384126279,
+ "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
+ "manufacturerCode": 1,
+ "modelId": 281,
+ "modelType": "ALPHA-IP-RBGa",
+ "oem": "M\u00f6hlenhoff",
+ "permanentlyReachable": true,
+ "serializedGlobalTradeItemNumber": "3014F711000000BBBB000005",
+ "type": "ROOM_CONTROL_DEVICE_ANALOG",
+ "updateState": "TRANSFERING_UPDATE"
+ },
+ "3014F711000000000000AAA5": {
+ "availableFirmwareVersion": "0.0.0",
+ "firmwareVersion": "1.0.12",
+ "firmwareVersionInteger": 65548,
+ "functionalChannels": {
+ "0": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F711000000000000AAA5",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": false,
+ "functionalChannelType": "DEVICE_OPERATIONLOCK",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": false,
+ "operationLockActive": false,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": -58,
+ "rssiPeerValue": -59,
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceOverheated": false,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": false
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "actualTemperature": 16.0,
+ "deviceId": "3014F711000000000000AAA5",
+ "display": "ACTUAL",
+ "functionalChannelType": "WALL_MOUNTED_THERMOSTAT_PRO_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "humidity": 42,
+ "index": 1,
+ "label": "",
+ "setPointTemperature": 12.0,
+ "temperatureOffset": 0.0,
+ "vaporAmount": 5.710127947243264
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F711000000000000AAA5",
+ "label": "Thermostat Schlafen Tal",
+ "lastStatusUpdate": 1578954498192,
+ "liveUpdateState": "UP_TO_DATE",
+ "manufacturerCode": 1,
+ "modelId": 408,
+ "modelType": "HmIP-WTH-B",
+ "oem": "eQ-3",
+ "permanentlyReachable": true,
+ "serializedGlobalTradeItemNumber": "3014F711000000000000AAA5",
+ "type": "WALL_MOUNTED_THERMOSTAT_BASIC_HUMIDITY",
+ "updateState": "BACKGROUND_UPDATE_NOT_SUPPORTED"
+ },
"3014F7110000000000ABCD50": {
"availableFirmwareVersion": "1.0.12",
"firmwareVersion": "1.0.12",
@@ -66,7 +263,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000ABCD50",
- "label": "Netzausfall",
+ "label": "Netzausfall\u00fcberwachung",
"lastStatusUpdate": 1577487207542,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3020,7 +3217,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000013",
- "label": "Heizkörperthermostat",
+ "label": "Heizkörperthermostat2",
"lastStatusUpdate": 1524514007132,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3188,7 +3385,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000016",
- "label": "Heizkörperthermostat",
+ "label": "Heizkörperthermostat3",
"lastStatusUpdate": 1524514626157,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3348,7 +3545,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000019",
- "label": "Rauchwarnmelder",
+ "label": "Rauchwarnmelder2",
"lastStatusUpdate": 1524480981494,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3400,7 +3597,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000020",
- "label": "Rauchwarnmelder",
+ "label": "Rauchwarnmelder3",
"lastStatusUpdate": 1524456324824,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3452,7 +3649,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000021",
- "label": "Rauchwarnmelder",
+ "label": "Rauchwarnmelder4",
"lastStatusUpdate": 1524443129876,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3566,7 +3763,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000023",
- "label": "Wandthermostat",
+ "label": "Wandthermostat4",
"lastStatusUpdate": 1524516454116,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3623,7 +3820,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000024",
- "label": "Wandthermostat",
+ "label": "Wandthermostat2",
"lastStatusUpdate": 1524516436601,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
@@ -3680,7 +3877,7 @@
},
"homeId": "00000000-0000-0000-0000-000000000001",
"id": "3014F7110000000000000025",
- "label": "Wandthermostat",
+ "label": "Wandthermostat3",
"lastStatusUpdate": 1524516556479,
"liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
"manufacturerCode": 1,
diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py
index b603f98bb04b93..afa428805e98a5 100644
--- a/tests/helpers/test_condition.py
+++ b/tests/helpers/test_condition.py
@@ -176,3 +176,37 @@ async def test_if_numeric_state_not_raise_on_unavailable(hass):
hass.states.async_set("sensor.temperature", "unknown")
assert not test(hass)
assert len(logwarn.mock_calls) == 0
+
+
+async def test_extract_entities():
+ """Test extracting entities."""
+ condition.async_extract_entities(
+ {
+ "condition": "and",
+ "conditions": [
+ {
+ "condition": "state",
+ "entity_id": "sensor.temperature",
+ "state": "100",
+ },
+ {
+ "condition": "numeric_state",
+ "entity_id": "sensor.temperature_2",
+ "below": 110,
+ },
+ ],
+ }
+ ) == {"sensor.temperature", "sensor.temperature_2"}
+
+
+async def test_extract_devices():
+ """Test extracting devices."""
+ condition.async_extract_devices(
+ {
+ "condition": "and",
+ "conditions": [
+ {"condition": "device", "device_id": "abcd", "domain": "light"},
+ {"condition": "device", "device_id": "qwer", "domain": "switch"},
+ ],
+ }
+ ) == {"abcd", "qwer"}
diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py
index 57554d37bb16a1..71d845ac637ec5 100644
--- a/tests/helpers/test_config_validation.py
+++ b/tests/helpers/test_config_validation.py
@@ -464,7 +464,7 @@ def test_time():
def test_datetime():
"""Test date time validation."""
schema = vol.Schema(cv.datetime)
- for value in [date.today(), "Wrong DateTime", "2016-11-23"]:
+ for value in [date.today(), "Wrong DateTime"]:
with pytest.raises(vol.MultipleInvalid):
schema(value)
@@ -472,6 +472,30 @@ def test_datetime():
schema("2016-11-23T18:59:08")
+def test_multi_select():
+ """Test multi select validation.
+
+ Expected behavior:
+ - Will not accept any input but a list
+ - Will not accept selections outside of configured scope
+ """
+ schema = vol.Schema(cv.multi_select({"paulus": "Paulus", "robban": "Robban"}))
+
+ with pytest.raises(vol.Invalid):
+ schema("robban")
+ schema(["paulus", "martinhj"])
+
+ schema(["robban", "paulus"])
+
+
+def test_multi_select_in_serializer():
+ """Test multi_select with custom_serializer."""
+ assert cv.custom_serializer(cv.multi_select({"paulus": "Paulus"})) == {
+ "type": "multi_select",
+ "options": {"paulus": "Paulus"},
+ }
+
+
@pytest.fixture
def schema():
"""Create a schema used for testing deprecation."""
@@ -963,3 +987,36 @@ def test_uuid4_hex(caplog):
_hex = uuid.uuid4().hex
assert schema(_hex) == _hex
assert schema(_hex.upper()) == _hex
+
+
+def test_key_value_schemas():
+ """Test key value schemas."""
+ schema = vol.Schema(
+ cv.key_value_schemas(
+ "mode",
+ {
+ "number": vol.Schema({"mode": "number", "data": int}),
+ "string": vol.Schema({"mode": "string", "data": str}),
+ },
+ )
+ )
+
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema(True)
+ assert str(excinfo.value) == "Expected a dictionary"
+
+ for mode in None, "invalid":
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema({"mode": mode})
+ assert str(excinfo.value) == f"Unexpected key {mode}. Expected number, string"
+
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema({"mode": "number", "data": "string-value"})
+ assert str(excinfo.value) == "expected int for dictionary value @ data['data']"
+
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema({"mode": "string", "data": 1})
+ assert str(excinfo.value) == "expected str for dictionary value @ data['data']"
+
+ for mode, data in (("number", 1), ("string", "hello")):
+ schema({"mode": mode, "data": data})
diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py
new file mode 100644
index 00000000000000..4972fbbc018a8b
--- /dev/null
+++ b/tests/helpers/test_debounce.py
@@ -0,0 +1,70 @@
+"""Tests for debounce."""
+from asynctest import CoroutineMock
+
+from homeassistant.helpers import debounce
+
+
+async def test_immediate_works(hass):
+ """Test immediate works."""
+ calls = []
+ debouncer = debounce.Debouncer(
+ hass,
+ None,
+ cooldown=0.01,
+ immediate=True,
+ function=CoroutineMock(side_effect=lambda: calls.append(None)),
+ )
+
+ await debouncer.async_call()
+ assert len(calls) == 1
+ assert debouncer._timer_task is not None
+ assert debouncer._execute_at_end_of_timer is False
+
+ await debouncer.async_call()
+ assert len(calls) == 1
+ assert debouncer._timer_task is not None
+ assert debouncer._execute_at_end_of_timer is True
+
+ debouncer.async_cancel()
+ assert debouncer._timer_task is None
+ assert debouncer._execute_at_end_of_timer is False
+
+ await debouncer.async_call()
+ assert len(calls) == 2
+ await debouncer._handle_timer_finish()
+ assert len(calls) == 2
+ assert debouncer._timer_task is None
+ assert debouncer._execute_at_end_of_timer is False
+
+
+async def test_not_immediate_works(hass):
+ """Test immediate works."""
+ calls = []
+ debouncer = debounce.Debouncer(
+ hass,
+ None,
+ cooldown=0.01,
+ immediate=False,
+ function=CoroutineMock(side_effect=lambda: calls.append(None)),
+ )
+
+ await debouncer.async_call()
+ assert len(calls) == 0
+ assert debouncer._timer_task is not None
+ assert debouncer._execute_at_end_of_timer is True
+
+ await debouncer.async_call()
+ assert len(calls) == 0
+ assert debouncer._timer_task is not None
+ assert debouncer._execute_at_end_of_timer is True
+
+ debouncer.async_cancel()
+ assert debouncer._timer_task is None
+ assert debouncer._execute_at_end_of_timer is False
+
+ await debouncer.async_call()
+ assert len(calls) == 0
+ await debouncer._handle_timer_finish()
+ assert len(calls) == 1
+ assert debouncer._timer_task is None
+ assert debouncer._execute_at_end_of_timer is False
diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py
index 749c11ff1a5084..6dc194c09d4c83 100644
--- a/tests/helpers/test_entity.py
+++ b/tests/helpers/test_entity.py
@@ -661,3 +661,43 @@ async def test_capability_attrs(hass):
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert state.attributes["always"] == "there"
+
+
+async def test_warn_slow_write_state(hass, caplog):
+ """Check that we log a warning if reading properties takes too long."""
+ mock_entity = entity.Entity()
+ mock_entity.hass = hass
+ mock_entity.entity_id = "comp_test.test_entity"
+ mock_entity.platform = MagicMock(platform_name="hue")
+
+ with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]):
+ mock_entity.async_write_ha_state()
+
+ assert (
+ "Updating state for comp_test.test_entity "
+ "() "
+ "took 10.000 seconds. Please create a bug report at "
+ "https://github.com/home-assistant/home-assistant/issues?"
+ "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22"
+ ) in caplog.text
+
+
+async def test_warn_slow_write_state_custom_component(hass, caplog):
+ """Check that we log a warning if reading properties takes too long."""
+
+ class CustomComponentEntity(entity.Entity):
+ __module__ = "custom_components.bla.sensor"
+
+ mock_entity = CustomComponentEntity()
+ mock_entity.hass = hass
+ mock_entity.entity_id = "comp_test.test_entity"
+ mock_entity.platform = MagicMock(platform_name="hue")
+
+ with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]):
+ mock_entity.async_write_ha_state()
+
+ assert (
+ "Updating state for comp_test.test_entity "
+ "(.CustomComponentEntity'>) "
+ "took 10.000 seconds. Please report it to the custom component author."
+ ) in caplog.text
diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py
index 07cea74c05fd27..306402cd2b9812 100644
--- a/tests/helpers/test_entity_component.py
+++ b/tests/helpers/test_entity_component.py
@@ -7,8 +7,9 @@
import asynctest
import pytest
+import voluptuous as vol
-from homeassistant.const import ENTITY_MATCH_ALL
+from homeassistant.const import ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
import homeassistant.core as ha
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import discovery
@@ -223,10 +224,21 @@ async def test_extract_from_service_fails_if_no_entity_id(hass):
[MockEntity(name="test_1"), MockEntity(name="test_2")]
)
- call = ha.ServiceCall("test", "service")
-
- assert [] == sorted(
- ent.entity_id for ent in (await component.async_extract_from_service(call))
+ assert (
+ await component.async_extract_from_service(ha.ServiceCall("test", "service"))
+ == []
+ )
+ assert (
+ await component.async_extract_from_service(
+ ha.ServiceCall("test", "service", {"entity_id": ENTITY_MATCH_NONE})
+ )
+ == []
+ )
+ assert (
+ await component.async_extract_from_service(
+ ha.ServiceCall("test", "service", {"area_id": ENTITY_MATCH_NONE})
+ )
+ == []
)
@@ -263,7 +275,7 @@ async def test_extract_from_service_no_group_expand(hass):
async def test_setup_dependencies_platform(hass):
"""Test we setup the dependencies of a platform.
- We're explictely testing that we process dependencies even if a component
+ We're explicitly testing that we process dependencies even if a component
with the same name has already been loaded.
"""
mock_integration(
@@ -305,7 +317,7 @@ async def test_setup_entry(hass):
async def test_setup_entry_platform_not_exist(hass):
- """Test setup entry fails if platform doesnt exist."""
+ """Test setup entry fails if platform does not exist."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
entry = MockConfigEntry(domain="non_existing")
@@ -429,3 +441,53 @@ async def test_extract_all_use_match_all(hass, caplog):
assert (
"Not passing an entity ID to a service to target all entities is deprecated"
) not in caplog.text
+
+
+async def test_register_entity_service(hass):
+ """Test not expanding a group."""
+ entity = MockEntity(entity_id=f"{DOMAIN}.entity")
+ calls = []
+
+ @ha.callback
+ def appender(**kwargs):
+ calls.append(kwargs)
+
+ entity.async_called_by_service = appender
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([entity])
+
+ component.async_register_entity_service(
+ "hello", {"some": str}, "async_called_by_service"
+ )
+
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call(
+ DOMAIN,
+ "hello",
+ {"entity_id": entity.entity_id, "invalid": "data"},
+ blocking=True,
+ )
+ assert len(calls) == 0
+
+ await hass.services.async_call(
+ DOMAIN, "hello", {"entity_id": entity.entity_id, "some": "data"}, blocking=True
+ )
+ assert len(calls) == 1
+ assert calls[0] == {"some": "data"}
+
+ await hass.services.async_call(
+ DOMAIN, "hello", {"entity_id": ENTITY_MATCH_ALL, "some": "data"}, blocking=True
+ )
+ assert len(calls) == 2
+ assert calls[1] == {"some": "data"}
+
+ await hass.services.async_call(
+ DOMAIN, "hello", {"entity_id": ENTITY_MATCH_NONE, "some": "data"}, blocking=True
+ )
+ assert len(calls) == 2
+
+ await hass.services.async_call(
+ DOMAIN, "hello", {"area_id": ENTITY_MATCH_NONE, "some": "data"}, blocking=True
+ )
+ assert len(calls) == 2
diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py
index 8eea8ad004f833..ee43f5d4f1de73 100644
--- a/tests/helpers/test_entity_platform.py
+++ b/tests/helpers/test_entity_platform.py
@@ -270,8 +270,6 @@ async def test_parallel_updates_async_platform_with_constant(hass):
handle = list(component._platforms.values())[-1]
- assert handle.parallel_updates == 2
-
class AsyncEntity(MockEntity):
"""Mock entity that has async_update."""
@@ -296,7 +294,6 @@ async def test_parallel_updates_sync_platform(hass):
await component.async_setup({DOMAIN: {"platform": "platform"}})
handle = list(component._platforms.values())[-1]
- assert handle.parallel_updates is None
class SyncEntity(MockEntity):
"""Mock entity that has update."""
@@ -323,7 +320,6 @@ async def test_parallel_updates_sync_platform_with_constant(hass):
await component.async_setup({DOMAIN: {"platform": "platform"}})
handle = list(component._platforms.values())[-1]
- assert handle.parallel_updates == 2
class SyncEntity(MockEntity):
"""Mock entity that has update."""
@@ -394,7 +390,7 @@ async def test_using_prescribed_entity_id(hass):
async def test_using_prescribed_entity_id_with_unique_id(hass):
- """Test for ammending predefined entity ID because currently exists."""
+ """Test for amending predefined entity ID because currently exists."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
await component.async_add_entities([MockEntity(entity_id="test_domain.world")])
@@ -839,7 +835,7 @@ async def test_override_restored_entities(hass):
async def test_platform_with_no_setup(hass, caplog):
- """Test setting up a platform that doesnt' support setup."""
+ """Test setting up a platform that does not support setup."""
entity_platform = MockEntityPlatform(
hass, domain="mock-integration", platform_name="mock-platform", platform=None
)
diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py
index e532d99f33313c..e7a7b856da2ed6 100644
--- a/tests/helpers/test_entity_registry.py
+++ b/tests/helpers/test_entity_registry.py
@@ -72,6 +72,9 @@ def test_get_or_create_updates_data(registry):
supported_features=5,
device_class="mock-device-class",
disabled_by=entity_registry.DISABLED_HASS,
+ unit_of_measurement="initial-unit_of_measurement",
+ original_name="initial-original_name",
+ original_icon="initial-original_icon",
)
assert orig_entry.config_entry_id == orig_config_entry.entry_id
@@ -80,6 +83,9 @@ def test_get_or_create_updates_data(registry):
assert orig_entry.supported_features == 5
assert orig_entry.device_class == "mock-device-class"
assert orig_entry.disabled_by == entity_registry.DISABLED_HASS
+ assert orig_entry.unit_of_measurement == "initial-unit_of_measurement"
+ assert orig_entry.original_name == "initial-original_name"
+ assert orig_entry.original_icon == "initial-original_icon"
new_config_entry = MockConfigEntry(domain="light")
@@ -93,6 +99,9 @@ def test_get_or_create_updates_data(registry):
supported_features=10,
device_class="new-mock-device-class",
disabled_by=entity_registry.DISABLED_USER,
+ unit_of_measurement="updated-unit_of_measurement",
+ original_name="updated-original_name",
+ original_icon="updated-original_icon",
)
assert new_entry.config_entry_id == new_config_entry.entry_id
@@ -100,6 +109,9 @@ def test_get_or_create_updates_data(registry):
assert new_entry.capabilities == {"new-max": 100}
assert new_entry.supported_features == 10
assert new_entry.device_class == "new-mock-device-class"
+ assert new_entry.unit_of_measurement == "updated-unit_of_measurement"
+ assert new_entry.original_name == "updated-original_name"
+ assert new_entry.original_icon == "updated-original_icon"
# Should not be updated
assert new_entry.disabled_by == entity_registry.DISABLED_HASS
@@ -147,6 +159,11 @@ async def test_loading_saving_data(hass, registry):
supported_features=5,
device_class="mock-device-class",
disabled_by=entity_registry.DISABLED_HASS,
+ original_name="Original Name",
+ original_icon="hass:original-icon",
+ )
+ orig_entry2 = registry.async_update_entity(
+ orig_entry2.entity_id, name="User Name", icon="hass:user-icon"
)
assert len(registry.entities) == 2
@@ -169,6 +186,10 @@ async def test_loading_saving_data(hass, registry):
assert new_entry2.capabilities == {"max": 100}
assert new_entry2.supported_features == 5
assert new_entry2.device_class == "mock-device-class"
+ assert new_entry2.name == "User Name"
+ assert new_entry2.icon == "hass:user-icon"
+ assert new_entry2.original_name == "Original Name"
+ assert new_entry2.original_icon == "hass:original-icon"
def test_generate_entity_considers_registered_entities(registry):
@@ -434,6 +455,7 @@ async def test_update_entity(registry):
for attr_name, new_value in (
("name", "new name"),
+ ("icon", "new icon"),
("disabled_by", entity_registry.DISABLED_USER),
):
changes = {attr_name: new_value}
@@ -503,6 +525,8 @@ async def test_restore_states(hass):
capabilities={"max": 100},
supported_features=5,
device_class="mock-device-class",
+ original_name="Mock Original Name",
+ original_icon="hass:original-icon",
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {})
@@ -524,6 +548,8 @@ async def test_restore_states(hass):
"supported_features": 5,
"device_class": "mock-device-class",
"restored": True,
+ "friendly_name": "Mock Original Name",
+ "icon": "hass:original-icon",
}
registry.async_remove("light.disabled")
diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py
index cef8baec70e839..313710d03b7da7 100644
--- a/tests/helpers/test_event.py
+++ b/tests/helpers/test_event.py
@@ -475,7 +475,7 @@ async def test_track_sunrise_update_location(hass):
with patch("homeassistant.util.dt.utcnow", return_value=utc_now):
async_track_sunrise(hass, lambda: runs.append(1))
- # Mimick sunrise
+ # Mimic sunrise
_send_time_changed(hass, next_rising)
await hass.async_block_till_done()
assert len(runs) == 1
@@ -485,7 +485,7 @@ async def test_track_sunrise_update_location(hass):
await hass.config.async_update(latitude=40.755931, longitude=-73.984606)
await hass.async_block_till_done()
- # Mimick sunrise
+ # Mimic sunrise
_send_time_changed(hass, next_rising)
await hass.async_block_till_done()
# Did not increase
@@ -501,7 +501,7 @@ async def test_track_sunrise_update_location(hass):
break
mod += 1
- # Mimick sunrise at new location
+ # Mimic sunrise at new location
_send_time_changed(hass, next_rising)
await hass.async_block_till_done()
assert len(runs) == 2
diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py
index a7fe2c25236879..443b131b2aa3de 100644
--- a/tests/helpers/test_script.py
+++ b/tests/helpers/test_script.py
@@ -1,11 +1,11 @@
"""The tests for the Script component."""
# pylint: disable=protected-access
+import asyncio
from datetime import timedelta
-import functools as ft
+import logging
from unittest import mock
import asynctest
-import jinja2
import pytest
import voluptuous as vol
@@ -21,80 +21,94 @@
ENTITY_ID = "script.test"
+_ALL_RUN_MODES = [None, "background", "blocking"]
-async def test_firing_event(hass):
+
+async def test_firing_event_basic(hass):
"""Test the firing of events."""
event = "test_event"
context = Context()
- calls = []
@callback
def record_event(event):
"""Add recorded event to set."""
- calls.append(event)
+ events.append(event)
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
- )
+ schema = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
- await script_obj.async_run(context=context)
+ # For this one test we'll make sure "legacy" works the same as None.
+ for run_mode in _ALL_RUN_MODES + ["legacy"]:
+ events = []
- await hass.async_block_till_done()
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get("hello") == "world"
- assert not script_obj.can_cancel
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run(context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].context is context
+ assert events[0].data.get("hello") == "world"
+ assert not script_obj.can_cancel
async def test_firing_event_template(hass):
"""Test the firing of events."""
event = "test_event"
context = Context()
- calls = []
@callback
def record_event(event):
"""Add recorded event to set."""
- calls.append(event)
+ events.append(event)
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- {
- "event": event,
- "event_data_template": {
- "dict": {
- 1: "{{ is_world }}",
- 2: "{{ is_world }}{{ is_world }}",
- 3: "{{ is_world }}{{ is_world }}{{ is_world }}",
- },
- "list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"],
+ schema = cv.SCRIPT_SCHEMA(
+ {
+ "event": event,
+ "event_data_template": {
+ "dict": {
+ 1: "{{ is_world }}",
+ 2: "{{ is_world }}{{ is_world }}",
+ 3: "{{ is_world }}{{ is_world }}{{ is_world }}",
},
- }
- ),
+ "list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"],
+ },
+ }
)
- await script_obj.async_run({"is_world": "yes"}, context=context)
+ for run_mode in _ALL_RUN_MODES:
+ events = []
- await hass.async_block_till_done()
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data == {
- "dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"},
- "list": ["yes", "yesyes"],
- }
- assert not script_obj.can_cancel
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run({"is_world": "yes"}, context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].context is context
+ assert events[0].data == {
+ "dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"},
+ "list": ["yes", "yesyes"],
+ }
-async def test_calling_service(hass):
+async def test_calling_service_basic(hass):
"""Test the calling of a service."""
- calls = []
context = Context()
@callback
@@ -104,25 +118,76 @@ def record_call(service):
hass.services.async_register("test", "script", record_call)
- hass.async_add_job(
- ft.partial(
- script.call_from_config,
- hass,
- {"service": "test.script", "data": {"hello": "world"}},
- context=context,
- )
- )
+ schema = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}})
+
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run(context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get("hello") == "world"
- await hass.async_block_till_done()
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get("hello") == "world"
+async def test_cancel_no_wait(hass, caplog):
+ """Test stopping script."""
+ event = "test_event"
+
+ async def async_simulate_long_service(service):
+ """Simulate a service that takes a not insignificant time."""
+ await asyncio.sleep(0.01)
+
+ hass.services.async_register("test", "script", async_simulate_long_service)
+
+ @callback
+ def monitor_event(event):
+ """Signal event happened."""
+ event_sem.release()
+
+ hass.bus.async_listen(event, monitor_event)
+
+ schema = cv.SCRIPT_SCHEMA([{"event": event}, {"service": "test.script"}])
+
+ for run_mode in _ALL_RUN_MODES:
+ event_sem = asyncio.Semaphore(0)
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ tasks = []
+ for _ in range(3):
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ tasks.append(hass.async_create_task(event_sem.acquire()))
+ await asyncio.wait_for(asyncio.gather(*tasks), 1)
+
+ # Can't assert just yet because we haven't verified stopping works yet.
+ # If assert fails we can hang test if async_stop doesn't work.
+ script_was_runing = script_obj.is_running
+
+ await script_obj.async_stop()
+ await hass.async_block_till_done()
+
+ assert script_was_runing
+ assert not script_obj.is_running
async def test_activating_scene(hass):
"""Test the activation of a scene."""
- calls = []
context = Context()
@callback
@@ -132,22 +197,29 @@ def record_call(service):
hass.services.async_register(scene.DOMAIN, SERVICE_TURN_ON, record_call)
- hass.async_add_job(
- ft.partial(
- script.call_from_config, hass, {"scene": "scene.hello"}, context=context
- )
- )
+ schema = cv.SCRIPT_SCHEMA({"scene": "scene.hello"})
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.hello"
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run(context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.hello"
async def test_calling_service_template(hass):
"""Test the calling of a service."""
- calls = []
context = Context()
@callback
@@ -157,45 +229,179 @@ def record_call(service):
hass.services.async_register("test", "script", record_call)
- hass.async_add_job(
- ft.partial(
- script.call_from_config,
- hass,
- {
- "service_template": """
- {% if True %}
- test.script
+ schema = cv.SCRIPT_SCHEMA(
+ {
+ "service_template": """
+ {% if True %}
+ test.script
+ {% else %}
+ test.not_script
+ {% endif %}""",
+ "data_template": {
+ "hello": """
+ {% if is_world == 'yes' %}
+ world
{% else %}
- test.not_script
- {% endif %}""",
- "data_template": {
- "hello": """
- {% if is_world == 'yes' %}
- world
- {% else %}
- not world
- {% endif %}
- """
- },
+ not world
+ {% endif %}
+ """
},
- {"is_world": "yes"},
- context=context,
- )
+ }
)
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run({"is_world": "yes"}, context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get("hello") == "world"
+
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get("hello") == "world"
+async def test_multiple_runs_no_wait(hass):
+ """Test multiple runs with no wait in script."""
+ logger = logging.getLogger("TEST")
+ async def async_simulate_long_service(service):
+ """Simulate a service that takes a not insignificant time."""
-async def test_delay(hass):
+ @callback
+ def service_done_cb(event):
+ logger.debug("simulated service (%s:%s) done", fire, listen)
+ service_done.set()
+
+ calls.append(service)
+
+ fire = service.data.get("fire")
+ listen = service.data.get("listen")
+ logger.debug("simulated service (%s:%s) started", fire, listen)
+
+ service_done = asyncio.Event()
+ unsub = hass.bus.async_listen(listen, service_done_cb)
+
+ hass.bus.async_fire(fire)
+
+ await service_done.wait()
+ unsub()
+
+ hass.services.async_register("test", "script", async_simulate_long_service)
+
+ heard_event = asyncio.Event()
+
+ @callback
+ def heard_event_cb(event):
+ logger.debug("heard: %s", event)
+ heard_event.set()
+
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "service": "test.script",
+ "data_template": {"fire": "{{ fire1 }}", "listen": "{{ listen1 }}"},
+ },
+ {
+ "service": "test.script",
+ "data_template": {"fire": "{{ fire2 }}", "listen": "{{ listen2 }}"},
+ },
+ ]
+ )
+
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
+ heard_event.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ # Start script twice in such a way that second run will be started while first
+ # run is in the middle of the first service call.
+
+ unsub = hass.bus.async_listen("1", heard_event_cb)
+
+ logger.debug("starting 1st script")
+ coro = script_obj.async_run(
+ {"fire1": "1", "listen1": "2", "fire2": "3", "listen2": "4"}
+ )
+ if run_mode == "background":
+ await coro
+ else:
+ hass.async_create_task(coro)
+ await asyncio.wait_for(heard_event.wait(), 1)
+
+ unsub()
+
+ logger.debug("starting 2nd script")
+ await script_obj.async_run(
+ {"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"}
+ )
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 4
+
+
+async def test_delay_basic(hass):
"""Test the delay."""
- event = "test_event"
- events = []
- context = Context()
delay_alias = "delay step"
+ delay_started_flag = asyncio.Event()
+
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
+
+ delay = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA({"delay": delay, "alias": delay_alias})
+
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert script_obj.last_action == delay_alias
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + delay
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert script_obj.last_action is None
+
+
+async def test_multiple_runs_delay(hass):
+ """Test multiple runs with delay in script."""
+ event = "test_event"
+ delay_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -204,40 +410,105 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": {"seconds": 5}, "alias": delay_alias},
- {"event": event},
- ]
- ),
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
+
+ delay = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"delay": delay},
+ {"event": event, "event_data": {"value": 2}},
+ ]
)
- await script_obj.async_run(context=context)
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ delay_started_flag.clear()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == delay_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[-1].data["value"] == 1
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ # Start second run of script while first run is in a delay.
+ await script_obj.async_run()
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + delay
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ if run_mode in (None, "legacy"):
+ assert len(events) == 2
+ else:
+ assert len(events) == 4
+ assert events[-3].data["value"] == 1
+ assert events[-2].data["value"] == 2
+ assert events[-1].data["value"] == 2
+
+
+async def test_delay_template_ok(hass):
+ """Test the delay as a template."""
+ delay_started_flag = asyncio.Event()
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
- assert not script_obj.is_running
- assert len(events) == 2
- assert events[0].context is context
- assert events[1].context is context
+ schema = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 1 }}"})
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
-async def test_delay_template(hass):
- """Test the delay as a template."""
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
+ assert script_obj.is_running
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+
+
+async def test_delay_template_invalid(hass, caplog):
+ """Test the delay as a template that fails."""
event = "test_event"
- events = []
- delay_alias = "delay step"
@callback
def record_event(event):
@@ -246,37 +517,82 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": "00:00:{{ 5 }}", "alias": delay_alias},
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {"delay": "{{ invalid_delay }}"},
+ {"delay": {"seconds": 5}},
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == delay_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+ start_idx = len(caplog.records)
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 2
+ assert any(
+ rec.levelname == "ERROR" and "Error rendering" in rec.message
+ for rec in caplog.records[start_idx:]
+ )
+ assert not script_obj.is_running
+ assert len(events) == 1
-async def test_delay_invalid_template(hass):
- """Test the delay as a template that fails."""
+
+async def test_delay_template_complex_ok(hass):
+ """Test the delay with a working complex template."""
+ delay_started_flag = asyncio.Event()
+
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
+
+ milliseconds = 10
+ schema = cv.SCRIPT_SCHEMA({"delay": {"milliseconds": "{{ milliseconds }}"}})
+
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ coro = script_obj.async_run({"milliseconds": milliseconds})
+ if run_mode == "background":
+ await coro
+ else:
+ hass.async_create_task(coro)
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
+ assert script_obj.is_running
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + timedelta(milliseconds=milliseconds)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+
+
+async def test_delay_template_complex_invalid(hass, caplog):
+ """Test the delay with a complex template that fails."""
event = "test_event"
- events = []
@callback
def record_event(event):
@@ -285,32 +601,44 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": "{{ invalid_delay }}"},
- {"delay": {"seconds": 5}},
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {"delay": {"seconds": "{{ invalid_delay }}"}},
+ {"delay": {"seconds": 5}},
+ {"event": event},
+ ]
)
- with mock.patch.object(script, "_LOGGER") as mock_logger:
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+ start_idx = len(caplog.records)
+
await script_obj.async_run()
await hass.async_block_till_done()
- assert mock_logger.error.called
- assert not script_obj.is_running
- assert len(events) == 1
+ assert any(
+ rec.levelname == "ERROR" and "Error rendering" in rec.message
+ for rec in caplog.records[start_idx:]
+ )
+ assert not script_obj.is_running
+ assert len(events) == 1
-async def test_delay_complex_template(hass):
- """Test the delay with a working complex template."""
+
+async def test_cancel_delay(hass):
+ """Test the cancelling while the delay is present."""
+ delay_started_flag = asyncio.Event()
event = "test_event"
- events = []
- delay_alias = "delay step"
+
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
@callback
def record_event(event):
@@ -319,37 +647,101 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": {"seconds": "{{ 5 }}"}, "alias": delay_alias},
- {"event": event},
- ]
- ),
- )
+ delay = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA([{"delay": delay}, {"event": event}])
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
+ events = []
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == delay_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ await script_obj.async_stop()
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ assert not script_obj.is_running
- assert not script_obj.is_running
- assert len(events) == 2
+ # Make sure the script is really stopped.
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + delay
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
-async def test_delay_complex_invalid_template(hass):
- """Test the delay with a complex template that fails."""
+ assert not script_obj.is_running
+ assert len(events) == 0
+
+
+async def test_wait_template_basic(hass):
+ """Test the wait template."""
+ wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
+
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ schema = cv.SCRIPT_SCHEMA(
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "alias": wait_alias,
+ }
+ )
+
+ for run_mode in _ALL_RUN_MODES:
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert script_obj.last_action == wait_alias
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert script_obj.last_action is None
+
+
+async def test_multiple_runs_wait_template(hass):
+ """Test multiple runs with wait_template in script."""
event = "test_event"
- events = []
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -358,31 +750,70 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": {"seconds": "{{ invalid_delay }}"}},
- {"delay": {"seconds": "{{ 5 }}"}},
- {"event": event},
- ]
- ),
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
)
- with mock.patch.object(script, "_LOGGER") as mock_logger:
- await script_obj.async_run()
- await hass.async_block_till_done()
- assert mock_logger.error.called
-
- assert not script_obj.is_running
- assert len(events) == 1
-
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
-async def test_cancel_while_delay(hass):
- """Test the cancelling while the delay is present."""
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[-1].data["value"] == 1
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ # Start second run of script while first run is in wait_template.
+ if run_mode == "blocking":
+ hass.async_create_task(script_obj.async_run())
+ else:
+ await script_obj.async_run()
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ if run_mode in (None, "legacy"):
+ assert len(events) == 2
+ else:
+ assert len(events) == 4
+ assert events[-3].data["value"] == 1
+ assert events[-2].data["value"] == 2
+ assert events[-1].data["value"] == 2
+
+
+async def test_cancel_wait_template(hass):
+ """Test the cancelling while wait_template is present."""
+ wait_started_flag = asyncio.Event()
event = "test_event"
- events = []
+
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
@callback
def record_event(event):
@@ -391,35 +822,54 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA([{"delay": {"seconds": 5}}, {"event": event}])
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ wait_started_flag.clear()
+ events = []
+ hass.states.async_set("switch.test", "on")
- assert script_obj.is_running
- assert len(events) == 0
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ await script_obj.async_stop()
- script_obj.async_stop()
+ assert not script_obj.is_running
- assert not script_obj.is_running
+ # Make sure the script is really stopped.
- # Make sure the script is really stopped.
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 0
+ assert not script_obj.is_running
+ assert len(events) == 0
-async def test_wait_template(hass):
- """Test the wait template."""
+async def test_wait_template_not_schedule(hass):
+ """Test the wait template with correct condition."""
event = "test_event"
- events = []
- context = Context()
- wait_alias = "wait step"
@callback
def record_event(event):
@@ -430,42 +880,33 @@ def record_event(event):
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {"wait_template": "{{ states.switch.test.state == 'on' }}"},
+ {"event": event},
+ ]
)
- await script_obj.async_run(context=context)
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- hass.states.async_set("switch.test", "off")
- await hass.async_block_till_done()
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 2
- assert events[0].context is context
- assert events[1].context is context
+ assert not script_obj.is_running
+ assert len(events) == 2
-async def test_wait_template_cancel(hass):
- """Test the wait template cancel action."""
+async def test_wait_template_timeout_halt(hass):
+ """Test the wait template, halt on timeout."""
event = "test_event"
- events = []
- wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -474,46 +915,124 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ timeout = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "continue_on_timeout": False,
+ "timeout": timeout,
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + timeout
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- script_obj.async_stop()
+ assert not script_obj.is_running
+ assert len(events) == 0
- assert not script_obj.is_running
- assert len(events) == 1
- hass.states.async_set("switch.test", "off")
- await hass.async_block_till_done()
+async def test_wait_template_timeout_continue(hass):
+ """Test the wait template with continuing the script."""
+ event = "test_event"
+ wait_started_flag = asyncio.Event()
- assert not script_obj.is_running
- assert len(events) == 1
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+ hass.bus.async_listen(event, record_event)
-async def test_wait_template_not_schedule(hass):
- """Test the wait template with correct condition."""
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ hass.states.async_set("switch.test", "on")
+
+ timeout = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "continue_on_timeout": True,
+ "timeout": timeout,
+ },
+ {"event": event},
+ ]
+ )
+
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + timeout
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 1
+
+
+async def test_wait_template_timeout_default(hass):
+ """Test the wait template with default continue."""
event = "test_event"
- events = []
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -522,32 +1041,102 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"wait_template": "{{states.switch.test.state == 'on'}}"},
- {"event": event},
- ]
- ),
+ timeout = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "timeout": timeout,
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + timeout
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert script_obj.can_cancel
- assert len(events) == 2
+ assert not script_obj.is_running
+ assert len(events) == 1
-async def test_wait_template_timeout_halt(hass):
- """Test the wait template, halt on timeout."""
+async def test_wait_template_variables(hass):
+ """Test the wait template with variables."""
+ wait_started_flag = asyncio.Event()
+
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ schema = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"})
+
+ for run_mode in _ALL_RUN_MODES:
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ coro = script_obj.async_run({"data": "switch.test"})
+ if run_mode == "background":
+ await coro
+ else:
+ hass.async_create_task(coro)
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+
+
+async def test_condition_basic(hass):
+ """Test if we can use conditions in a script."""
event = "test_event"
events = []
- wait_alias = "wait step"
@callback
def record_event(event):
@@ -556,45 +1145,46 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- hass.states.async_set("switch.test", "on")
-
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "continue_on_timeout": False,
- "timeout": 5,
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {
+ "condition": "template",
+ "value_template": "{{ states.test.entity.state == 'hello' }}",
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ hass.states.async_set("test.entity", "hello")
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert len(events) == 2
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ hass.states.async_set("test.entity", "goodbye")
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 1
+ assert len(events) == 3
-async def test_wait_template_timeout_continue(hass):
- """Test the wait template with continuing the script."""
+@asynctest.patch("homeassistant.helpers.script.condition.async_from_config")
+async def test_condition_created_once(async_from_config, hass):
+ """Test that the conditions do not get created multiple times."""
event = "test_event"
events = []
- wait_alias = "wait step"
@callback
def record_event(event):
@@ -603,7 +1193,7 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- hass.states.async_set("switch.test", "on")
+ hass.states.async_set("test.entity", "hello")
script_obj = script.Script(
hass,
@@ -611,37 +1201,25 @@ def record_event(event):
[
{"event": event},
{
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "timeout": 5,
- "continue_on_timeout": True,
- "alias": wait_alias,
+ "condition": "template",
+ "value_template": '{{ states.test.entity.state == "hello" }}',
},
{"event": event},
]
),
)
+ await script_obj.async_run()
await script_obj.async_run()
await hass.async_block_till_done()
-
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
-
- assert not script_obj.is_running
- assert len(events) == 2
+ assert async_from_config.call_count == 1
+ assert len(script_obj._config_cache) == 1
-async def test_wait_template_timeout_default(hass):
- """Test the wait template with default contiune."""
+async def test_condition_all_cached(hass):
+ """Test that multiple conditions get cached."""
event = "test_event"
events = []
- wait_alias = "wait step"
@callback
def record_event(event):
@@ -650,7 +1228,7 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- hass.states.async_set("switch.test", "on")
+ hass.states.async_set("test.entity", "hello")
script_obj = script.Script(
hass,
@@ -658,9 +1236,12 @@ def record_event(event):
[
{"event": event},
{
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "timeout": 5,
- "alias": wait_alias,
+ "condition": "template",
+ "value_template": '{{ states.test.entity.state == "hello" }}',
+ },
+ {
+ "condition": "template",
+ "value_template": '{{ states.test.entity.state != "hello" }}',
},
{"event": event},
]
@@ -669,187 +1250,228 @@ def record_event(event):
await script_obj.async_run()
await hass.async_block_till_done()
+ assert len(script_obj._config_cache) == 2
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+async def test_last_triggered(hass):
+ """Test the last_triggered."""
+ event = "test_event"
- assert not script_obj.is_running
- assert len(events) == 2
+ schema = cv.SCRIPT_SCHEMA({"event": event})
+ for run_mode in _ALL_RUN_MODES:
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
-async def test_wait_template_variables(hass):
- """Test the wait template with variables."""
+ assert script_obj.last_triggered is None
+
+ time = dt_util.utcnow()
+ with mock.patch("homeassistant.helpers.script.utcnow", return_value=time):
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.last_triggered == time
+
+
+async def test_propagate_error_service_not_found(hass):
+ """Test that a script aborts when a service is not found."""
event = "test_event"
- events = []
- wait_alias = "wait step"
@callback
def record_event(event):
- """Add recorded event to set."""
events.append(event)
hass.bus.async_listen(event, record_event)
- hass.states.async_set("switch.test", "on")
+ schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"wait_template": "{{is_state(data, 'off')}}", "alias": wait_alias},
- {"event": event},
- ]
- ),
- )
+ run_modes = _ALL_RUN_MODES
+ if "background" in run_modes:
+ run_modes.remove("background")
+ for run_mode in run_modes:
+ events = []
- await script_obj.async_run({"data": "switch.test"})
- await hass.async_block_till_done()
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ with pytest.raises(exceptions.ServiceNotFound):
+ await script_obj.async_run()
+
+ assert len(events) == 0
+ assert not script_obj.is_running
- hass.states.async_set("switch.test", "off")
- await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 2
+async def test_propagate_error_invalid_service_data(hass):
+ """Test that a script aborts when we send invalid service data."""
+ event = "test_event"
+ @callback
+ def record_event(event):
+ events.append(event)
-async def test_passing_variables_to_script(hass):
- """Test if we can pass variables to script."""
- calls = []
+ hass.bus.async_listen(event, record_event)
@callback
def record_call(service):
"""Add recorded event to set."""
calls.append(service)
- hass.services.async_register("test", "script", record_call)
-
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {
- "service": "test.script",
- "data_template": {"hello": "{{ greeting }}"},
- },
- {"delay": "{{ delay_period }}"},
- {
- "service": "test.script",
- "data_template": {"hello": "{{ greeting2 }}"},
- },
- ]
- ),
+ hass.services.async_register(
+ "test", "script", record_call, schema=vol.Schema({"text": str})
)
- await script_obj.async_run(
- {"greeting": "world", "greeting2": "universe", "delay_period": "00:00:05"}
+ schema = cv.SCRIPT_SCHEMA(
+ [{"service": "test.script", "data": {"text": 1}}, {"event": event}]
)
- await hass.async_block_till_done()
+ run_modes = _ALL_RUN_MODES
+ if "background" in run_modes:
+ run_modes.remove("background")
+ for run_mode in run_modes:
+ events = []
+ calls = []
- assert script_obj.is_running
- assert len(calls) == 1
- assert calls[-1].data["hello"] == "world"
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ with pytest.raises(vol.Invalid):
+ await script_obj.async_run()
- assert not script_obj.is_running
- assert len(calls) == 2
- assert calls[-1].data["hello"] == "universe"
+ assert len(events) == 0
+ assert len(calls) == 0
+ assert not script_obj.is_running
-async def test_condition(hass):
- """Test if we can use conditions in a script."""
+async def test_propagate_error_service_exception(hass):
+ """Test that a script aborts when a service throws an exception."""
event = "test_event"
- events = []
@callback
def record_event(event):
- """Add recorded event to set."""
events.append(event)
hass.bus.async_listen(event, record_event)
- hass.states.async_set("test.entity", "hello")
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ raise ValueError("BROKEN")
+
+ hass.services.async_register("test", "script", record_call)
+
+ schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
+
+ run_modes = _ALL_RUN_MODES
+ if "background" in run_modes:
+ run_modes.remove("background")
+ for run_mode in run_modes:
+ events = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ with pytest.raises(ValueError):
+ await script_obj.async_run()
+
+ assert len(events) == 0
+ assert not script_obj.is_running
+
+async def test_referenced_entities():
+ """Test referenced entities."""
script_obj = script.Script(
- hass,
+ None,
cv.SCRIPT_SCHEMA(
[
- {"event": event},
{
- "condition": "template",
- "value_template": '{{ states.test.entity.state == "hello" }}',
+ "service": "test.script",
+ "data": {"entity_id": "light.service_not_list"},
},
- {"event": event},
+ {
+ "service": "test.script",
+ "data": {"entity_id": ["light.service_list"]},
+ },
+ {
+ "condition": "state",
+ "entity_id": "sensor.condition",
+ "state": "100",
+ },
+ {"service": "test.script", "data": {"without": "entity_id"}},
+ {"scene": "scene.hello"},
+ {"event": "test_event"},
+ {"delay": "{{ delay_period }}"},
]
),
)
+ assert script_obj.referenced_entities == {
+ "light.service_not_list",
+ "light.service_list",
+ "sensor.condition",
+ "scene.hello",
+ }
+ # Test we cache results.
+ assert script_obj.referenced_entities is script_obj.referenced_entities
- await script_obj.async_run()
- await hass.async_block_till_done()
- assert len(events) == 2
-
- hass.states.async_set("test.entity", "goodbye")
-
- await script_obj.async_run()
- await hass.async_block_till_done()
- assert len(events) == 3
-
-
-@asynctest.patch("homeassistant.helpers.script.condition.async_from_config")
-async def test_condition_created_once(async_from_config, hass):
- """Test that the conditions do not get created multiple times."""
- event = "test_event"
- events = []
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- hass.states.async_set("test.entity", "hello")
+async def test_referenced_devices():
+ """Test referenced entities."""
script_obj = script.Script(
- hass,
+ None,
cv.SCRIPT_SCHEMA(
[
- {"event": event},
+ {"domain": "light", "device_id": "script-dev-id"},
{
- "condition": "template",
- "value_template": '{{ states.test.entity.state == "hello" }}',
+ "condition": "device",
+ "device_id": "condition-dev-id",
+ "domain": "switch",
},
- {"event": event},
]
),
)
+ assert script_obj.referenced_devices == {"script-dev-id", "condition-dev-id"}
+ # Test we cache results.
+ assert script_obj.referenced_devices is script_obj.referenced_devices
- await script_obj.async_run()
- await script_obj.async_run()
- await hass.async_block_till_done()
- assert async_from_config.call_count == 1
- assert len(script_obj._config_cache) == 1
+async def test_if_running_with_legacy_run_mode(hass, caplog):
+ """Test using if_running with run_mode='legacy'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ with pytest.raises(exceptions.HomeAssistantError):
+ script.Script(
+ hass,
+ [],
+ if_running="ignore",
+ run_mode="legacy",
+ logger=logging.getLogger("TEST"),
+ )
+ assert any(
+ rec.levelname == "ERROR"
+ and rec.name == "TEST"
+ and all(text in rec.message for text in ("if_running", "legacy"))
+ for rec in caplog.records
+ )
+
+
+async def test_if_running_ignore(hass, caplog):
+ """Test overlapping runs with if_running='ignore'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
-async def test_all_conditions_cached(hass):
- """Test that multiple conditions get cached."""
event = "test_event"
events = []
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -858,167 +1480,266 @@ def record_event(event):
hass.bus.async_listen(event, record_event)
- hass.states.async_set("test.entity", "hello")
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ hass.states.async_set("switch.test", "on")
script_obj = script.Script(
hass,
cv.SCRIPT_SCHEMA(
[
- {"event": event},
- {
- "condition": "template",
- "value_template": '{{ states.test.entity.state == "hello" }}',
- },
- {
- "condition": "template",
- "value_template": '{{ states.test.entity.state != "hello" }}',
- },
- {"event": event},
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
]
),
+ change_listener=wait_started_cb,
+ if_running="ignore",
+ run_mode="background",
+ logger=logging.getLogger("TEST"),
)
- await script_obj.async_run()
- await hass.async_block_till_done()
- assert len(script_obj._config_cache) == 2
-
-
-async def test_last_triggered(hass):
- """Test the last_triggered."""
- event = "test_event"
+ try:
+ await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [{"event": event}, {"delay": {"seconds": 5}}, {"event": event}]
- ),
- )
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[0].data["value"] == 1
- assert script_obj.last_triggered is None
+ # Start second run of script while first run is suspended in wait_template.
+ # This should ignore second run.
- time = dt_util.utcnow()
- with mock.patch("homeassistant.helpers.script.date_util.utcnow", return_value=time):
await script_obj.async_run()
+
+ assert script_obj.is_running
+ assert any(
+ rec.levelname == "INFO" and rec.name == "TEST" and "Skipping" in rec.message
+ for rec in caplog.records
+ )
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
await hass.async_block_till_done()
- assert script_obj.last_triggered == time
+ assert not script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 2
-async def test_propagate_error_service_not_found(hass):
- """Test that a script aborts when a service is not found."""
+async def test_if_running_error(hass, caplog):
+ """Test overlapping runs with if_running='error'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ event = "test_event"
events = []
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
+ """Add recorded event to set."""
events.append(event)
- hass.bus.async_listen("test_event", record_event)
+ hass.bus.async_listen(event, record_event)
+
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ hass.states.async_set("switch.test", "on")
script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}])
+ hass,
+ cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
+ ),
+ change_listener=wait_started_cb,
+ if_running="error",
+ run_mode="background",
+ logger=logging.getLogger("TEST"),
)
- with pytest.raises(exceptions.ServiceNotFound):
+ try:
await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
- assert len(events) == 0
- assert script_obj._cur == -1
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[0].data["value"] == 1
+ # Start second run of script while first run is suspended in wait_template.
+ # This should cause an error.
-async def test_propagate_error_invalid_service_data(hass):
- """Test that a script aborts when we send invalid service data."""
+ with pytest.raises(exceptions.HomeAssistantError):
+ await script_obj.async_run()
+
+ assert script_obj.is_running
+ assert any(
+ rec.levelname == "ERROR"
+ and rec.name == "TEST"
+ and "Already running" in rec.message
+ for rec in caplog.records
+ )
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 2
+
+
+async def test_if_running_restart(hass, caplog):
+ """Test overlapping runs with if_running='restart'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ event = "test_event"
events = []
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
+ """Add recorded event to set."""
events.append(event)
- hass.bus.async_listen("test_event", record_event)
-
- calls = []
+ hass.bus.async_listen(event, record_event)
@callback
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
+ def wait_started_cb():
+ wait_started_flag.set()
- hass.services.async_register(
- "test", "script", record_call, schema=vol.Schema({"text": str})
- )
+ hass.states.async_set("switch.test", "on")
script_obj = script.Script(
hass,
cv.SCRIPT_SCHEMA(
- [{"service": "test.script", "data": {"text": 1}}, {"event": "test_event"}]
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
),
+ change_listener=wait_started_cb,
+ if_running="restart",
+ run_mode="background",
+ logger=logging.getLogger("TEST"),
)
- with pytest.raises(vol.Invalid):
+ try:
await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
- assert len(events) == 0
- assert len(calls) == 0
- assert script_obj._cur == -1
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[0].data["value"] == 1
+ # Start second run of script while first run is suspended in wait_template.
+ # This should stop first run then start a new run.
-async def test_propagate_error_service_exception(hass):
- """Test that a script aborts when a service throws an exception."""
+ wait_started_flag.clear()
+ await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 1
+ assert any(
+ rec.levelname == "INFO"
+ and rec.name == "TEST"
+ and "Restarting" in rec.message
+ for rec in caplog.records
+ )
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 3
+ assert events[2].data["value"] == 2
+
+
+async def test_if_running_parallel(hass):
+ """Test overlapping runs with if_running='parallel'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ event = "test_event"
events = []
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
+ """Add recorded event to set."""
events.append(event)
- hass.bus.async_listen("test_event", record_event)
-
- calls = []
+ hass.bus.async_listen(event, record_event)
@callback
- def record_call(service):
- """Add recorded event to set."""
- raise ValueError("BROKEN")
+ def wait_started_cb():
+ wait_started_flag.set()
- hass.services.async_register("test", "script", record_call)
+ hass.states.async_set("switch.test", "on")
script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}])
+ hass,
+ cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
+ ),
+ change_listener=wait_started_cb,
+ if_running="parallel",
+ run_mode="background",
+ logger=logging.getLogger("TEST"),
)
- with pytest.raises(ValueError):
+ try:
await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
- assert len(events) == 0
- assert len(calls) == 0
- assert script_obj._cur == -1
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[0].data["value"] == 1
+ # Start second run of script while first run is suspended in wait_template.
+ # This should start a new, independent run.
-def test_log_exception():
- """Test logged output."""
- script_obj = script.Script(
- None, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}])
- )
- script_obj._exception_step = 1
+ wait_started_flag.clear()
+ await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 1
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
- for exc, msg in (
- (vol.Invalid("Invalid number"), "Invalid data"),
- (
- exceptions.TemplateError(jinja2.TemplateError("Unclosed bracket")),
- "Error rendering template",
- ),
- (exceptions.Unauthorized(), "Unauthorized"),
- (exceptions.ServiceNotFound("light", "turn_on"), "Service not found"),
- (ValueError("Cannot parse JSON"), "Unknown error"),
- ):
- logger = mock.Mock()
- script_obj.async_log_exception(logger, "Test error", exc)
-
- assert len(logger.mock_calls) == 1
- _, _, p_error_desc, p_action_type, p_step, p_error = logger.mock_calls[0][1]
-
- assert p_error_desc == msg
- assert p_action_type == script.ACTION_FIRE_EVENT
- assert p_step == 2
- if isinstance(exc, ValueError):
- assert p_error == ""
- else:
- assert p_error == str(exc)
+ assert not script_obj.is_running
+ assert len(events) == 4
+ assert events[2].data["value"] == 2
+ assert events[3].data["value"] == 2
diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py
index c80b6eac193827..cc4098a613a0e6 100644
--- a/tests/helpers/test_service.py
+++ b/tests/helpers/test_service.py
@@ -12,7 +12,13 @@
from homeassistant import core as ha, exceptions
from homeassistant.auth.permissions import PolicyPermissions
import homeassistant.components # noqa: F401
-from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, STATE_OFF, STATE_ON
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ENTITY_MATCH_ALL,
+ ENTITY_MATCH_NONE,
+ STATE_OFF,
+ STATE_ON,
+)
from homeassistant.helpers import (
device_registry as dev_reg,
entity_registry as ent_reg,
@@ -33,31 +39,29 @@
@pytest.fixture
-def mock_service_platform_call():
+def mock_handle_entity_call():
"""Mock service platform call."""
with patch(
- "homeassistant.helpers.service._handle_service_platform_call",
+ "homeassistant.helpers.service._handle_entity_call",
side_effect=lambda *args: mock_coro(),
) as mock_call:
yield mock_call
@pytest.fixture
-def mock_entities():
+def mock_entities(hass):
"""Return mock entities in an ordered dict."""
- kitchen = Mock(
+ kitchen = MockEntity(
entity_id="light.kitchen",
available=True,
should_poll=False,
supported_features=1,
- platform="test_domain",
)
- living_room = Mock(
+ living_room = MockEntity(
entity_id="light.living_room",
available=True,
should_poll=False,
supported_features=0,
- platform="test_domain",
)
entities = OrderedDict()
entities[kitchen.entity_id] = kitchen
@@ -252,6 +256,14 @@ async def test_extract_entity_ids(hass):
hass, call, expand_group=False
)
+ assert (
+ await service.async_extract_entity_ids(
+ hass,
+ ha.ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: ENTITY_MATCH_NONE}),
+ )
+ == set()
+ )
+
async def test_extract_entity_ids_from_area(hass, area_mock):
"""Test extract_entity_ids method with areas."""
@@ -266,6 +278,13 @@ async def test_extract_entity_ids_from_area(hass, area_mock):
"light.diff_area",
} == await service.async_extract_entity_ids(hass, call)
+ assert (
+ await service.async_extract_entity_ids(
+ hass, ha.ServiceCall("light", "turn_on", {"area_id": ENTITY_MATCH_NONE})
+ )
+ == set()
+ )
+
@asyncio.coroutine
def test_async_get_all_descriptions(hass):
@@ -306,6 +325,36 @@ async def test_call_with_required_features(hass, mock_entities):
assert test_service_mock.call_count == 1
+async def test_call_with_sync_func(hass, mock_entities):
+ """Test invoking sync service calls."""
+ test_service_mock = Mock()
+ await service.entity_service_call(
+ hass,
+ [Mock(entities=mock_entities)],
+ test_service_mock,
+ ha.ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}),
+ )
+ assert test_service_mock.call_count == 1
+
+
+async def test_call_with_sync_attr(hass, mock_entities):
+ """Test invoking sync service calls."""
+ mock_method = mock_entities["light.kitchen"].sync_method = Mock()
+ await service.entity_service_call(
+ hass,
+ [Mock(entities=mock_entities)],
+ "sync_method",
+ ha.ServiceCall(
+ "test_domain",
+ "test_service",
+ {"entity_id": "light.kitchen", "area_id": "abcd"},
+ ),
+ )
+ assert mock_method.call_count == 1
+ # We pass empty kwargs because both entity_id and area_id are filtered out
+ assert mock_method.mock_calls[0][2] == {}
+
+
async def test_call_context_user_not_exist(hass):
"""Check we don't allow deleted users to do things."""
with pytest.raises(exceptions.UnknownUser) as err:
@@ -323,8 +372,8 @@ async def test_call_context_user_not_exist(hass):
assert err.value.context.user_id == "non-existing"
-async def test_call_context_target_all(hass, mock_service_platform_call, mock_entities):
- """Check we only target allowed entities if targetting all."""
+async def test_call_context_target_all(hass, mock_handle_entity_call, mock_entities):
+ """Check we only target allowed entities if targeting all."""
with patch(
"homeassistant.auth.AuthManager.async_get_user",
return_value=mock_coro(
@@ -347,13 +396,12 @@ async def test_call_context_target_all(hass, mock_service_platform_call, mock_en
),
)
- assert len(mock_service_platform_call.mock_calls) == 1
- entities = mock_service_platform_call.mock_calls[0][1][2]
- assert entities == [mock_entities["light.kitchen"]]
+ assert len(mock_handle_entity_call.mock_calls) == 1
+ assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen"
async def test_call_context_target_specific(
- hass, mock_service_platform_call, mock_entities
+ hass, mock_handle_entity_call, mock_entities
):
"""Check targeting specific entities."""
with patch(
@@ -378,13 +426,12 @@ async def test_call_context_target_specific(
),
)
- assert len(mock_service_platform_call.mock_calls) == 1
- entities = mock_service_platform_call.mock_calls[0][1][2]
- assert entities == [mock_entities["light.kitchen"]]
+ assert len(mock_handle_entity_call.mock_calls) == 1
+ assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen"
async def test_call_context_target_specific_no_auth(
- hass, mock_service_platform_call, mock_entities
+ hass, mock_handle_entity_call, mock_entities
):
"""Check targeting specific entities without auth."""
with pytest.raises(exceptions.Unauthorized) as err:
@@ -408,9 +455,7 @@ async def test_call_context_target_specific_no_auth(
assert err.value.entity_id == "light.kitchen"
-async def test_call_no_context_target_all(
- hass, mock_service_platform_call, mock_entities
-):
+async def test_call_no_context_target_all(hass, mock_handle_entity_call, mock_entities):
"""Check we target all if no user context given."""
await service.entity_service_call(
hass,
@@ -421,13 +466,14 @@ async def test_call_no_context_target_all(
),
)
- assert len(mock_service_platform_call.mock_calls) == 1
- entities = mock_service_platform_call.mock_calls[0][1][2]
- assert entities == list(mock_entities.values())
+ assert len(mock_handle_entity_call.mock_calls) == 2
+ assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list(
+ mock_entities.values()
+ )
async def test_call_no_context_target_specific(
- hass, mock_service_platform_call, mock_entities
+ hass, mock_handle_entity_call, mock_entities
):
"""Check we can target specified entities."""
await service.entity_service_call(
@@ -441,15 +487,14 @@ async def test_call_no_context_target_specific(
),
)
- assert len(mock_service_platform_call.mock_calls) == 1
- entities = mock_service_platform_call.mock_calls[0][1][2]
- assert entities == [mock_entities["light.kitchen"]]
+ assert len(mock_handle_entity_call.mock_calls) == 1
+ assert mock_handle_entity_call.mock_calls[0][1][1].entity_id == "light.kitchen"
async def test_call_with_match_all(
- hass, mock_service_platform_call, mock_entities, caplog
+ hass, mock_handle_entity_call, mock_entities, caplog
):
- """Check we only target allowed entities if targetting all."""
+ """Check we only target allowed entities if targeting all."""
await service.entity_service_call(
hass,
[Mock(entities=mock_entities)],
@@ -457,20 +502,13 @@ async def test_call_with_match_all(
ha.ServiceCall("test_domain", "test_service", {"entity_id": "all"}),
)
- assert len(mock_service_platform_call.mock_calls) == 1
- entities = mock_service_platform_call.mock_calls[0][1][2]
- assert entities == [
- mock_entities["light.kitchen"],
- mock_entities["light.living_room"],
- ]
- assert (
- "Not passing an entity ID to a service to target all entities is deprecated"
- ) not in caplog.text
+ assert len(mock_handle_entity_call.mock_calls) == 2
+ assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list(
+ mock_entities.values()
+ )
-async def test_call_with_omit_entity_id(
- hass, mock_service_platform_call, mock_entities
-):
+async def test_call_with_omit_entity_id(hass, mock_handle_entity_call, mock_entities):
"""Check service call if we do not pass an entity ID."""
await service.entity_service_call(
hass,
@@ -479,9 +517,7 @@ async def test_call_with_omit_entity_id(
ha.ServiceCall("test_domain", "test_service"),
)
- assert len(mock_service_platform_call.mock_calls) == 1
- entities = mock_service_platform_call.mock_calls[0][1][2]
- assert entities == []
+ assert len(mock_handle_entity_call.mock_calls) == 0
async def test_register_admin_service(hass, hass_read_only_user, hass_admin_user):
@@ -593,96 +629,113 @@ async def mock_service_log(call):
assert len(calls) == 0
-async def test_domain_control_unauthorized(hass, hass_read_only_user, mock_entities):
+async def test_domain_control_unauthorized(hass, hass_read_only_user):
"""Test domain verification in a service call with an unauthorized user."""
+ mock_registry(
+ hass,
+ {
+ "light.kitchen": ent_reg.RegistryEntry(
+ entity_id="light.kitchen", unique_id="kitchen", platform="test_domain",
+ )
+ },
+ )
+
calls = []
async def mock_service_log(call):
"""Define a protected service."""
calls.append(call)
- with patch(
- "homeassistant.helpers.entity_registry.async_get_registry",
- return_value=mock_coro(Mock(entities=mock_entities)),
- ):
- protected_mock_service = hass.helpers.service.verify_domain_control(
- "test_domain"
- )(mock_service_log)
+ protected_mock_service = hass.helpers.service.verify_domain_control("test_domain")(
+ mock_service_log
+ )
- hass.services.async_register(
- "test_domain", "test_service", protected_mock_service, schema=None
+ hass.services.async_register(
+ "test_domain", "test_service", protected_mock_service, schema=None
+ )
+
+ with pytest.raises(exceptions.Unauthorized):
+ await hass.services.async_call(
+ "test_domain",
+ "test_service",
+ {},
+ blocking=True,
+ context=ha.Context(user_id=hass_read_only_user.id),
)
- with pytest.raises(exceptions.Unauthorized):
- await hass.services.async_call(
- "test_domain",
- "test_service",
- {},
- blocking=True,
- context=ha.Context(user_id=hass_read_only_user.id),
- )
+ assert len(calls) == 0
-async def test_domain_control_admin(hass, hass_admin_user, mock_entities):
+async def test_domain_control_admin(hass, hass_admin_user):
"""Test domain verification in a service call with an admin user."""
+ mock_registry(
+ hass,
+ {
+ "light.kitchen": ent_reg.RegistryEntry(
+ entity_id="light.kitchen", unique_id="kitchen", platform="test_domain",
+ )
+ },
+ )
+
calls = []
async def mock_service_log(call):
"""Define a protected service."""
calls.append(call)
- with patch(
- "homeassistant.helpers.entity_registry.async_get_registry",
- return_value=mock_coro(Mock(entities=mock_entities)),
- ):
- protected_mock_service = hass.helpers.service.verify_domain_control(
- "test_domain"
- )(mock_service_log)
+ protected_mock_service = hass.helpers.service.verify_domain_control("test_domain")(
+ mock_service_log
+ )
- hass.services.async_register(
- "test_domain", "test_service", protected_mock_service, schema=None
- )
+ hass.services.async_register(
+ "test_domain", "test_service", protected_mock_service, schema=None
+ )
- await hass.services.async_call(
- "test_domain",
- "test_service",
- {},
- blocking=True,
- context=ha.Context(user_id=hass_admin_user.id),
- )
+ await hass.services.async_call(
+ "test_domain",
+ "test_service",
+ {},
+ blocking=True,
+ context=ha.Context(user_id=hass_admin_user.id),
+ )
- assert len(calls) == 1
+ assert len(calls) == 1
-async def test_domain_control_no_user(hass, mock_entities):
+async def test_domain_control_no_user(hass):
"""Test domain verification in a service call with no user."""
+ mock_registry(
+ hass,
+ {
+ "light.kitchen": ent_reg.RegistryEntry(
+ entity_id="light.kitchen", unique_id="kitchen", platform="test_domain",
+ )
+ },
+ )
+
calls = []
async def mock_service_log(call):
"""Define a protected service."""
calls.append(call)
- with patch(
- "homeassistant.helpers.entity_registry.async_get_registry",
- return_value=mock_coro(Mock(entities=mock_entities)),
- ):
- protected_mock_service = hass.helpers.service.verify_domain_control(
- "test_domain"
- )(mock_service_log)
+ protected_mock_service = hass.helpers.service.verify_domain_control("test_domain")(
+ mock_service_log
+ )
- hass.services.async_register(
- "test_domain", "test_service", protected_mock_service, schema=None
- )
+ hass.services.async_register(
+ "test_domain", "test_service", protected_mock_service, schema=None
+ )
- await hass.services.async_call(
- "test_domain",
- "test_service",
- {},
- blocking=True,
- context=ha.Context(user_id=None),
- )
+ await hass.services.async_call(
+ "test_domain",
+ "test_service",
+ {},
+ blocking=True,
+ context=ha.Context(user_id=None),
+ )
- assert len(calls) == 1
+ assert len(calls) == 1
async def test_extract_from_service_available_device(hass):
@@ -712,6 +765,15 @@ async def test_extract_from_service_available_device(hass):
for ent in (await service.async_extract_entities(hass, entities, call_2))
]
+ assert (
+ await service.async_extract_entities(
+ hass,
+ entities,
+ ha.ServiceCall("test", "service", data={"entity_id": ENTITY_MATCH_NONE},),
+ )
+ == []
+ )
+
async def test_extract_from_service_empty_if_no_entity_id(hass):
"""Test the extraction from service without specifying entity."""
diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py
new file mode 100644
index 00000000000000..04fd180b60db58
--- /dev/null
+++ b/tests/helpers/test_update_coordinator.py
@@ -0,0 +1,127 @@
+"""Tests for the update coordinator."""
+from datetime import timedelta
+import logging
+
+from asynctest import CoroutineMock, Mock
+import pytest
+
+from homeassistant.helpers import update_coordinator
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+LOGGER = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def crd(hass):
+ """Coordinator mock."""
+ calls = []
+
+ async def refresh():
+ calls.append(None)
+ return len(calls)
+
+ crd = update_coordinator.DataUpdateCoordinator(
+ hass,
+ LOGGER,
+ name="test",
+ update_method=refresh,
+ update_interval=timedelta(seconds=10),
+ )
+ return crd
+
+
+async def test_async_refresh(crd):
+ """Test async_refresh for update coordinator."""
+ assert crd.data is None
+ await crd.async_refresh()
+ assert crd.data == 1
+ assert crd.last_update_success is True
+
+ updates = []
+
+ def update_callback():
+ updates.append(crd.data)
+
+ crd.async_add_listener(update_callback)
+
+ await crd.async_refresh()
+
+ assert updates == [2]
+
+ crd.async_remove_listener(update_callback)
+
+ await crd.async_refresh()
+
+ assert updates == [2]
+
+
+async def test_request_refresh(crd):
+ """Test request refresh for update coordinator."""
+ assert crd.data is None
+ await crd.async_request_refresh()
+ assert crd.data == 1
+ assert crd.last_update_success is True
+
+ # Second time we hit the debonuce
+ await crd.async_request_refresh()
+ assert crd.data == 1
+ assert crd.last_update_success is True
+
+
+async def test_refresh_fail(crd, caplog):
+ """Test a failing update function."""
+ crd.update_method = CoroutineMock(side_effect=update_coordinator.UpdateFailed)
+
+ await crd.async_refresh()
+
+ assert crd.data is None
+ assert crd.last_update_success is False
+ assert "Error fetching test data" in caplog.text
+
+ crd.update_method = CoroutineMock(return_value=1)
+
+ await crd.async_refresh()
+
+ assert crd.data == 1
+ assert crd.last_update_success is True
+
+ crd.update_method = CoroutineMock(side_effect=ValueError)
+ caplog.clear()
+
+ await crd.async_refresh()
+
+ assert crd.data == 1 # value from previous fetch
+ assert crd.last_update_success is False
+ assert "Unexpected error fetching test data" in caplog.text
+
+
+async def test_update_interval(hass, crd):
+ """Test update interval works."""
+ # Test we don't update without subscriber
+ async_fire_time_changed(hass, utcnow() + crd.update_interval)
+ await hass.async_block_till_done()
+ assert crd.data is None
+
+ # Add subscriber
+ update_callback = Mock()
+ crd.async_add_listener(update_callback)
+
+ # Test twice we update with subscriber
+ async_fire_time_changed(hass, utcnow() + crd.update_interval)
+ await hass.async_block_till_done()
+ assert crd.data == 1
+
+ async_fire_time_changed(hass, utcnow() + crd.update_interval)
+ await hass.async_block_till_done()
+ assert crd.data == 2
+
+ # Test removing listener
+ crd.async_remove_listener(update_callback)
+
+ async_fire_time_changed(hass, utcnow() + crd.update_interval)
+ await hass.async_block_till_done()
+
+ # Test we stop updating after we lose last subscriber
+ assert crd.data == 2
diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py
index 48c5360d8886cb..34704ddfb747a2 100644
--- a/tests/test_bootstrap.py
+++ b/tests/test_bootstrap.py
@@ -50,18 +50,18 @@ async def test_load_hassio(hass):
async def test_empty_setup(hass):
"""Test an empty set up loads the core."""
- await bootstrap._async_set_up_integrations(hass, {})
+ await bootstrap.async_from_config_dict({}, hass)
for domain in bootstrap.CORE_INTEGRATIONS:
assert domain in hass.config.components, domain
-async def test_core_failure_aborts(hass, caplog):
+async def test_core_failure_loads_safe_mode(hass, caplog):
"""Test failing core setup aborts further setup."""
with patch(
"homeassistant.components.homeassistant.async_setup",
return_value=mock_coro(False),
):
- await bootstrap._async_set_up_integrations(hass, {"group": {}})
+ await bootstrap.async_from_config_dict({"group": {}}, hass)
assert "core failed to initialize" in caplog.text
# We aborted early, group not set up
@@ -250,7 +250,8 @@ async def test_setup_hass(
log_no_color = Mock()
with patch(
- "homeassistant.config.async_hass_config_yaml", return_value={"browser": {}}
+ "homeassistant.config.async_hass_config_yaml",
+ return_value={"browser": {}, "frontend": {}},
):
hass = await bootstrap.async_setup_hass(
config_dir=get_test_config_dir(),
@@ -263,6 +264,7 @@ async def test_setup_hass(
)
assert "browser" in hass.config.components
+ assert "safe_mode" not in hass.config.components
assert len(mock_enable_logging.mock_calls) == 1
assert mock_enable_logging.mock_calls[0][1] == (
@@ -357,3 +359,58 @@ async def test_setup_hass_safe_mode(
# Validate we didn't try to set up config entry.
assert "browser" not in hass.config.components
assert len(browser_setup.mock_calls) == 0
+
+
+async def test_setup_hass_invalid_core_config(
+ mock_enable_logging,
+ mock_is_virtual_env,
+ mock_mount_local_lib_path,
+ mock_ensure_config_exists,
+ mock_process_ha_config_upgrade,
+):
+ """Test it works."""
+ with patch(
+ "homeassistant.config.async_hass_config_yaml",
+ return_value={"homeassistant": {"non-existing": 1}},
+ ):
+ hass = await bootstrap.async_setup_hass(
+ config_dir=get_test_config_dir(),
+ verbose=False,
+ log_rotate_days=10,
+ log_file="",
+ log_no_color=False,
+ skip_pip=True,
+ safe_mode=False,
+ )
+
+ assert "safe_mode" in hass.config.components
+
+
+async def test_setup_safe_mode_if_no_frontend(
+ mock_enable_logging,
+ mock_is_virtual_env,
+ mock_mount_local_lib_path,
+ mock_ensure_config_exists,
+ mock_process_ha_config_upgrade,
+):
+ """Test we setup safe mode if frontend didn't load."""
+ verbose = Mock()
+ log_rotate_days = Mock()
+ log_file = Mock()
+ log_no_color = Mock()
+
+ with patch(
+ "homeassistant.config.async_hass_config_yaml",
+ return_value={"map": {}, "person": {"invalid": True}},
+ ):
+ hass = await bootstrap.async_setup_hass(
+ config_dir=get_test_config_dir(),
+ verbose=verbose,
+ log_rotate_days=log_rotate_days,
+ log_file=log_file,
+ log_no_color=log_no_color,
+ skip_pip=True,
+ safe_mode=False,
+ )
+
+ assert "safe_mode" in hass.config.components
diff --git a/tests/test_config.py b/tests/test_config.py
index 1fc92ee954b182..fc5ec43093b24c 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -4,9 +4,11 @@
from collections import OrderedDict
import copy
import os
-import unittest.mock as mock
+from unittest import mock
+from unittest.mock import Mock
import asynctest
+from asynctest import CoroutineMock, patch
import pytest
from voluptuous import Invalid, MultipleInvalid
import yaml
@@ -576,7 +578,7 @@ async def test_merge(merge_log_err, hass):
async def test_merge_try_falsy(merge_log_err, hass):
- """Ensure we dont add falsy items like empty OrderedDict() to list."""
+ """Ensure we don't add falsy items like empty OrderedDict() to list."""
packages = {
"pack_falsy_to_lst": {"automation": OrderedDict()},
"pack_list2": {"light": OrderedDict()},
@@ -893,3 +895,97 @@ async def test_merge_split_component_definition(hass):
assert len(config["light one"]) == 1
assert len(config["light two"]) == 1
assert len(config["light three"]) == 1
+
+
+async def test_component_config_exceptions(hass, caplog):
+ """Test unexpected exceptions validating component config."""
+ # Config validator
+ assert (
+ await config_util.async_process_component_config(
+ hass,
+ {},
+ integration=Mock(
+ domain="test_domain",
+ get_platform=Mock(
+ return_value=Mock(
+ async_validate_config=CoroutineMock(
+ side_effect=ValueError("broken")
+ )
+ )
+ ),
+ ),
+ )
+ is None
+ )
+ assert "ValueError: broken" in caplog.text
+ assert "Unknown error calling test_domain config validator" in caplog.text
+
+ # component.CONFIG_SCHEMA
+ caplog.clear()
+ assert (
+ await config_util.async_process_component_config(
+ hass,
+ {},
+ integration=Mock(
+ domain="test_domain",
+ get_platform=Mock(return_value=None),
+ get_component=Mock(
+ return_value=Mock(
+ CONFIG_SCHEMA=Mock(side_effect=ValueError("broken"))
+ )
+ ),
+ ),
+ )
+ is None
+ )
+ assert "ValueError: broken" in caplog.text
+ assert "Unknown error calling test_domain CONFIG_SCHEMA" in caplog.text
+
+ # component.PLATFORM_SCHEMA
+ caplog.clear()
+ assert await config_util.async_process_component_config(
+ hass,
+ {"test_domain": {"platform": "test_platform"}},
+ integration=Mock(
+ domain="test_domain",
+ get_platform=Mock(return_value=None),
+ get_component=Mock(
+ return_value=Mock(
+ spec=["PLATFORM_SCHEMA_BASE"],
+ PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")),
+ )
+ ),
+ ),
+ ) == {"test_domain": []}
+ assert "ValueError: broken" in caplog.text
+ assert (
+ "Unknown error validating test_platform platform config with test_domain component platform schema"
+ in caplog.text
+ )
+
+ # platform.PLATFORM_SCHEMA
+ caplog.clear()
+ with patch(
+ "homeassistant.config.async_get_integration_with_requirements",
+ return_value=Mock( # integration that owns platform
+ get_platform=Mock(
+ return_value=Mock( # platform
+ PLATFORM_SCHEMA=Mock(side_effect=ValueError("broken"))
+ )
+ )
+ ),
+ ):
+ assert await config_util.async_process_component_config(
+ hass,
+ {"test_domain": {"platform": "test_platform"}},
+ integration=Mock(
+ domain="test_domain",
+ get_platform=Mock(return_value=None),
+ get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])),
+ ),
+ ) == {"test_domain": []}
+ assert "ValueError: broken" in caplog.text
+ assert (
+ "Unknown error validating config for test_platform platform for test_domain component with PLATFORM_SCHEMA"
+ in caplog.text
+ )
diff --git a/tests/test_core.py b/tests/test_core.py
index aa0c615ec04594..f5a6f4718cd711 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -904,6 +904,7 @@ def test_as_dict(self):
"whitelist_external_dirs": set(),
"version": __version__,
"config_source": "default",
+ "safe_mode": False,
}
assert expected == self.config.as_dict()
@@ -1180,3 +1181,59 @@ def test_context():
assert c.user_id == 23
assert c.parent_id == 100
assert c.id is not None
+
+
+async def test_async_functions_with_callback(hass):
+ """Test we deal with async functions accidentally marked as callback."""
+ runs = []
+
+ @ha.callback
+ async def test():
+ runs.append(True)
+
+ await hass.async_add_job(test)
+ assert len(runs) == 1
+
+ hass.async_run_job(test)
+ await hass.async_block_till_done()
+ assert len(runs) == 2
+
+ @ha.callback
+ async def service_handler(call):
+ runs.append(True)
+
+ hass.services.async_register("test_domain", "test_service", service_handler)
+
+ await hass.services.async_call("test_domain", "test_service", blocking=True)
+ assert len(runs) == 3
+
+
+def test_valid_entity_id():
+ """Test valid entity ID."""
+ for invalid in [
+ "_light.kitchen",
+ ".kitchen",
+ ".light.kitchen",
+ "light_.kitchen",
+ "light._kitchen",
+ "light.",
+ "light.kitchen__ceiling",
+ "light.kitchen_yo_",
+ "light.kitchen.",
+ "Light.kitchen",
+ "light.Kitchen",
+ "lightkitchen",
+ ]:
+ assert not ha.valid_entity_id(invalid), invalid
+
+ for valid in [
+ "1.a",
+ "1light.kitchen",
+ "a.1",
+ "a.a",
+ "input_boolean.hello_world_0123",
+ "light.1kitchen",
+ "light.kitchen",
+ "light.something_yoo",
+ ]:
+ assert ha.valid_entity_id(valid), valid
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 47d9e4e23fa975..745bb9c8c2cded 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -236,3 +236,9 @@ async def test_get_config_flows(hass):
flows = await loader.async_get_config_flows(hass)
assert "test_2" in flows
assert "test_1" not in flows
+
+
+async def test_get_custom_components_safe_mode(hass):
+ """Test that we get empty custom components in safe mode."""
+ hass.config.safe_mode = True
+ assert await loader.async_get_custom_components(hass) == {}
diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py
index 4b018adb5cbd39..d3f96c367d86b2 100644
--- a/tests/testing_config/custom_components/test/light.py
+++ b/tests/testing_config/custom_components/test/light.py
@@ -3,6 +3,7 @@
Call init before using it in your tests to ensure clean test data.
"""
+from homeassistant.components.light import Light
from homeassistant.const import STATE_OFF, STATE_ON
from tests.common import MockToggleEntity
@@ -18,9 +19,9 @@ def init(empty=False):
[]
if empty
else [
- MockToggleEntity("Ceiling", STATE_ON),
- MockToggleEntity("Ceiling", STATE_OFF),
- MockToggleEntity(None, STATE_OFF),
+ MockLight("Ceiling", STATE_ON),
+ MockLight("Ceiling", STATE_OFF),
+ MockLight(None, STATE_OFF),
]
)
@@ -30,3 +31,10 @@ async def async_setup_platform(
):
"""Return mock entities."""
async_add_entities_callback(ENTITIES)
+
+
+class MockLight(MockToggleEntity, Light):
+ """Mock light class."""
+
+ brightness = None
+ supported_features = 0
diff --git a/tests/util/test_json.py b/tests/util/test_json.py
index 26245482c2f359..bc93ef54a4b6c2 100644
--- a/tests/util/test_json.py
+++ b/tests/util/test_json.py
@@ -9,13 +9,16 @@
import pytest
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util.json import SerializationError, load_json, save_json
+from homeassistant.util.json import (
+ SerializationError,
+ find_paths_unserializable_data,
+ load_json,
+ save_json,
+)
# Test data that can be saved as JSON
TEST_JSON_A = {"a": 1, "B": "two"}
TEST_JSON_B = {"a": "one", "B": 2}
-# Test data that can not be saved as JSON (keys must be strings)
-TEST_BAD_OBJECT = {("A",): 1}
# Test data that can not be loaded as JSON
TEST_BAD_SERIALIED = "THIS IS NOT JSON\n"
TMP_DIR = None
@@ -71,9 +74,12 @@ def test_overwrite_and_reload():
def test_save_bad_data():
"""Test error from trying to save unserialisable data."""
- fname = _path_for("test4")
- with pytest.raises(SerializationError):
- save_json(fname, TEST_BAD_OBJECT)
+ with pytest.raises(SerializationError) as excinfo:
+ save_json("test4", {"hello": set()})
+
+ assert "Failed to serialize to JSON: test4. Bad data found at $.hello" in str(
+ excinfo.value
+ )
def test_load_bad_data():
@@ -99,3 +105,20 @@ def default(self, o):
save_json(fname, Mock(), encoder=MockJSONEncoder)
data = load_json(fname)
assert data == "9"
+
+
+def test_find_unserializable_data():
+ """Find unserializeable data."""
+ assert find_paths_unserializable_data(1) == []
+ assert find_paths_unserializable_data([1, 2]) == []
+ assert find_paths_unserializable_data({"something": "yo"}) == []
+
+ assert find_paths_unserializable_data({"something": set()}) == ["$.something"]
+ assert find_paths_unserializable_data({"something": [1, set()]}) == [
+ "$.something[1]"
+ ]
+ assert find_paths_unserializable_data([1, {"bla": set(), "blub": set()}]) == [
+ "$[1].bla",
+ "$[1].blub",
+ ]
+ assert find_paths_unserializable_data({("A",): 1}) == ["$"]
diff --git a/tox.ini b/tox.ini
index 17253e1d1e14ff..5527db738a6a58 100644
--- a/tox.ini
+++ b/tox.ini
@@ -36,6 +36,7 @@ deps =
commands =
python -m script.gen_requirements_all validate
python -m script.hassfest validate
+ pre-commit run codespell {posargs: --all-files}
pre-commit run flake8 {posargs: --all-files}
pre-commit run bandit {posargs: --all-files}
@@ -44,4 +45,4 @@ deps =
-r{toxinidir}/requirements_test.txt
-c{toxinidir}/homeassistant/package_constraints.txt
commands =
- pre-commit run --config .pre-commit-config-all.yaml mypy {posargs: --all-files}
+ pre-commit run mypy {posargs: --all-files}