diff --git a/.coveragerc b/.coveragerc
index 2716a1fed44..851922e4f3a 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -8,15 +8,6 @@ omit =
homeassistant/scripts/*.py
# omit pieces of code that rely on external devices being present
- homeassistant/components/abode/__init__.py
- homeassistant/components/abode/alarm_control_panel.py
- homeassistant/components/abode/binary_sensor.py
- homeassistant/components/abode/camera.py
- homeassistant/components/abode/cover.py
- homeassistant/components/abode/light.py
- homeassistant/components/abode/lock.py
- homeassistant/components/abode/sensor.py
- homeassistant/components/abode/switch.py
homeassistant/components/acer_projector/switch.py
homeassistant/components/actiontec/device_tracker.py
homeassistant/components/adguard/__init__.py
@@ -33,7 +24,6 @@ omit =
homeassistant/components/airvisual/sensor.py
homeassistant/components/aladdin_connect/cover.py
homeassistant/components/alarmdecoder/*
- homeassistant/components/alarmdotcom/alarm_control_panel.py
homeassistant/components/alpha_vantage/sensor.py
homeassistant/components/amazon_polly/tts.py
homeassistant/components/ambiclimate/climate.py
@@ -85,6 +75,7 @@ omit =
homeassistant/components/bluetooth_tracker/*
homeassistant/components/bme280/sensor.py
homeassistant/components/bme680/sensor.py
+ homeassistant/components/bmp280/sensor.py
homeassistant/components/bmw_connected_drive/*
homeassistant/components/bom/camera.py
homeassistant/components/bom/sensor.py
@@ -93,9 +84,6 @@ omit =
homeassistant/components/broadlink/remote.py
homeassistant/components/broadlink/sensor.py
homeassistant/components/broadlink/switch.py
- homeassistant/components/brother/__init__.py
- homeassistant/components/brother/sensor.py
- homeassistant/components/brother/const.py
homeassistant/components/brottsplatskartan/sensor.py
homeassistant/components/browser/*
homeassistant/components/brunt/cover.py
@@ -242,7 +230,11 @@ omit =
homeassistant/components/foscam/const.py
homeassistant/components/foursquare/*
homeassistant/components/free_mobile/notify.py
- homeassistant/components/freebox/*
+ homeassistant/components/freebox/__init__.py
+ homeassistant/components/freebox/device_tracker.py
+ homeassistant/components/freebox/router.py
+ homeassistant/components/freebox/sensor.py
+ homeassistant/components/freebox/switch.py
homeassistant/components/fritz/device_tracker.py
homeassistant/components/fritzbox/*
homeassistant/components/fritzbox_callmonitor/sensor.py
@@ -433,6 +425,7 @@ omit =
homeassistant/components/minecraft_server/__init__.py
homeassistant/components/minecraft_server/binary_sensor.py
homeassistant/components/minecraft_server/const.py
+ homeassistant/components/minecraft_server/helpers.py
homeassistant/components/minecraft_server/sensor.py
homeassistant/components/minio/*
homeassistant/components/mitemp_bt/sensor.py
@@ -441,7 +434,6 @@ omit =
homeassistant/components/mochad/*
homeassistant/components/modbus/*
homeassistant/components/modem_callerid/sensor.py
- homeassistant/components/mopar/*
homeassistant/components/mpchc/media_player.py
homeassistant/components/mpd/media_player.py
homeassistant/components/mqtt_room/sensor.py
@@ -450,7 +442,6 @@ omit =
homeassistant/components/mychevy/*
homeassistant/components/mycroft/*
homeassistant/components/mycroft/notify.py
- homeassistant/components/myq/cover.py
homeassistant/components/mysensors/*
homeassistant/components/mystrom/binary_sensor.py
homeassistant/components/mystrom/light.py
@@ -476,6 +467,7 @@ omit =
homeassistant/components/netgear_lte/*
homeassistant/components/netio/switch.py
homeassistant/components/neurio_energy/sensor.py
+ homeassistant/components/nextcloud/*
homeassistant/components/nfandroidtv/notify.py
homeassistant/components/niko_home_control/light.py
homeassistant/components/nilu/air_quality.py
@@ -597,7 +589,9 @@ omit =
homeassistant/components/ring/camera.py
homeassistant/components/ripple/sensor.py
homeassistant/components/rocketchat/notify.py
- homeassistant/components/roku/*
+ homeassistant/components/roku/__init__.py
+ homeassistant/components/roku/media_player.py
+ homeassistant/components/roku/remote.py
homeassistant/components/roomba/vacuum.py
homeassistant/components/route53/*
homeassistant/components/rova/sensor.py
@@ -614,6 +608,7 @@ omit =
homeassistant/components/saj/sensor.py
homeassistant/components/salt/device_tracker.py
homeassistant/components/satel_integra/*
+ homeassistant/components/schluter/*
homeassistant/components/scrape/sensor.py
homeassistant/components/scsgate/*
homeassistant/components/scsgate/cover.py
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 00000000000..713c7dc2872
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,49 @@
+
+## The problem
+
+
+
+## Environment
+
+
+- Home Assistant Core release with the issue:
+- Last working Home Assistant Core release (if known):
+- Operating environment (Home Assistant/Supervised/Docker/venv):
+- Integration causing this issue:
+- Link to integration documentation on our website:
+
+## Problem-relevant `configuration.yaml`
+
+
+```yaml
+
+```
+
+## Traceback/Error logs
+
+
+```txt
+
+```
+
+## Additional information
+
diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md
index 977abc6ef6b..9bfecda724f 100644
--- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md
+++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md
@@ -1,10 +1,10 @@
---
-name: Report a bug with Home Assistant
-about: Report an issue with Home Assistant
+name: Report a bug with Home Assistant Core
+about: Report an issue with Home Assistant Core
---
@@ -23,9 +23,9 @@ about: Report an issue with Home Assistant
Home Assistant frontend: Developer tools -> Info.
-->
-- Home Assistant release with the issue:
-- Last working Home Assistant release (if known):
-- Operating environment (Hass.io/Docker/Windows/etc.):
+- Home Assistant Core release with the issue:
+- Last working Home Assistant Core release (if known):
+- Operating environment (Home Assistant/Supervised/Docker/venv):
- Integration causing this issue:
- Link to integration documentation on our website:
diff --git a/.github/stale.yml b/.github/stale.yml
index e75d791a57c..f09f3733651 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -57,6 +57,7 @@ limitPerRun: 30
# Handle pull requests a little bit faster and with an adjusted comment.
pulls:
daysUntilStale: 30
+ exemptProjects: false
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
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7d55224c335..4211438379c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -59,3 +59,17 @@ repos:
types: [python]
require_serial: true
files: ^homeassistant/.+\.py$
+ - id: gen_requirements_all
+ name: gen_requirements_all
+ entry: script/run-in-env.sh python3 -m script.gen_requirements_all
+ pass_filenames: false
+ language: script
+ types: [json]
+ files: ^homeassistant/.+/manifest\.json$
+ - id: hassfest
+ name: hassfest
+ entry: script/run-in-env.sh python3 -m script.hassfest
+ pass_filenames: false
+ language: script
+ types: [json]
+ files: ^homeassistant/.+/manifest\.json$
diff --git a/CODEOWNERS b/CODEOWNERS
index 19bb89ff51e..4d4c7d3d900 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -17,6 +17,7 @@ homeassistant/components/abode/* @shred86
homeassistant/components/adguard/* @frenck
homeassistant/components/airly/* @bieniu
homeassistant/components/airvisual/* @bachya
+homeassistant/components/alarmdecoder/* @ajschmidt8
homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy
homeassistant/components/almond/* @gcampax @balloob
homeassistant/components/alpha_vantage/* @fabaff
@@ -51,6 +52,7 @@ homeassistant/components/beewi_smartclim/* @alemuro
homeassistant/components/bitcoin/* @fabaff
homeassistant/components/bizkaibus/* @UgaitzEtxebarria
homeassistant/components/blink/* @fronzbot
+homeassistant/components/bmp280/* @belidzs
homeassistant/components/bmw_connected_drive/* @gerard33
homeassistant/components/bom/* @maddenp
homeassistant/components/braviatv/* @robbiet480
@@ -80,12 +82,13 @@ homeassistant/components/darksky/* @fabaff
homeassistant/components/deconz/* @kane610
homeassistant/components/delijn/* @bollewolle
homeassistant/components/demo/* @home-assistant/core
+homeassistant/components/denonavr/* @scarface-4711 @starkillerOG
homeassistant/components/derivative/* @afaucogney
homeassistant/components/device_automation/* @home-assistant/core
homeassistant/components/digital_ocean/* @fabaff
homeassistant/components/directv/* @ctalkington
homeassistant/components/discogs/* @thibmaek
-homeassistant/components/doorbird/* @oblogic7
+homeassistant/components/doorbird/* @oblogic7 @bdraco
homeassistant/components/dsmr_reader/* @depl0y
homeassistant/components/dweet/* @fabaff
homeassistant/components/dynalite/* @ziv1234
@@ -96,6 +99,7 @@ homeassistant/components/edl21/* @mtdcr
homeassistant/components/egardia/* @jeroenterheerdt
homeassistant/components/eight_sleep/* @mezz64
homeassistant/components/elgato/* @frenck
+homeassistant/components/elkm1/* @bdraco
homeassistant/components/elv/* @majuss
homeassistant/components/emby/* @mezz64
homeassistant/components/emoncms/* @borpin
@@ -122,7 +126,7 @@ homeassistant/components/fortigate/* @kifeo
homeassistant/components/fortios/* @kimfrellsen
homeassistant/components/foscam/* @skgsergio
homeassistant/components/foursquare/* @robbiet480
-homeassistant/components/freebox/* @snoof85
+homeassistant/components/freebox/* @snoof85 @Quentame
homeassistant/components/fronius/* @nielstron
homeassistant/components/frontend/* @home-assistant/frontend
homeassistant/components/garmin_connect/* @cyberjunky
@@ -146,7 +150,7 @@ homeassistant/components/griddy/* @bdraco
homeassistant/components/group/* @home-assistant/core
homeassistant/components/growatt_server/* @indykoning
homeassistant/components/gtfs/* @robbiet480
-homeassistant/components/harmony/* @ehendrix23
+homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco
homeassistant/components/hassio/* @home-assistant/hass-io
homeassistant/components/heatmiser/* @andylockran
homeassistant/components/heos/* @andrewsayre
@@ -183,6 +187,7 @@ homeassistant/components/intesishome/* @jnimmo
homeassistant/components/ios/* @robbiet480
homeassistant/components/iperf3/* @rohankapoorcom
homeassistant/components/ipma/* @dgomes @abmantis
+homeassistant/components/ipp/* @ctalkington
homeassistant/components/iqvia/* @bachya
homeassistant/components/irish_rail_transport/* @ttroy50
homeassistant/components/izone/* @Swamp-Ig
@@ -210,6 +215,7 @@ homeassistant/components/luci/* @fbradyirl @mzdrale
homeassistant/components/luftdaten/* @fabaff
homeassistant/components/lupusec/* @majuss
homeassistant/components/lutron/* @JonGilmore
+homeassistant/components/lutron_caseta/* @swails
homeassistant/components/mastodon/* @fabaff
homeassistant/components/matrix/* @tinloaf
homeassistant/components/mcp23017/* @jardiamj
@@ -226,12 +232,13 @@ homeassistant/components/min_max/* @fabaff
homeassistant/components/minecraft_server/* @elmurato
homeassistant/components/minio/* @tkislan
homeassistant/components/mobile_app/* @robbiet480
-homeassistant/components/modbus/* @adamchengtkc
+homeassistant/components/modbus/* @adamchengtkc @janiversen
homeassistant/components/monoprice/* @etsinko
homeassistant/components/moon/* @fabaff
homeassistant/components/mpd/* @fabaff
homeassistant/components/mqtt/* @home-assistant/core
homeassistant/components/msteams/* @peroyvind
+homeassistant/components/myq/* @bdraco
homeassistant/components/mysensors/* @MartinHjelmare
homeassistant/components/mystrom/* @fabaff
homeassistant/components/neato/* @dshokouhi @Santobert
@@ -241,7 +248,9 @@ homeassistant/components/ness_alarm/* @nickw444
homeassistant/components/nest/* @awarecan
homeassistant/components/netatmo/* @cgtobi
homeassistant/components/netdata/* @fabaff
+homeassistant/components/nexia/* @ryannazaretian @bdraco
homeassistant/components/nextbus/* @vividboarder
+homeassistant/components/nextcloud/* @meichthys
homeassistant/components/nilu/* @hfurubotten
homeassistant/components/nissan_leaf/* @filcole
homeassistant/components/nmbs/* @thibmaek
@@ -250,7 +259,9 @@ homeassistant/components/notify/* @home-assistant/core
homeassistant/components/notion/* @bachya
homeassistant/components/nsw_fuel_station/* @nickw444
homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte
+homeassistant/components/nuheat/* @bdraco
homeassistant/components/nuki/* @pvizeli
+homeassistant/components/nut/* @bdraco
homeassistant/components/nws/* @MatthewFlamm
homeassistant/components/nzbget/* @chriscla
homeassistant/components/obihai/* @dshokouhi
@@ -275,17 +286,21 @@ homeassistant/components/plaato/* @JohNan
homeassistant/components/plant/* @ChristianKuehnel
homeassistant/components/plex/* @jjlawren
homeassistant/components/plugwise/* @laetificat @CoMPaTech @bouwew
+homeassistant/components/plum_lightpad/* @ColinHarrington
homeassistant/components/point/* @fredrike
+homeassistant/components/powerwall/* @bdraco
homeassistant/components/proxmoxve/* @k4ds3
homeassistant/components/ps4/* @ktnrg45
homeassistant/components/ptvsd/* @swamp-ig
homeassistant/components/push/* @dgomes
homeassistant/components/pvoutput/* @fabaff
+homeassistant/components/pvpc_hourly_pricing/* @azogue
homeassistant/components/qld_bushfire/* @exxamalte
homeassistant/components/qnap/* @colinodell
homeassistant/components/quantum_gateway/* @cisasteelersfan
homeassistant/components/qvr_pro/* @oblogic7
homeassistant/components/qwikswitch/* @kellerza
+homeassistant/components/rachio/* @bdraco
homeassistant/components/rainbird/* @konikvranik
homeassistant/components/raincloud/* @vanstinator
homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert
@@ -302,6 +317,7 @@ homeassistant/components/saj/* @fredericvl
homeassistant/components/salt/* @bjornorri
homeassistant/components/samsungtv/* @escoand
homeassistant/components/scene/* @home-assistant/core
+homeassistant/components/schluter/* @prairieapps
homeassistant/components/scrape/* @fabaff
homeassistant/components/script/* @home-assistant/core
homeassistant/components/search/* @home-assistant/core
@@ -331,6 +347,7 @@ homeassistant/components/solax/* @squishykid
homeassistant/components/soma/* @ratsept
homeassistant/components/somfy/* @tetienne
homeassistant/components/songpal/* @rytilahti
+homeassistant/components/sonos/* @amelchio
homeassistant/components/spaceapi/* @fabaff
homeassistant/components/speedtestdotnet/* @rohankapoorcom
homeassistant/components/spider/* @peternijssen
@@ -354,7 +371,7 @@ homeassistant/components/switchmate/* @danielhiversen
homeassistant/components/syncthru/* @nielstron
homeassistant/components/synology_srm/* @aerialls
homeassistant/components/syslog/* @fabaff
-homeassistant/components/tado/* @michaelarnauts
+homeassistant/components/tado/* @michaelarnauts @bdraco
homeassistant/components/tahoma/* @philklei
homeassistant/components/tankerkoenig/* @guillempages
homeassistant/components/tautulli/* @ludeeus
diff --git a/azure-pipelines-translation.yml b/azure-pipelines-translation.yml
index 2fd49c056f7..0791cbcaab1 100644
--- a/azure-pipelines-translation.yml
+++ b/azure-pipelines-translation.yml
@@ -7,7 +7,7 @@ trigger:
- dev
pr: none
schedules:
- - cron: "30 0 * * *"
+ - cron: "0 0 * * *"
displayName: "translation update"
branches:
include:
diff --git a/build.json b/build.json
index c61a693af1c..331999b5470 100644
--- a/build.json
+++ b/build.json
@@ -1,11 +1,11 @@
{
"image": "homeassistant/{arch}-homeassistant",
"build_from": {
- "aarch64": "homeassistant/aarch64-homeassistant-base:7.0.1",
- "armhf": "homeassistant/armhf-homeassistant-base:7.0.1",
- "armv7": "homeassistant/armv7-homeassistant-base:7.0.1",
- "amd64": "homeassistant/amd64-homeassistant-base:7.0.1",
- "i386": "homeassistant/i386-homeassistant-base:7.0.1"
+ "aarch64": "homeassistant/aarch64-homeassistant-base:7.1.0",
+ "armhf": "homeassistant/armhf-homeassistant-base:7.1.0",
+ "armv7": "homeassistant/armv7-homeassistant-base:7.1.0",
+ "amd64": "homeassistant/amd64-homeassistant-base:7.1.0",
+ "i386": "homeassistant/i386-homeassistant-base:7.1.0"
},
"labels": {
"io.hass.type": "core"
diff --git a/docs/source/api/auth.rst b/docs/source/api/auth.rst
new file mode 100644
index 00000000000..16a1dc69b6b
--- /dev/null
+++ b/docs/source/api/auth.rst
@@ -0,0 +1,29 @@
+:mod:`homeassistant.auth`
+=========================
+
+.. automodule:: homeassistant.auth
+ :members:
+
+homeassistant.auth.auth\_store
+------------------------------
+
+.. automodule:: homeassistant.auth.auth_store
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.auth.const
+------------------------
+
+.. automodule:: homeassistant.auth.const
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.auth.models
+-------------------------
+
+.. automodule:: homeassistant.auth.models
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst
index 363f7969961..fdc0b1c731d 100644
--- a/docs/source/api/bootstrap.rst
+++ b/docs/source/api/bootstrap.rst
@@ -1,7 +1,7 @@
.. _bootstrap_module:
:mod:`homeassistant.bootstrap`
--------------------------
+------------------------------
.. automodule:: homeassistant.bootstrap
:members:
diff --git a/docs/source/api/components.rst b/docs/source/api/components.rst
new file mode 100644
index 00000000000..a27f93765b4
--- /dev/null
+++ b/docs/source/api/components.rst
@@ -0,0 +1,170 @@
+:mod:`homeassistant.components`
+===============================
+
+air\_quality
+--------------------------------------------
+
+.. automodule:: homeassistant.components.air_quality
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+alarm\_control\_panel
+--------------------------------------------
+
+.. automodule:: homeassistant.components.alarm_control_panel
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+binary\_sensor
+--------------------------------------------
+
+.. automodule:: homeassistant.components.binary_sensor
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+camera
+---------------------------
+
+.. automodule:: homeassistant.components.camera
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+calendar
+---------------------------
+
+.. automodule:: homeassistant.components.calendar
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+climate
+---------------------------
+
+.. automodule:: homeassistant.components.climate
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+conversation
+---------------------------
+
+.. automodule:: homeassistant.components.conversation
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+cover
+---------------------------
+
+.. automodule:: homeassistant.components.cover
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+device\_tracker
+---------------------------
+
+.. automodule:: homeassistant.components.device_tracker
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+fan
+---------------------------
+
+.. automodule:: homeassistant.components.fan
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+light
+---------------------------
+
+.. automodule:: homeassistant.components.light
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+lock
+---------------------------
+
+.. automodule:: homeassistant.components.lock
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+media\_player
+---------------------------
+
+.. automodule:: homeassistant.components.media_player
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+notify
+---------------------------
+
+.. automodule:: homeassistant.components.notify
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+remote
+---------------------------
+
+.. automodule:: homeassistant.components.remote
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+switch
+---------------------------
+
+.. automodule:: homeassistant.components.switch
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+sensor
+-------------------------------------
+
+.. automodule:: homeassistant.components.sensor
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+vacuum
+-------------------------------------
+
+.. automodule:: homeassistant.components.vacuum
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+water\_heater
+-------------------------------------
+
+.. automodule:: homeassistant.components.water_heater
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+weather
+---------------------------
+
+.. automodule:: homeassistant.components.weather
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+webhook
+---------------------------
+
+.. automodule:: homeassistant.components.webhook
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/api/config_entries.rst b/docs/source/api/config_entries.rst
new file mode 100644
index 00000000000..4a207b82e16
--- /dev/null
+++ b/docs/source/api/config_entries.rst
@@ -0,0 +1,7 @@
+.. _config_entries_module:
+
+:mod:`homeassistant.config_entries`
+-----------------------------------
+
+.. automodule:: homeassistant.config_entries
+ :members:
diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst
index bbaf591052c..7928655b8a1 100644
--- a/docs/source/api/core.rst
+++ b/docs/source/api/core.rst
@@ -4,35 +4,4 @@
-------------------------
.. automodule:: homeassistant.core
-
-.. autoclass:: Config
- :members:
-
-.. autoclass:: Event
- :members:
-
-.. autoclass:: EventBus
- :members:
-
-.. autoclass:: HomeAssistant
- :members:
-
-.. autoclass:: State
- :members:
-
-.. autoclass:: StateMachine
- :members:
-
-.. autoclass:: ServiceCall
- :members:
-
-.. autoclass:: ServiceRegistry
- :members:
-
-Module contents
----------------
-
-.. automodule:: homeassistant.core
- :members:
- :undoc-members:
- :show-inheritance:
+ :members:
\ No newline at end of file
diff --git a/docs/source/api/data_entry_flow.rst b/docs/source/api/data_entry_flow.rst
new file mode 100644
index 00000000000..7252780b870
--- /dev/null
+++ b/docs/source/api/data_entry_flow.rst
@@ -0,0 +1,7 @@
+.. _data_entry_flow_module:
+
+:mod:`homeassistant.data_entry_flow`
+-----------------------------
+
+.. automodule:: homeassistant.data_entry_flow
+ :members:
diff --git a/docs/source/api/device_tracker.rst b/docs/source/api/device_tracker.rst
deleted file mode 100644
index e3d65174815..00000000000
--- a/docs/source/api/device_tracker.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-.. _components_device_tracker_module:
-
-:mod:`homeassistant.components.device_tracker`
-----------------------------------------------
-
-.. automodule:: homeassistant.components.device_tracker
- :members:
-
-.. autoclass:: Device
- :members:
diff --git a/docs/source/api/entity.rst b/docs/source/api/entity.rst
deleted file mode 100644
index 99ae43dc3ae..00000000000
--- a/docs/source/api/entity.rst
+++ /dev/null
@@ -1,12 +0,0 @@
-.. _helpers_entity_module:
-
-:mod:`homeassistant.helpers.entity`
------------------------------------
-
-.. automodule:: homeassistant.helpers.entity
-
-.. autoclass:: Entity
- :members:
-
-.. autoclass:: ToggleEntity
- :members:
diff --git a/docs/source/api/event.rst b/docs/source/api/event.rst
deleted file mode 100644
index b1295b81409..00000000000
--- a/docs/source/api/event.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-.. _helpers_event_module:
-
-:mod:`homeassistant.helpers.event`
-----------------------------------
-
-.. automodule:: homeassistant.helpers.event
-
-.. autofunction:: track_state_change
-
-.. autofunction:: track_point_in_time
-
-.. autofunction:: track_point_in_utc_time
-
-.. autofunction:: track_sunrise
-
-.. autofunction:: track_sunset
-
-.. autofunction:: track_utc_time_change
-
-.. autofunction:: track_time_change
diff --git a/docs/source/api/exceptions.rst b/docs/source/api/exceptions.rst
new file mode 100644
index 00000000000..e2977c51dae
--- /dev/null
+++ b/docs/source/api/exceptions.rst
@@ -0,0 +1,7 @@
+.. _exceptions_module:
+
+:mod:`homeassistant.exceptions`
+-------------------------------
+
+.. automodule:: homeassistant.exceptions
+ :members:
diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst
index 8ad645b7977..1b0b529c655 100644
--- a/docs/source/api/helpers.rst
+++ b/docs/source/api/helpers.rst
@@ -1,287 +1,335 @@
-homeassistant.helpers package
-=============================
-
-Submodules
-----------
-
-homeassistant.helpers.aiohttp_client module
--------------------------------------------
-
-.. automodule:: homeassistant.helpers.aiohttp_client
- :members:
- :undoc-members:
- :show-inheritance:
-
-
-homeassistant.helpers.area_registry module
-------------------------------------------
-
-.. automodule:: homeassistant.helpers.area_registry
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.condition module
---------------------------------------
-
-.. automodule:: homeassistant.helpers.condition
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.config_entry_flow module
-----------------------------------------------
-
-.. automodule:: homeassistant.helpers.config_entry_flow
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.config_validation module
-----------------------------------------------
-
-.. automodule:: homeassistant.helpers.config_validation
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.data_entry_flow module
---------------------------------------------
-
-.. automodule:: homeassistant.helpers.data_entry_flow
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.deprecation module
-----------------------------------------
-
-.. automodule:: homeassistant.helpers.deprecation
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.device_registry module
---------------------------------------------
-
-.. automodule:: homeassistant.helpers.device_registry
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.discovery module
---------------------------------------
-
-.. automodule:: homeassistant.helpers.discovery
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.dispatcher module
----------------------------------------
-
-.. automodule:: homeassistant.helpers.dispatcher
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.entity module
------------------------------------
-
-.. automodule:: homeassistant.helpers.entity
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.entity_component module
----------------------------------------------
-
-.. automodule:: homeassistant.helpers.entity_component
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.entity_platform module
---------------------------------------------
-
-.. automodule:: homeassistant.helpers.entity_platform
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.entity_registry module
---------------------------------------------
-
-.. automodule:: homeassistant.helpers.entity_registry
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.entity_values module
-------------------------------------------
-
-.. automodule:: homeassistant.helpers.entity_values
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.entityfilter module
------------------------------------------
-
-.. automodule:: homeassistant.helpers.entityfilter
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.event module
-----------------------------------
-
-.. automodule:: homeassistant.helpers.event
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.icon module
----------------------------------
-
-.. automodule:: homeassistant.helpers.icon
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.intent module
------------------------------------
-
-.. automodule:: homeassistant.helpers.intent
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.json module
----------------------------------
-
-.. automodule:: homeassistant.helpers.json
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.location module
--------------------------------------
-
-.. automodule:: homeassistant.helpers.location
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.logging module
-------------------------------------
-
-.. automodule:: homeassistant.helpers.logging
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.restore_state module
-------------------------------------------
-
-.. automodule:: homeassistant.helpers.restore_state
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.script module
------------------------------------
-
-.. automodule:: homeassistant.helpers.script
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.service module
-------------------------------------
-
-.. automodule:: homeassistant.helpers.service
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.signal module
------------------------------------
-
-.. automodule:: homeassistant.helpers.signal
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.state module
-----------------------------------
-
-.. automodule:: homeassistant.helpers.state
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.storage module
-------------------------------------
-
-.. automodule:: homeassistant.helpers.storage
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.sun module
---------------------------------
-
-.. automodule:: homeassistant.helpers.sun
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.system_info module
-----------------------------------------
-
-.. automodule:: homeassistant.helpers.system_info
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.temperature module
-----------------------------------------
-
-.. automodule:: homeassistant.helpers.temperature
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.template module
--------------------------------------
-
-.. automodule:: homeassistant.helpers.template
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.translation module
------------------------------------------
-
-.. automodule:: homeassistant.helpers.translation
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.helpers.typing module
------------------------------------
-
-.. automodule:: homeassistant.helpers.typing
- :members:
- :undoc-members:
- :show-inheritance:
-
-
-Module contents
----------------
+:mod:`homeassistant.helpers`
+============================
.. automodule:: homeassistant.helpers
- :members:
- :undoc-members:
- :show-inheritance:
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.aiohttp\_client
+-------------------------------------
+
+.. automodule:: homeassistant.helpers.aiohttp_client
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.area\_registry
+------------------------------------
+
+.. automodule:: homeassistant.helpers.area_registry
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.check\_config
+-----------------------------------
+
+.. automodule:: homeassistant.helpers.check_config
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.collection
+--------------------------------
+
+.. automodule:: homeassistant.helpers.collection
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.condition
+-------------------------------
+
+.. automodule:: homeassistant.helpers.condition
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.config\_entry\_flow
+-----------------------------------------
+
+.. automodule:: homeassistant.helpers.config_entry_flow
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.config\_entry\_oauth2\_flow
+-------------------------------------------------
+
+.. automodule:: homeassistant.helpers.config_entry_oauth2_flow
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.config\_validation
+----------------------------------------
+
+.. automodule:: homeassistant.helpers.config_validation
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.data\_entry\_flow
+---------------------------------------
+
+.. automodule:: homeassistant.helpers.data_entry_flow
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.debounce
+------------------------------
+
+.. automodule:: homeassistant.helpers.debounce
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.deprecation
+---------------------------------
+
+.. automodule:: homeassistant.helpers.deprecation
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.device\_registry
+--------------------------------------
+
+.. automodule:: homeassistant.helpers.device_registry
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.discovery
+-------------------------------
+
+.. automodule:: homeassistant.helpers.discovery
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.dispatcher
+--------------------------------
+
+.. automodule:: homeassistant.helpers.dispatcher
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity
+----------------------------
+
+.. automodule:: homeassistant.helpers.entity
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity\_component
+---------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_component
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity\_platform
+--------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_platform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity\_registry
+--------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_registry
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity\_values
+------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_values
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entityfilter
+----------------------------------
+
+.. automodule:: homeassistant.helpers.entityfilter
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.event
+---------------------------
+
+.. automodule:: homeassistant.helpers.event
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.icon
+--------------------------
+
+.. automodule:: homeassistant.helpers.icon
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.integration\_platform
+-------------------------------------------
+
+.. automodule:: homeassistant.helpers.integration_platform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.intent
+----------------------------
+
+.. automodule:: homeassistant.helpers.intent
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.json
+--------------------------
+
+.. automodule:: homeassistant.helpers.json
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.location
+------------------------------
+
+.. automodule:: homeassistant.helpers.location
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.logging
+-----------------------------
+
+.. automodule:: homeassistant.helpers.logging
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.network
+-----------------------------
+
+.. automodule:: homeassistant.helpers.network
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.restore\_state
+------------------------------------
+
+.. automodule:: homeassistant.helpers.restore_state
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.script
+----------------------------
+
+.. automodule:: homeassistant.helpers.script
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.service
+-----------------------------
+
+.. automodule:: homeassistant.helpers.service
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.signal
+-----------------------------
+
+.. automodule:: homeassistant.helpers.signal
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.state
+---------------------------
+
+.. automodule:: homeassistant.helpers.state
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.storage
+-----------------------------
+
+.. automodule:: homeassistant.helpers.storage
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.sun
+-------------------------
+
+.. automodule:: homeassistant.helpers.sun
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.system\_info
+----------------------------------
+
+.. automodule:: homeassistant.helpers.system_info
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.temperature
+---------------------------------
+
+.. automodule:: homeassistant.helpers.temperature
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.template
+------------------------------
+
+.. automodule:: homeassistant.helpers.template
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.translation
+---------------------------------
+
+.. automodule:: homeassistant.helpers.translation
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.typing
+----------------------------
+
+.. automodule:: homeassistant.helpers.typing
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.update\_coordinator
+-----------------------------------------
+
+.. automodule:: homeassistant.helpers.update_coordinator
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/api/homeassistant.rst b/docs/source/api/homeassistant.rst
deleted file mode 100644
index 599f5fb8019..00000000000
--- a/docs/source/api/homeassistant.rst
+++ /dev/null
@@ -1,70 +0,0 @@
-homeassistant package
-=====================
-
-Subpackages
------------
-
-.. toctree::
-
- helpers
- util
-
-Submodules
-----------
-
-bootstrap module
-------------------------------
-
-.. automodule:: homeassistant.bootstrap
- :members:
- :undoc-members:
- :show-inheritance:
-
-config module
----------------------------
-
-.. automodule:: homeassistant.config
- :members:
- :undoc-members:
- :show-inheritance:
-
-const module
---------------------------
-
-.. automodule:: homeassistant.const
- :members:
- :undoc-members:
- :show-inheritance:
-
-core module
--------------------------
-
-.. automodule:: homeassistant.core
- :members:
- :undoc-members:
- :show-inheritance:
-
-exceptions module
--------------------------------
-
-.. automodule:: homeassistant.exceptions
- :members:
- :undoc-members:
- :show-inheritance:
-
-loader module
----------------------------
-
-.. automodule:: homeassistant.loader
- :members:
- :undoc-members:
- :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: homeassistant
- :members:
- :undoc-members:
- :show-inheritance:
diff --git a/docs/source/api/loader.rst b/docs/source/api/loader.rst
new file mode 100644
index 00000000000..91594a8a774
--- /dev/null
+++ b/docs/source/api/loader.rst
@@ -0,0 +1,7 @@
+.. _loader_module:
+
+:mod:`homeassistant.loader`
+---------------------------
+
+.. automodule:: homeassistant.loader
+ :members:
diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst
index fb61cd94fe6..52ae8eacdd3 100644
--- a/docs/source/api/util.rst
+++ b/docs/source/api/util.rst
@@ -1,86 +1,159 @@
-homeassistant.util package
-==========================
-
-Submodules
-----------
-
-homeassistant.util.async_ module
--------------------------------
-
-.. automodule:: homeassistant.util.async_
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.color module
--------------------------------
-
-.. automodule:: homeassistant.util.color
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.distance module
-----------------------------------
-
-.. automodule:: homeassistant.util.distance
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.dt module
-----------------------------
-
-.. automodule:: homeassistant.util.dt
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.location module
-----------------------------------
-
-.. automodule:: homeassistant.util.location
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.package module
----------------------------------
-
-.. automodule:: homeassistant.util.package
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.temperature module
--------------------------------------
-
-.. automodule:: homeassistant.util.temperature
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.unit_system module
--------------------------------------
-
-.. automodule:: homeassistant.util.unit_system
- :members:
- :undoc-members:
- :show-inheritance:
-
-homeassistant.util.yaml module
-------------------------------
-
-.. automodule:: homeassistant.util.yaml
- :members:
- :undoc-members:
- :show-inheritance:
-
-
-Module contents
----------------
+:mod:`homeassistant.util`
+=========================
.. automodule:: homeassistant.util
- :members:
- :undoc-members:
- :show-inheritance:
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.yaml
+-----------------------
+
+.. automodule:: homeassistant.util.yaml
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.aiohttp
+--------------------------
+
+.. automodule:: homeassistant.util.aiohttp
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.async\_
+--------------------------
+
+.. automodule:: homeassistant.util.async_
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.color
+------------------------
+
+.. automodule:: homeassistant.util.color
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.decorator
+----------------------------
+
+.. automodule:: homeassistant.util.decorator
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.distance
+---------------------------
+
+.. automodule:: homeassistant.util.distance
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.dt
+---------------------
+
+.. automodule:: homeassistant.util.dt
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.json
+-----------------------
+
+.. automodule:: homeassistant.util.json
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.location
+---------------------------
+
+.. automodule:: homeassistant.util.location
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.logging
+--------------------------
+
+.. automodule:: homeassistant.util.logging
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.network
+--------------------------
+
+.. automodule:: homeassistant.util.network
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.package
+--------------------------
+
+.. automodule:: homeassistant.util.package
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.pil
+----------------------
+
+.. automodule:: homeassistant.util.pil
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.pressure
+---------------------------
+
+.. automodule:: homeassistant.util.pressure
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.ruamel\_yaml
+-------------------------------
+
+.. automodule:: homeassistant.util.ruamel_yaml
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.ssl
+----------------------
+
+.. automodule:: homeassistant.util.ssl
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.temperature
+------------------------------
+
+.. automodule:: homeassistant.util.temperature
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.unit\_system
+-------------------------------
+
+.. automodule:: homeassistant.util.unit_system
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.util.volume
+-------------------------
+
+.. automodule:: homeassistant.util.volume
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f36b5b8124a..3aa30965c95 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -26,7 +26,7 @@ from homeassistant.const import __short_version__, __version__
PROJECT_NAME = 'Home Assistant'
PROJECT_PACKAGE_NAME = 'homeassistant'
PROJECT_AUTHOR = 'The Home Assistant Authors'
-PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR)
+PROJECT_COPYRIGHT = ' 2013-2020, {}'.format(PROJECT_AUTHOR)
PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source '
'home automation platform running on Python 3. '
'Track and control all devices at home and '
diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py
index d1d59482e6d..a6d4e0c7bc9 100644
--- a/homeassistant/__main__.py
+++ b/homeassistant/__main__.py
@@ -339,7 +339,7 @@ def main() -> int:
if args.pid_file:
write_pid(args.pid_file)
- exit_code = asyncio.run(setup_and_run_hass(config_dir, args))
+ exit_code = asyncio.run(setup_and_run_hass(config_dir, args), debug=args.debug)
if exit_code == RESTART_EXIT_CODE and not args.runner:
try_to_restart()
diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py
index 710b4af1cd8..26bd10535d0 100644
--- a/homeassistant/auth/__init__.py
+++ b/homeassistant/auth/__init__.py
@@ -215,12 +215,14 @@ class AuthManager:
return user
- async def async_create_user(self, name: str) -> models.User:
+ async def async_create_user(
+ self, name: str, group_ids: Optional[List[str]] = None
+ ) -> models.User:
"""Create a user."""
kwargs: Dict[str, Any] = {
"name": name,
"is_active": True,
- "group_ids": [GROUP_ID_ADMIN],
+ "group_ids": group_ids or [],
}
if await self._user_should_be_owner():
diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py
index 8b4e6355700..502155df129 100644
--- a/homeassistant/auth/models.py
+++ b/homeassistant/auth/models.py
@@ -123,4 +123,8 @@ class Credentials:
is_new = attr.ib(type=bool, default=True)
-UserMeta = NamedTuple("UserMeta", [("name", Optional[str]), ("is_active", bool)])
+class UserMeta(NamedTuple):
+ """User metadata."""
+
+ name: Optional[str]
+ is_active: bool
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 7d4155257db..5d939d4b34e 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -20,6 +20,7 @@ from homeassistant.const import (
REQUIRED_NEXT_PYTHON_VER,
)
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.typing import ConfigType
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
@@ -133,7 +134,7 @@ async def async_setup_hass(
async def async_from_config_dict(
- config: Dict[str, Any], hass: core.HomeAssistant
+ config: ConfigType, hass: core.HomeAssistant
) -> Optional[core.HomeAssistant]:
"""Try to configure Home Assistant from a configuration dictionary.
@@ -324,15 +325,30 @@ async def _async_set_up_integrations(
hass: core.HomeAssistant, config: Dict[str, Any]
) -> None:
"""Set up all the integrations."""
+
+ async def async_setup_multi_components(domains: Set[str]) -> None:
+ """Set up multiple domains. Log on failure."""
+ futures = {
+ domain: hass.async_create_task(async_setup_component(hass, domain, config))
+ for domain in domains
+ }
+ await asyncio.wait(futures.values())
+ errors = [domain for domain in domains if futures[domain].exception()]
+ for domain in errors:
+ exception = futures[domain].exception()
+ _LOGGER.error(
+ "Error setting up integration %s - received exception",
+ domain,
+ exc_info=(type(exception), exception, exception.__traceback__),
+ )
+
domains = _get_domains(hass, config)
# Start up debuggers. Start these first in case they want to wait.
debuggers = domains & DEBUGGER_INTEGRATIONS
if debuggers:
_LOGGER.debug("Starting up debuggers %s", debuggers)
- await asyncio.gather(
- *(async_setup_component(hass, domain, config) for domain in debuggers)
- )
+ await async_setup_multi_components(debuggers)
domains -= DEBUGGER_INTEGRATIONS
# Resolve all dependencies of all components so we can find the logging
@@ -357,9 +373,7 @@ async def _async_set_up_integrations(
if logging_domains:
_LOGGER.info("Setting up %s", logging_domains)
- await asyncio.gather(
- *(async_setup_component(hass, domain, config) for domain in logging_domains)
- )
+ await async_setup_multi_components(logging_domains)
# Kick off loading the registries. They don't need to be awaited.
asyncio.gather(
@@ -369,9 +383,7 @@ async def _async_set_up_integrations(
)
if stage_1_domains:
- await asyncio.gather(
- *(async_setup_component(hass, domain, config) for domain in stage_1_domains)
- )
+ await async_setup_multi_components(stage_1_domains)
# Load all integrations
after_dependencies: Dict[str, Set[str]] = {}
@@ -400,9 +412,7 @@ async def _async_set_up_integrations(
_LOGGER.debug("Setting up %s", domains_to_load)
- await asyncio.gather(
- *(async_setup_component(hass, domain, config) for domain in domains_to_load)
- )
+ await async_setup_multi_components(domains_to_load)
last_load = domains_to_load
stage_2_domains -= domains_to_load
@@ -412,9 +422,7 @@ async def _async_set_up_integrations(
if stage_2_domains:
_LOGGER.debug("Final set up: %s", stage_2_domains)
- await asyncio.gather(
- *(async_setup_component(hass, domain, config) for domain in stage_2_domains)
- )
+ await async_setup_multi_components(stage_2_domains)
# Wrap up startup
await hass.async_block_till_done()
diff --git a/homeassistant/components/.translations/synology_dsm.ca.json b/homeassistant/components/.translations/synology_dsm.ca.json
new file mode 100644
index 00000000000..39b99ac9306
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.ca.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat"
+ },
+ "error": {
+ "login": "Error d\u2019inici de sessi\u00f3: comprova el nom d'usuari i la contrasenya",
+ "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard o revisa la configuraci\u00f3"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_version": "Versi\u00f3 DSM",
+ "host": "Amfitri\u00f3",
+ "name": "Nom",
+ "password": "Contrasenya",
+ "port": "Port",
+ "ssl": "Utilitza SSL/TLS per connectar-te al servidor NAS",
+ "username": "Nom d'usuari"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.da.json b/homeassistant/components/.translations/synology_dsm.da.json
new file mode 100644
index 00000000000..f95e08df3c1
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "step": {
+ "link": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.en.json b/homeassistant/components/.translations/synology_dsm.en.json
new file mode 100644
index 00000000000..3bac6d16288
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.en.json
@@ -0,0 +1,38 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Host already configured"
+ },
+ "error": {
+ "login": "Login error: please check your username & password",
+ "unknown": "Unknown error: please retry later or an other configuration"
+ },
+ "flow_title": "Synology DSM {name} ({host})",
+ "step": {
+ "link": {
+ "data": {
+ "api_version": "DSM version",
+ "password": "Password",
+ "port": "Port (Optional)",
+ "ssl": "Use SSL/TLS to connect to your NAS",
+ "username": "Username"
+ },
+ "description": "Do you want to setup {name} ({host})?",
+ "title": "Synology DSM"
+ },
+ "user": {
+ "data": {
+ "api_version": "DSM version",
+ "host": "Host",
+ "name": "Name",
+ "password": "Password",
+ "port": "Port (Optional)",
+ "ssl": "Use SSL/TLS to connect to your NAS",
+ "username": "Username"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.es.json b/homeassistant/components/.translations/synology_dsm.es.json
new file mode 100644
index 00000000000..fafedb50a0e
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.es.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El host ya est\u00e1 configurado."
+ },
+ "error": {
+ "login": "Error de inicio de sesi\u00f3n: comprueba tu direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a",
+ "unknown": "Error desconocido: por favor vuelve a intentarlo m\u00e1s tarde o usa otra configuraci\u00f3n"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_version": "Versi\u00f3n del DSM",
+ "host": "Host",
+ "name": "Nombre",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "ssl": "Usar SSL/TLS para conectar con tu NAS",
+ "username": "Usuario"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.ko.json b/homeassistant/components/.translations/synology_dsm.ko.json
new file mode 100644
index 00000000000..60fcd9866c1
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.ko.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "login": "\ub85c\uadf8\uc778 \uc624\ub958: \uc0ac\uc6a9\uc790 \uc774\ub984 \ubc0f \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694",
+ "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud558\uac70\ub098 \ub2e4\ub978 \uad6c\uc131\uc744 \uc2dc\ub3c4\ud574\ubcf4\uc138\uc694"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_version": "DSM \ubc84\uc804",
+ "host": "\ud638\uc2a4\ud2b8",
+ "name": "\uc774\ub984",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "port": "\ud3ec\ud2b8",
+ "ssl": "SSL/TLS \ub97c \uc0ac\uc6a9\ud558\uc5ec NAS \uc5d0 \uc5f0\uacb0",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.lb.json b/homeassistant/components/.translations/synology_dsm.lb.json
new file mode 100644
index 00000000000..92026cbe2d8
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.lb.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "login": "Feeler beim Login: iwwerpr\u00e9if de Benotzernumm & Passwuert",
+ "unknown": "Onbekannte Feeler: prob\u00e9ier sp\u00e9ider nach emol oder mat enger aner Konfiguratioun"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_version": "DSM Versioun",
+ "host": "Apparat",
+ "name": "Numm",
+ "password": "Passwuert",
+ "port": "Port",
+ "ssl": "Benotzt SSL/TLS fir sech mam NAS ze verbannen",
+ "username": "Benotzernumm"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.nl.json b/homeassistant/components/.translations/synology_dsm.nl.json
new file mode 100644
index 00000000000..1927227b65f
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.nl.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Host is al geconfigureerd."
+ },
+ "error": {
+ "unknown": "Onbekende fout: probeer het later opnieuw of een andere configuratie"
+ },
+ "flow_title": "Synology DSM {name} ({host})",
+ "step": {
+ "link": {
+ "data": {
+ "api_version": "DSM-versie",
+ "password": "Wachtwoord",
+ "port": "Poort (optioneel)",
+ "ssl": "Gebruik SSL/TLS om verbinding te maken met uw NAS",
+ "username": "Gebruikersnaam"
+ },
+ "description": "Wil je {name} ({host}) instellen?",
+ "title": "Synology DSM"
+ },
+ "user": {
+ "data": {
+ "api_version": "DSM-versie",
+ "host": "Host",
+ "name": "Naam",
+ "password": "Wachtwoord",
+ "port": "Poort (optioneel)",
+ "ssl": "Gebruik SSL/TLS om verbinding te maken met uw NAS",
+ "username": "Gebruikersnaam"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/.translations/synology_dsm.ru.json b/homeassistant/components/.translations/synology_dsm.ru.json
new file mode 100644
index 00000000000..c76fa9ee972
--- /dev/null
+++ b/homeassistant/components/.translations/synology_dsm.ru.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\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."
+ },
+ "error": {
+ "login": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u0445\u043e\u0434\u0430: \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c.",
+ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u0441 \u0434\u0440\u0443\u0433\u043e\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0435\u0439 \u0438\u043b\u0438 \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_version": "\u0412\u0435\u0440\u0441\u0438\u044f DSM",
+ "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",
+ "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL / TLS \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "title": "Synology DSM"
+ }
+ },
+ "title": "Synology DSM"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/abode/.translations/no.json b/homeassistant/components/abode/.translations/no.json
index 542381cbb64..eefd4526d7f 100644
--- a/homeassistant/components/abode/.translations/no.json
+++ b/homeassistant/components/abode/.translations/no.json
@@ -17,6 +17,6 @@
"title": "Fyll ut innloggingsinformasjonen for Abode"
}
},
- "title": "Abode"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py
index 666c8481bfb..687d0d31263 100644
--- a/homeassistant/components/abode/__init__.py
+++ b/homeassistant/components/abode/__init__.py
@@ -263,7 +263,6 @@ def setup_abode_events(hass):
TIMELINE.TEST_GROUP,
TIMELINE.CAPTURE_GROUP,
TIMELINE.DEVICE_GROUP,
- TIMELINE.AUTOMATION_EDIT_GROUP,
]
for event in events:
@@ -343,21 +342,14 @@ class AbodeDevice(Entity):
class AbodeAutomation(Entity):
"""Representation of an Abode automation."""
- def __init__(self, data, automation, event=None):
+ def __init__(self, data, automation):
"""Initialize for Abode automation."""
self._data = data
self._automation = automation
- self._event = event
async def async_added_to_hass(self):
- """Subscribe to a group of Abode timeline events."""
- if self._event:
- self.hass.async_add_job(
- self._data.abode.events.add_event_callback,
- self._event,
- self._update_callback,
- )
- self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
+ """Set up automation entity."""
+ self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
@property
def should_poll(self):
@@ -385,8 +377,3 @@ class AbodeAutomation(Entity):
def unique_id(self):
"""Return a unique ID to use for this automation."""
return self._automation.automation_id
-
- def _update_callback(self, device):
- """Update the automation state."""
- self._automation.refresh()
- self.schedule_update_ha_state()
diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py
index c4cdadf9bd9..916ed2e2613 100644
--- a/homeassistant/components/abode/binary_sensor.py
+++ b/homeassistant/components/abode/binary_sensor.py
@@ -1,7 +1,10 @@
"""Support for Abode Security System binary sensors."""
import abodepy.helpers.constants as CONST
-from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_WINDOW,
+ BinarySensorDevice,
+)
from . import AbodeDevice
from .const import DOMAIN
@@ -38,4 +41,6 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorDevice):
@property
def device_class(self):
"""Return the class of the binary sensor."""
+ if self._device.get_value("is_window") == "1":
+ return DEVICE_CLASS_WINDOW
return self._device.generic_type
diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py
index e29deb72f82..b57f3fbe143 100644
--- a/homeassistant/components/abode/switch.py
+++ b/homeassistant/components/abode/switch.py
@@ -1,6 +1,5 @@
"""Support for Abode Security System switches."""
import abodepy.helpers.constants as CONST
-import abodepy.helpers.timeline as TIMELINE
from homeassistant.components.switch import SwitchDevice
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -24,9 +23,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities.append(AbodeSwitch(data, device))
for automation in data.abode.get_automations():
- entities.append(
- AbodeAutomationSwitch(data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)
- )
+ entities.append(AbodeAutomationSwitch(data, automation))
async_add_entities(entities)
@@ -52,7 +49,7 @@ class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice):
"""A switch implementation for Abode automations."""
async def async_added_to_hass(self):
- """Subscribe Abode events."""
+ """Set up trigger automation service."""
await super().async_added_to_hass()
signal = f"abode_trigger_automation_{self.entity_id}"
diff --git a/homeassistant/components/adguard/.translations/no.json b/homeassistant/components/adguard/.translations/no.json
index 22a8c23644f..d91f226a7eb 100644
--- a/homeassistant/components/adguard/.translations/no.json
+++ b/homeassistant/components/adguard/.translations/no.json
@@ -18,7 +18,7 @@
"data": {
"host": "Vert",
"password": "Passord",
- "port": "Port",
+ "port": "",
"ssl": "AdGuard Hjem bruker et SSL-sertifikat",
"username": "Brukernavn",
"verify_ssl": "AdGuard Home bruker et riktig sertifikat"
diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json
index c77e0b3254d..02b0e2ea455 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.1"],
+ "requirements": ["adguardhome==0.4.2"],
"dependencies": [],
"codeowners": ["@frenck"]
}
diff --git a/homeassistant/components/airly/.translations/bg.json b/homeassistant/components/airly/.translations/bg.json
index c91190d9852..e09d9c0d62f 100644
--- a/homeassistant/components/airly/.translations/bg.json
+++ b/homeassistant/components/airly/.translations/bg.json
@@ -2,7 +2,6 @@
"config": {
"error": {
"auth": "API \u043a\u043b\u044e\u0447\u044a\u0442 \u043d\u0435 \u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d.",
- "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430.",
"wrong_location": "\u0412 \u0442\u0430\u0437\u0438 \u043e\u0431\u043b\u0430\u0441\u0442 \u043d\u044f\u043c\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u0442\u0435\u043b\u043d\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Airly."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/ca.json b/homeassistant/components/airly/.translations/ca.json
index 4c5a7a6bd59..00ef4c7180e 100644
--- a/homeassistant/components/airly/.translations/ca.json
+++ b/homeassistant/components/airly/.translations/ca.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "La clau API no \u00e9s correcta.",
- "name_exists": "El nom ja existeix.",
"wrong_location": "No hi ha estacions de mesura Airly en aquesta zona."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/da.json b/homeassistant/components/airly/.translations/da.json
index 52bf903d5a8..b33e9b18da8 100644
--- a/homeassistant/components/airly/.translations/da.json
+++ b/homeassistant/components/airly/.translations/da.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API-n\u00f8glen er ikke korrekt.",
- "name_exists": "Navnet findes allerede.",
"wrong_location": "Ingen Airly-m\u00e5lestationer i dette omr\u00e5de."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/de.json b/homeassistant/components/airly/.translations/de.json
index ef2b2d64a4e..727b67e3245 100644
--- a/homeassistant/components/airly/.translations/de.json
+++ b/homeassistant/components/airly/.translations/de.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "Der API-Schl\u00fcssel ist nicht korrekt.",
- "name_exists": "Name existiert bereits",
"wrong_location": "Keine Airly Luftmessstation an diesem Ort"
},
"step": {
diff --git a/homeassistant/components/airly/.translations/en.json b/homeassistant/components/airly/.translations/en.json
index cae6854d231..ef485ec610f 100644
--- a/homeassistant/components/airly/.translations/en.json
+++ b/homeassistant/components/airly/.translations/en.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API key is not correct.",
- "name_exists": "Name already exists.",
"wrong_location": "No Airly measuring stations in this area."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/es-419.json b/homeassistant/components/airly/.translations/es-419.json
index 74924493863..41f7e29b408 100644
--- a/homeassistant/components/airly/.translations/es-419.json
+++ b/homeassistant/components/airly/.translations/es-419.json
@@ -2,7 +2,6 @@
"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": {
diff --git a/homeassistant/components/airly/.translations/es.json b/homeassistant/components/airly/.translations/es.json
index 6fd18eb747c..b364a45c344 100644
--- a/homeassistant/components/airly/.translations/es.json
+++ b/homeassistant/components/airly/.translations/es.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "La clave de la API no es correcta.",
- "name_exists": "El nombre ya existe.",
"wrong_location": "No hay estaciones de medici\u00f3n Airly en esta zona."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/fr.json b/homeassistant/components/airly/.translations/fr.json
index f2fdbbd9754..b11493e337f 100644
--- a/homeassistant/components/airly/.translations/fr.json
+++ b/homeassistant/components/airly/.translations/fr.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "La cl\u00e9 API n'est pas correcte.",
- "name_exists": "Le nom existe d\u00e9j\u00e0.",
"wrong_location": "Aucune station de mesure Airly dans cette zone."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/hu.json b/homeassistant/components/airly/.translations/hu.json
index 30898c61abb..ae3990c31ce 100644
--- a/homeassistant/components/airly/.translations/hu.json
+++ b/homeassistant/components/airly/.translations/hu.json
@@ -5,7 +5,6 @@
},
"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": {
diff --git a/homeassistant/components/airly/.translations/it.json b/homeassistant/components/airly/.translations/it.json
index c52e77881c0..0453d397bc4 100644
--- a/homeassistant/components/airly/.translations/it.json
+++ b/homeassistant/components/airly/.translations/it.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "La chiave API non \u00e8 corretta.",
- "name_exists": "Il nome \u00e8 gi\u00e0 esistente",
"wrong_location": "Nessuna stazione di misurazione Airly in quest'area."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/ko.json b/homeassistant/components/airly/.translations/ko.json
index b64a16635a6..75b9bcfc1c4 100644
--- a/homeassistant/components/airly/.translations/ko.json
+++ b/homeassistant/components/airly/.translations/ko.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API \ud0a4\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
- "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4.",
"wrong_location": "\uc774 \uc9c0\uc5ed\uc5d0\ub294 Airly \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc774 \uc5c6\uc2b5\ub2c8\ub2e4."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/lb.json b/homeassistant/components/airly/.translations/lb.json
index 8c2f5c615f3..75c77d9481e 100644
--- a/homeassistant/components/airly/.translations/lb.json
+++ b/homeassistant/components/airly/.translations/lb.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "Api Schl\u00ebssel ass net korrekt.",
- "name_exists": "Numm g\u00ebtt et schonn",
"wrong_location": "Keng Airly Moos Statioun an d\u00ebsem Ber\u00e4ich"
},
"step": {
diff --git a/homeassistant/components/airly/.translations/nl.json b/homeassistant/components/airly/.translations/nl.json
index a9c6865ad91..2e9c97c8232 100644
--- a/homeassistant/components/airly/.translations/nl.json
+++ b/homeassistant/components/airly/.translations/nl.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API-sleutel is niet correct.",
- "name_exists": "Naam bestaat al.",
"wrong_location": "Geen Airly meetstations in dit gebied."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/no.json b/homeassistant/components/airly/.translations/no.json
index ada9955f9c5..492e1471351 100644
--- a/homeassistant/components/airly/.translations/no.json
+++ b/homeassistant/components/airly/.translations/no.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API-n\u00f8kkelen er ikke korrekt.",
- "name_exists": "Navnet finnes allerede.",
"wrong_location": "Ingen Airly m\u00e5lestasjoner i dette omr\u00e5det."
},
"step": {
@@ -17,9 +16,9 @@
"name": "Navn p\u00e5 integrasjonen"
},
"description": "Sett opp Airly luftkvalitet integrering. For \u00e5 generere API-n\u00f8kkel g\u00e5 til https://developer.airly.eu/register",
- "title": "Airly"
+ "title": ""
}
},
- "title": "Airly"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airly/.translations/pl.json b/homeassistant/components/airly/.translations/pl.json
index 5274a4383b6..85918d7c711 100644
--- a/homeassistant/components/airly/.translations/pl.json
+++ b/homeassistant/components/airly/.translations/pl.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "Klucz API jest nieprawid\u0142owy.",
- "name_exists": "Nazwa ju\u017c istnieje.",
"wrong_location": "Brak stacji pomiarowych Airly w tym rejonie."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/ru.json b/homeassistant/components/airly/.translations/ru.json
index 5094d3f4d1e..7846d8173c4 100644
--- a/homeassistant/components/airly/.translations/ru.json
+++ b/homeassistant/components/airly/.translations/ru.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.",
- "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_location": "\u0412 \u044d\u0442\u043e\u0439 \u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043d\u0435\u0442 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0445 \u0441\u0442\u0430\u043d\u0446\u0438\u0439 Airly."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/sl.json b/homeassistant/components/airly/.translations/sl.json
index f8ca4e5b6d5..d7797997910 100644
--- a/homeassistant/components/airly/.translations/sl.json
+++ b/homeassistant/components/airly/.translations/sl.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "Klju\u010d API ni pravilen.",
- "name_exists": "Ime \u017ee obstaja",
"wrong_location": "Na tem obmo\u010dju ni merilnih postaj Airly."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/sv.json b/homeassistant/components/airly/.translations/sv.json
index 5b81b4625a2..7c7d10f47dc 100644
--- a/homeassistant/components/airly/.translations/sv.json
+++ b/homeassistant/components/airly/.translations/sv.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API-nyckeln \u00e4r inte korrekt.",
- "name_exists": "Namnet finns redan.",
"wrong_location": "Inga Airly m\u00e4tstationer i detta omr\u00e5de."
},
"step": {
diff --git a/homeassistant/components/airly/.translations/zh-Hant.json b/homeassistant/components/airly/.translations/zh-Hant.json
index 5bc0a52f394..66934d7a986 100644
--- a/homeassistant/components/airly/.translations/zh-Hant.json
+++ b/homeassistant/components/airly/.translations/zh-Hant.json
@@ -5,7 +5,6 @@
},
"error": {
"auth": "API \u5bc6\u9470\u4e0d\u6b63\u78ba\u3002",
- "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728",
"wrong_location": "\u8a72\u5340\u57df\u6c92\u6709 Arily \u76e3\u6e2c\u7ad9\u3002"
},
"step": {
diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py
index bad5a48c05f..85071925357 100644
--- a/homeassistant/components/airly/__init__.py
+++ b/homeassistant/components/airly/__init__.py
@@ -2,6 +2,7 @@
import asyncio
from datetime import timedelta
import logging
+from math import ceil
from aiohttp.client_exceptions import ClientConnectorError
from airly import Airly
@@ -10,28 +11,40 @@ import async_timeout
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import Config, HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.util import Throttle
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_API_ADVICE,
ATTR_API_CAQI,
ATTR_API_CAQI_DESCRIPTION,
ATTR_API_CAQI_LEVEL,
- DATA_CLIENT,
DOMAIN,
+ MAX_REQUESTS_PER_DAY,
NO_AIRLY_SENSORS,
)
+PLATFORMS = ["air_quality", "sensor"]
+
_LOGGER = logging.getLogger(__name__)
-DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
+
+def set_update_interval(hass, instances):
+ """Set update_interval to another configured Airly instances."""
+ # We check how many Airly configured instances are and calculate interval to not
+ # exceed allowed numbers of requests.
+ interval = timedelta(minutes=ceil(24 * 60 / MAX_REQUESTS_PER_DAY) * instances)
+
+ if hass.data.get(DOMAIN):
+ for instance in hass.data[DOMAIN].values():
+ instance.update_interval = interval
+
+ return interval
async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured Airly."""
- hass.data[DOMAIN] = {}
- hass.data[DOMAIN][DATA_CLIENT] = {}
return True
@@ -48,70 +61,85 @@ async def async_setup_entry(hass, config_entry):
)
websession = async_get_clientsession(hass)
-
- airly = AirlyData(websession, api_key, latitude, longitude)
-
- await airly.async_update()
-
- hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = airly
-
- hass.async_create_task(
- hass.config_entries.async_forward_entry_setup(config_entry, "air_quality")
+ # Change update_interval for other Airly instances
+ update_interval = set_update_interval(
+ hass, len(hass.config_entries.async_entries(DOMAIN))
)
- hass.async_create_task(
- hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
+
+ coordinator = AirlyDataUpdateCoordinator(
+ hass, websession, api_key, latitude, longitude, update_interval
)
+ await coordinator.async_refresh()
+
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
+
+ hass.data.setdefault(DOMAIN, {})
+ hass.data[DOMAIN][config_entry.entry_id] = coordinator
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(config_entry, component)
+ )
+
return True
async def async_unload_entry(hass, config_entry):
"""Unload a config entry."""
- hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
- await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality")
- await hass.config_entries.async_forward_entry_unload(config_entry, "sensor")
- return True
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(config_entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ hass.data[DOMAIN].pop(config_entry.entry_id)
+
+ # Change update_interval for other Airly instances
+ set_update_interval(hass, len(hass.data[DOMAIN]))
+
+ return unload_ok
-class AirlyData:
+class AirlyDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold Airly data."""
- def __init__(self, session, api_key, latitude, longitude):
+ def __init__(self, hass, session, api_key, latitude, longitude, update_interval):
"""Initialize."""
self.latitude = latitude
self.longitude = longitude
self.airly = Airly(api_key, session)
- self.data = {}
- @Throttle(DEFAULT_SCAN_INTERVAL)
- async def async_update(self):
- """Update Airly data."""
+ super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
- try:
- with async_timeout.timeout(20):
- measurements = self.airly.create_measurements_session_point(
- self.latitude, self.longitude
- )
+ async def _async_update_data(self):
+ """Update data via library."""
+ data = {}
+ with async_timeout.timeout(20):
+ measurements = self.airly.create_measurements_session_point(
+ self.latitude, self.longitude
+ )
+ try:
await measurements.update()
+ except (AirlyError, ClientConnectorError) as error:
+ raise UpdateFailed(error)
- values = measurements.current["values"]
- index = measurements.current["indexes"][0]
- standards = measurements.current["standards"]
+ values = measurements.current["values"]
+ index = measurements.current["indexes"][0]
+ standards = measurements.current["standards"]
- if index["description"] == NO_AIRLY_SENSORS:
- _LOGGER.error("Can't retrieve data: no Airly sensors in this area")
- return
- for value in values:
- self.data[value["name"]] = value["value"]
- for standard in standards:
- self.data[f"{standard['pollutant']}_LIMIT"] = standard["limit"]
- self.data[f"{standard['pollutant']}_PERCENT"] = standard["percent"]
- self.data[ATTR_API_CAQI] = index["value"]
- self.data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ")
- self.data[ATTR_API_CAQI_DESCRIPTION] = index["description"]
- self.data[ATTR_API_ADVICE] = index["advice"]
- _LOGGER.debug("Data retrieved from Airly")
- except asyncio.TimeoutError:
- _LOGGER.error("Asyncio Timeout Error")
- except (ValueError, AirlyError, ClientConnectorError) as error:
- _LOGGER.error(error)
- self.data = {}
+ if index["description"] == NO_AIRLY_SENSORS:
+ raise UpdateFailed("Can't retrieve data: no Airly sensors in this area")
+ for value in values:
+ data[value["name"]] = value["value"]
+ for standard in standards:
+ data[f"{standard['pollutant']}_LIMIT"] = standard["limit"]
+ data[f"{standard['pollutant']}_PERCENT"] = standard["percent"]
+ data[ATTR_API_CAQI] = index["value"]
+ data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ")
+ data[ATTR_API_CAQI_DESCRIPTION] = index["description"]
+ data[ATTR_API_ADVICE] = index["advice"]
+ return data
diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py
index 45b4dfa3a37..fa42e58e9ad 100644
--- a/homeassistant/components/airly/air_quality.py
+++ b/homeassistant/components/airly/air_quality.py
@@ -18,13 +18,13 @@ from .const import (
ATTR_API_PM25,
ATTR_API_PM25_LIMIT,
ATTR_API_PM25_PERCENT,
- DATA_CLIENT,
DOMAIN,
)
ATTRIBUTION = "Data provided by Airly"
LABEL_ADVICE = "advice"
+LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description"
LABEL_AQI_LEVEL = f"{ATTR_AQI}_level"
LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit"
LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit"
@@ -36,9 +36,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Airly air_quality entity based on a config entry."""
name = config_entry.data[CONF_NAME]
- data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
+ coordinator = hass.data[DOMAIN][config_entry.entry_id]
- async_add_entities([AirlyAirQuality(data, name, config_entry.unique_id)], True)
+ async_add_entities(
+ [AirlyAirQuality(coordinator, name, config_entry.unique_id)], False
+ )
def round_state(func):
@@ -56,23 +58,23 @@ def round_state(func):
class AirlyAirQuality(AirQualityEntity):
"""Define an Airly air quality."""
- def __init__(self, airly, name, unique_id):
+ def __init__(self, coordinator, name, unique_id):
"""Initialize."""
- self.airly = airly
- self.data = airly.data
+ self.coordinator = coordinator
self._name = name
self._unique_id = unique_id
- self._pm_2_5 = None
- self._pm_10 = None
- self._aqi = None
self._icon = "mdi:blur"
- self._attrs = {}
@property
def name(self):
"""Return the name."""
return self._name
+ @property
+ def should_poll(self):
+ """Return the polling requirement of the entity."""
+ return False
+
@property
def icon(self):
"""Return the icon."""
@@ -82,30 +84,25 @@ class AirlyAirQuality(AirQualityEntity):
@round_state
def air_quality_index(self):
"""Return the air quality index."""
- return self._aqi
+ return self.coordinator.data[ATTR_API_CAQI]
@property
@round_state
def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
- return self._pm_2_5
+ return self.coordinator.data[ATTR_API_PM25]
@property
@round_state
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
- return self._pm_10
+ return self.coordinator.data[ATTR_API_PM10]
@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION
- @property
- def state(self):
- """Return the CAQI description."""
- return self.data[ATTR_API_CAQI_DESCRIPTION]
-
@property
def unique_id(self):
"""Return a unique_id for this entity."""
@@ -114,25 +111,29 @@ class AirlyAirQuality(AirQualityEntity):
@property
def available(self):
"""Return True if entity is available."""
- return bool(self.data)
+ return self.coordinator.last_update_success
@property
def device_state_attributes(self):
"""Return the state attributes."""
- self._attrs[LABEL_ADVICE] = self.data[ATTR_API_ADVICE]
- self._attrs[LABEL_AQI_LEVEL] = self.data[ATTR_API_CAQI_LEVEL]
- self._attrs[LABEL_PM_2_5_LIMIT] = self.data[ATTR_API_PM25_LIMIT]
- self._attrs[LABEL_PM_2_5_PERCENT] = round(self.data[ATTR_API_PM25_PERCENT])
- self._attrs[LABEL_PM_10_LIMIT] = self.data[ATTR_API_PM10_LIMIT]
- self._attrs[LABEL_PM_10_PERCENT] = round(self.data[ATTR_API_PM10_PERCENT])
- return self._attrs
+ return {
+ LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION],
+ LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE],
+ LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL],
+ LABEL_PM_2_5_LIMIT: self.coordinator.data[ATTR_API_PM25_LIMIT],
+ LABEL_PM_2_5_PERCENT: round(self.coordinator.data[ATTR_API_PM25_PERCENT]),
+ LABEL_PM_10_LIMIT: self.coordinator.data[ATTR_API_PM10_LIMIT],
+ LABEL_PM_10_PERCENT: round(self.coordinator.data[ATTR_API_PM10_PERCENT]),
+ }
+
+ async def async_added_to_hass(self):
+ """Connect to dispatcher listening for entity data notifications."""
+ self.coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
async def async_update(self):
- """Update the entity."""
- await self.airly.async_update()
-
- if self.airly.data:
- self.data = self.airly.data
- self._pm_10 = self.data[ATTR_API_PM10]
- self._pm_2_5 = self.data[ATTR_API_PM25]
- self._aqi = self.data[ATTR_API_CAQI]
+ """Update Airly entity."""
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py
index 2040faea6b6..d7f8fc12797 100644
--- a/homeassistant/components/airly/const.py
+++ b/homeassistant/components/airly/const.py
@@ -13,7 +13,7 @@ ATTR_API_PM25_LIMIT = "PM25_LIMIT"
ATTR_API_PM25_PERCENT = "PM25_PERCENT"
ATTR_API_PRESSURE = "PRESSURE"
ATTR_API_TEMPERATURE = "TEMPERATURE"
-DATA_CLIENT = "client"
DEFAULT_NAME = "Airly"
DOMAIN = "airly"
+MAX_REQUESTS_PER_DAY = 100
NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet."
diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py
index a6754b4a00d..0ee9fb3aac5 100644
--- a/homeassistant/components/airly/sensor.py
+++ b/homeassistant/components/airly/sensor.py
@@ -18,7 +18,6 @@ from .const import (
ATTR_API_PM1,
ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE,
- DATA_CLIENT,
DOMAIN,
)
@@ -60,14 +59,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Airly sensor entities based on a config entry."""
name = config_entry.data[CONF_NAME]
- data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
+ coordinator = hass.data[DOMAIN][config_entry.entry_id]
sensors = []
for sensor in SENSOR_TYPES:
unique_id = f"{config_entry.unique_id}-{sensor.lower()}"
- sensors.append(AirlySensor(data, name, sensor, unique_id))
+ sensors.append(AirlySensor(coordinator, name, sensor, unique_id))
- async_add_entities(sensors, True)
+ async_add_entities(sensors, False)
def round_state(func):
@@ -85,10 +84,9 @@ def round_state(func):
class AirlySensor(Entity):
"""Define an Airly sensor."""
- def __init__(self, airly, name, kind, unique_id):
+ def __init__(self, coordinator, name, kind, unique_id):
"""Initialize."""
- self.airly = airly
- self.data = airly.data
+ self.coordinator = coordinator
self._name = name
self._unique_id = unique_id
self.kind = kind
@@ -103,10 +101,15 @@ class AirlySensor(Entity):
"""Return the name."""
return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}"
+ @property
+ def should_poll(self):
+ """Return the polling requirement of the entity."""
+ return False
+
@property
def state(self):
"""Return the state."""
- self._state = self.data[self.kind]
+ self._state = self.coordinator.data[self.kind]
if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]:
self._state = round(self._state)
if self.kind in [ATTR_API_TEMPERATURE, ATTR_API_HUMIDITY]:
@@ -142,11 +145,16 @@ class AirlySensor(Entity):
@property
def available(self):
"""Return True if entity is available."""
- return bool(self.data)
+ return self.coordinator.last_update_success
+
+ async def async_added_to_hass(self):
+ """Connect to dispatcher listening for entity data notifications."""
+ self.coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
async def async_update(self):
- """Update the sensor."""
- await self.airly.async_update()
-
- if self.airly.data:
- self.data = self.airly.data
+ """Update Airly entity."""
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/airvisual/.translations/ca.json b/homeassistant/components/airvisual/.translations/ca.json
index b80386dc75b..070eeee8b51 100644
--- a/homeassistant/components/airvisual/.translations/ca.json
+++ b/homeassistant/components/airvisual/.translations/ca.json
@@ -11,13 +11,23 @@
"data": {
"api_key": "Clau API",
"latitude": "Latitud",
- "longitude": "Longitud",
- "show_on_map": "Mostra al mapa l'\u00e0rea geogr\u00e0fica monitoritzada"
+ "longitude": "Longitud"
},
"description": "Monitoritzaci\u00f3 de la qualitat de l'aire per ubicaci\u00f3 geogr\u00e0fica.",
"title": "Configura AirVisual"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Mostra al mapa l'\u00e0rea geogr\u00e0fica monitoritzada"
+ },
+ "description": "Estableix les diferents opcions de la integraci\u00f3 AirVisual.",
+ "title": "Configuraci\u00f3 d'AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/de.json b/homeassistant/components/airvisual/.translations/de.json
index 0c624614610..02f25900428 100644
--- a/homeassistant/components/airvisual/.translations/de.json
+++ b/homeassistant/components/airvisual/.translations/de.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Dieser API-Schl\u00fcssel wird bereits verwendet."
+ "already_configured": "Diese Koordinaten wurden bereits registriert."
},
"error": {
"invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel"
@@ -13,9 +13,21 @@
"latitude": "Breitengrad",
"longitude": "L\u00e4ngengrad"
},
+ "description": "\u00dcberwachen Sie die Luftqualit\u00e4t an einem geografischen Ort.",
"title": "Konfigurieren Sie AirVisual"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Zeigen Sie die \u00fcberwachte Geografie auf der Karte an"
+ },
+ "description": "Legen Sie verschiedene Optionen f\u00fcr die AirVisual-Integration fest.",
+ "title": "Konfigurieren Sie AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/en.json b/homeassistant/components/airvisual/.translations/en.json
index 2bcff29b770..30d501f1af6 100644
--- a/homeassistant/components/airvisual/.translations/en.json
+++ b/homeassistant/components/airvisual/.translations/en.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "This API key is already in use."
+ "already_configured": "These coordinates have already been registered."
},
"error": {
"invalid_api_key": "Invalid API key"
diff --git a/homeassistant/components/airvisual/.translations/es.json b/homeassistant/components/airvisual/.translations/es.json
index 3ec5c12f1e9..752593ce29d 100644
--- a/homeassistant/components/airvisual/.translations/es.json
+++ b/homeassistant/components/airvisual/.translations/es.json
@@ -11,13 +11,23 @@
"data": {
"api_key": "Clave API",
"latitude": "Latitud",
- "longitude": "Longitud",
- "show_on_map": "Mostrar geograf\u00eda monitorizada en el mapa"
+ "longitude": "Longitud"
},
"description": "Monitorizar la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.",
"title": "Configurar AirVisual"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Mostrar geograf\u00eda monitorizada en el mapa"
+ },
+ "description": "Ajustar varias opciones para la integraci\u00f3n de AirVisual.",
+ "title": "Configurar AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/fr.json b/homeassistant/components/airvisual/.translations/fr.json
new file mode 100644
index 00000000000..6ee4377db95
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/fr.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Cette cl\u00e9 API est d\u00e9j\u00e0 utilis\u00e9e."
+ },
+ "error": {
+ "invalid_api_key": "Cl\u00e9 API invalide"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Cl\u00e9 API",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "description": "Surveiller la qualit\u00e9 de l\u2019air dans un emplacement g\u00e9ographique.",
+ "title": "Configurer AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "D\u00e9finissez diverses options pour l'int\u00e9gration d'AirVisual.",
+ "title": "Configurer AirVisual"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/it.json b/homeassistant/components/airvisual/.translations/it.json
index 860a1e3e577..762c99ec4d7 100644
--- a/homeassistant/components/airvisual/.translations/it.json
+++ b/homeassistant/components/airvisual/.translations/it.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Questa chiave API \u00e8 gi\u00e0 in uso."
+ "already_configured": "Queste coordinate sono gi\u00e0 state registrate."
},
"error": {
"invalid_api_key": "Chiave API non valida"
@@ -11,13 +11,23 @@
"data": {
"api_key": "Chiave API",
"latitude": "Latitudine",
- "longitude": "Logitudine",
- "show_on_map": "Mostra l'area geografica monitorata sulla mappa"
+ "longitude": "Logitudine"
},
"description": "Monitorare la qualit\u00e0 dell'aria in una posizione geografica.",
"title": "Configura AirVisual"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Mostra l'area geografica monitorata sulla mappa"
+ },
+ "description": "Impostare varie opzioni per l'integrazione AirVisual.",
+ "title": "Configurare AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/ko.json b/homeassistant/components/airvisual/.translations/ko.json
new file mode 100644
index 00000000000..4e1511b2d2d
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/ko.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "invalid_api_key": "\uc798\ubabb\ub41c API \ud0a4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API \ud0a4",
+ "latitude": "\uc704\ub3c4",
+ "longitude": "\uacbd\ub3c4"
+ },
+ "description": "\uc9c0\ub9ac\uc801 \uc704\uce58\uc5d0\uc11c \ub300\uae30\uc9c8\uc744 \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4.",
+ "title": "AirVisual \uad6c\uc131"
+ }
+ },
+ "title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "\uc9c0\ub3c4\uc5d0 \ubaa8\ub2c8\ud130\ub9c1\ub41c \uc9c0\ub9ac \uc815\ubcf4 \ud45c\uc2dc"
+ },
+ "description": "AirVisual \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc5d0 \ub300\ud55c \ub2e4\uc591\ud55c \uc635\uc158\uc744 \uc124\uc815\ud574\uc8fc\uc138\uc694.",
+ "title": "AirVisual \uad6c\uc131"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/lb.json b/homeassistant/components/airvisual/.translations/lb.json
index 0ae807dde52..a7f20253ef1 100644
--- a/homeassistant/components/airvisual/.translations/lb.json
+++ b/homeassistant/components/airvisual/.translations/lb.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "D\u00ebsen App Schl\u00ebssel g\u00ebtt scho benotzt"
+ "already_configured": "D\u00ebs Koordinate si schon registr\u00e9iert."
},
"error": {
"invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel"
@@ -13,9 +13,21 @@
"latitude": "Breedegrad",
"longitude": "L\u00e4ngegrad"
},
+ "description": "Loft Qualit\u00e9it an enger geografescher Lag iwwerwaachen.",
"title": "AirVisual konfigur\u00e9ieren"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Iwwerwaachte Geografie op der Kaart uweisen"
+ },
+ "description": "Verschidden Optioune fir d'AirVisual Integratioun d\u00e9fin\u00e9ieren.",
+ "title": "Airvisual ariichten"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/no.json b/homeassistant/components/airvisual/.translations/no.json
index bf089c485d6..2a2a1fcd07c 100644
--- a/homeassistant/components/airvisual/.translations/no.json
+++ b/homeassistant/components/airvisual/.translations/no.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Denne API-n\u00f8kkelen er allerede i bruk."
+ "already_configured": "Disse koordinatene er allerede registrert."
},
"error": {
"invalid_api_key": "Ugyldig API-n\u00f8kkel"
@@ -11,13 +11,23 @@
"data": {
"api_key": "API-n\u00f8kkel",
"latitude": "Breddegrad",
- "longitude": "Lengdegrad",
- "show_on_map": "Vis overv\u00e5ket geografi p\u00e5 kartet"
+ "longitude": "Lengdegrad"
},
"description": "Overv\u00e5k luftkvaliteten p\u00e5 et geografisk sted.",
"title": "Konfigurer AirVisual"
}
},
- "title": "AirVisual"
+ "title": ""
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Vis overv\u00e5ket geografi p\u00e5 kartet"
+ },
+ "description": "Angi forskjellige alternativer for AirVisual-integrasjonen.",
+ "title": "Konfigurer AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/pl.json b/homeassistant/components/airvisual/.translations/pl.json
new file mode 100644
index 00000000000..99c74c3e5cd
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/pl.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ten klucz API jest ju\u017c w u\u017cyciu."
+ },
+ "error": {
+ "invalid_api_key": "Nieprawid\u0142owy klucz API"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Klucz API",
+ "latitude": "Szeroko\u015b\u0107 geograficzna",
+ "longitude": "D\u0142ugo\u015b\u0107 geograficzna"
+ },
+ "description": "Monitoruj jako\u015b\u0107 powietrza w okre\u015blonej lokalizacji geograficznej.",
+ "title": "Konfiguracja AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Wy\u015bwietlaj encje na mapie"
+ },
+ "description": "Konfiguracja opcji integracji AirVisual.",
+ "title": "Konfiguracja AirVisual"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/ru.json b/homeassistant/components/airvisual/.translations/ru.json
index 2eac29c9ecc..e8682a0188a 100644
--- a/homeassistant/components/airvisual/.translations/ru.json
+++ b/homeassistant/components/airvisual/.translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u042d\u0442\u043e\u0442 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f."
+ "already_configured": "\u041a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b."
},
"error": {
"invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API."
@@ -11,13 +11,23 @@
"data": {
"api_key": "\u041a\u043b\u044e\u0447 API",
"latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
- "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430",
- "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0435\u043c\u0443\u044e \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435"
+ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430"
},
"description": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u0438\u0440\u0443\u0439\u0442\u0435 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u043e \u0432\u043e\u0437\u0434\u0443\u0445\u0430 \u0432 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438.",
"title": "AirVisual"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0435\u043c\u0443\u044e \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435"
+ },
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 AirVisual.",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/sk.json b/homeassistant/components/airvisual/.translations/sk.json
new file mode 100644
index 00000000000..e6945904d90
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/sk.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Zemepisn\u00e1 \u0161\u00edrka",
+ "longitude": "Zemepisn\u00e1 d\u013a\u017eka"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/sl.json b/homeassistant/components/airvisual/.translations/sl.json
new file mode 100644
index 00000000000..6511c7b6da8
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/sl.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ta klju\u010d API je \u017ee v uporabi."
+ },
+ "error": {
+ "invalid_api_key": "Neveljaven API klju\u010d"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Klju\u010d",
+ "latitude": "Zemljepisna \u0161irina",
+ "longitude": "Zemljepisna dol\u017eina"
+ },
+ "description": "Spremljajte kakovost zraka na zemljepisni lokaciji.",
+ "title": "Nastavite AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Prika\u017ei nadzorovano obmo\u010dje na zemljevidu"
+ },
+ "description": "Nastavite razli\u010dne mo\u017enosti za integracijo AirVisual.",
+ "title": "Nastavite AirVisual"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/zh-Hant.json b/homeassistant/components/airvisual/.translations/zh-Hant.json
index 3f62c06a9e2..e40926d4a08 100644
--- a/homeassistant/components/airvisual/.translations/zh-Hant.json
+++ b/homeassistant/components/airvisual/.translations/zh-Hant.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u6b64 API \u5bc6\u9470\u5df2\u88ab\u4f7f\u7528\u3002"
+ "already_configured": "\u6b64\u4e9b\u5ea7\u6a19\u5df2\u8a3b\u518a\u3002"
},
"error": {
"invalid_api_key": "API \u5bc6\u78bc\u7121\u6548"
@@ -11,13 +11,23 @@
"data": {
"api_key": "API \u5bc6\u9470",
"latitude": "\u7def\u5ea6",
- "longitude": "\u7d93\u5ea6",
- "show_on_map": "\u65bc\u5730\u5716\u4e0a\u986f\u793a\u76e3\u63a7\u4f4d\u7f6e\u3002"
+ "longitude": "\u7d93\u5ea6"
},
"description": "\u4f9d\u5730\u7406\u4f4d\u7f6e\u76e3\u63a7\u7a7a\u6c23\u54c1\u8cea\u3002",
"title": "\u8a2d\u5b9a AirVisual"
}
},
"title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "\u65bc\u5730\u5716\u4e0a\u986f\u793a\u76e3\u63a7\u4f4d\u7f6e\u3002"
+ },
+ "description": "\u8a2d\u5b9a AirVisual \u6574\u5408\u9078\u9805\u3002",
+ "title": "\u8a2d\u5b9a AirVisual"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py
index a48acf7bb34..e234c2b1c67 100644
--- a/homeassistant/components/airvisual/__init__.py
+++ b/homeassistant/components/airvisual/__init__.py
@@ -1,5 +1,4 @@
"""The airvisual component."""
-import asyncio
import logging
from pyairvisual import Client
@@ -23,7 +22,6 @@ from homeassistant.helpers.event import async_track_time_interval
from .const import (
CONF_CITY,
CONF_COUNTRY,
- CONF_GEOGRAPHIES,
DATA_CLIENT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
@@ -36,7 +34,7 @@ DATA_LISTENER = "listener"
DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True}
-CONF_NODE_ID = "node_id"
+CONF_GEOGRAPHIES = "geographies"
GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema(
{
@@ -70,34 +68,38 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA)
def async_get_geography_id(geography_dict):
"""Generate a unique ID from a geography dict."""
if CONF_CITY in geography_dict:
- return ",".join(
+ return ", ".join(
(
geography_dict[CONF_CITY],
geography_dict[CONF_STATE],
geography_dict[CONF_COUNTRY],
)
)
- return ",".join(
+ return ", ".join(
(str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE]))
)
async def async_setup(hass, config):
"""Set up the AirVisual component."""
- hass.data[DOMAIN] = {}
- hass.data[DOMAIN][DATA_CLIENT] = {}
- hass.data[DOMAIN][DATA_LISTENER] = {}
+ hass.data[DOMAIN] = {DATA_CLIENT: {}, DATA_LISTENER: {}}
if DOMAIN not in config:
return True
conf = config[DOMAIN]
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
+ for geography in conf.get(
+ CONF_GEOGRAPHIES,
+ [{CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude}],
+ ):
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_API_KEY: conf[CONF_API_KEY], **geography},
+ )
)
- )
return True
@@ -144,6 +146,45 @@ async def async_setup_entry(hass, config_entry):
return True
+async def async_migrate_entry(hass, config_entry):
+ """Migrate an old config entry."""
+ version = config_entry.version
+
+ _LOGGER.debug("Migrating from version %s", version)
+
+ # 1 -> 2: One geography per config entry
+ if version == 1:
+ version = config_entry.version = 2
+
+ # Update the config entry to only include the first geography (there is always
+ # guaranteed to be at least one):
+ data = {**config_entry.data}
+ geographies = data.pop(CONF_GEOGRAPHIES)
+ first_geography = geographies.pop(0)
+ first_id = async_get_geography_id(first_geography)
+
+ hass.config_entries.async_update_entry(
+ config_entry,
+ unique_id=first_id,
+ title=f"Cloud API ({first_id})",
+ data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **first_geography},
+ )
+
+ # For any geographies that remain, create a new config entry for each one:
+ for geography in geographies:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **geography},
+ )
+ )
+
+ _LOGGER.info("Migration to version %s successful", version)
+
+ return True
+
+
async def async_unload_entry(hass, config_entry):
"""Unload an AirVisual config entry."""
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
@@ -170,40 +211,28 @@ class AirVisualData:
self._client = client
self._hass = hass
self.data = {}
+ self.geography_data = config_entry.data
+ self.geography_id = config_entry.unique_id
self.options = config_entry.options
- self.geographies = {
- async_get_geography_id(geography): geography
- for geography in config_entry.data[CONF_GEOGRAPHIES]
- }
-
async def async_update(self):
"""Get new data for all locations from the AirVisual cloud API."""
- tasks = []
+ if CONF_CITY in self.geography_data:
+ api_coro = self._client.api.city(
+ self.geography_data[CONF_CITY],
+ self.geography_data[CONF_STATE],
+ self.geography_data[CONF_COUNTRY],
+ )
+ else:
+ api_coro = self._client.api.nearest_city(
+ self.geography_data[CONF_LATITUDE], self.geography_data[CONF_LONGITUDE],
+ )
- for geography in self.geographies.values():
- if CONF_CITY in geography:
- tasks.append(
- self._client.api.city(
- geography[CONF_CITY],
- geography[CONF_STATE],
- geography[CONF_COUNTRY],
- )
- )
- else:
- tasks.append(
- self._client.api.nearest_city(
- geography[CONF_LATITUDE], geography[CONF_LONGITUDE],
- )
- )
-
- results = await asyncio.gather(*tasks, return_exceptions=True)
- for geography_id, result in zip(self.geographies, results):
- if isinstance(result, AirVisualError):
- _LOGGER.error("Error while retrieving data: %s", result)
- self.data[geography_id] = {}
- continue
- self.data[geography_id] = result
+ try:
+ self.data[self.geography_id] = await api_coro
+ except AirVisualError as err:
+ _LOGGER.error("Error while retrieving data: %s", err)
+ self.data[self.geography_id] = {}
_LOGGER.debug("Received new data")
async_dispatcher_send(self._hass, TOPIC_UPDATE)
diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py
index 2f961ccfb49..047f585a4ff 100644
--- a/homeassistant/components/airvisual/config_flow.py
+++ b/homeassistant/components/airvisual/config_flow.py
@@ -1,5 +1,5 @@
"""Define a config flow manager for AirVisual."""
-import logging
+import asyncio
from pyairvisual import Client
from pyairvisual.errors import InvalidKeyError
@@ -15,15 +15,14 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
-from .const import CONF_GEOGRAPHIES, DOMAIN # pylint: disable=unused-import
-
-_LOGGER = logging.getLogger("homeassistant.components.airvisual")
+from . import async_get_geography_id
+from .const import DOMAIN # pylint: disable=unused-import
class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle an AirVisual config flow."""
- VERSION = 1
+ VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@property
@@ -68,35 +67,33 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if not user_input:
return await self._show_form()
- await self._async_set_unique_id(user_input[CONF_API_KEY])
+ geo_id = async_get_geography_id(user_input)
+ await self._async_set_unique_id(geo_id)
websession = aiohttp_client.async_get_clientsession(self.hass)
client = Client(websession, api_key=user_input[CONF_API_KEY])
- try:
- await client.api.nearest_city()
- except InvalidKeyError:
- return await self._show_form(errors={CONF_API_KEY: "invalid_api_key"})
-
- data = {CONF_API_KEY: user_input[CONF_API_KEY]}
- if user_input.get(CONF_GEOGRAPHIES):
- data[CONF_GEOGRAPHIES] = user_input[CONF_GEOGRAPHIES]
- else:
- data[CONF_GEOGRAPHIES] = [
- {
- CONF_LATITUDE: user_input.get(
- CONF_LATITUDE, self.hass.config.latitude
- ),
- CONF_LONGITUDE: user_input.get(
- CONF_LONGITUDE, self.hass.config.longitude
- ),
- }
- ]
-
- return self.async_create_entry(
- title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)", data=data
+ # If this is the first (and only the first) time we've seen this API key, check
+ # that it's valid:
+ checked_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set())
+ check_keys_lock = self.hass.data.setdefault(
+ "airvisual_checked_api_keys_lock", asyncio.Lock()
)
+ async with check_keys_lock:
+ if user_input[CONF_API_KEY] not in checked_keys:
+ try:
+ await client.api.nearest_city()
+ except InvalidKeyError:
+ return await self._show_form(
+ errors={CONF_API_KEY: "invalid_api_key"}
+ )
+
+ checked_keys.add(user_input[CONF_API_KEY])
+ return self.async_create_entry(
+ title=f"Cloud API ({geo_id})", data=user_input
+ )
+
class AirVisualOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle an AirVisual options flow."""
diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py
index ab54e191116..3bfc224a735 100644
--- a/homeassistant/components/airvisual/const.py
+++ b/homeassistant/components/airvisual/const.py
@@ -5,7 +5,6 @@ DOMAIN = "airvisual"
CONF_CITY = "city"
CONF_COUNTRY = "country"
-CONF_GEOGRAPHIES = "geographies"
DATA_CLIENT = "client"
diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py
index 28d2b3f5f86..49a5f53361f 100644
--- a/homeassistant/components/airvisual/sensor.py
+++ b/homeassistant/components/airvisual/sensor.py
@@ -191,16 +191,19 @@ class AirVisualSensor(Entity):
}
)
- geography = self._airvisual.geographies[self._geography_id]
- if CONF_LATITUDE in geography:
+ if CONF_LATITUDE in self._airvisual.geography_data:
if self._airvisual.options[CONF_SHOW_ON_MAP]:
- self._attrs[ATTR_LATITUDE] = geography[CONF_LATITUDE]
- self._attrs[ATTR_LONGITUDE] = geography[CONF_LONGITUDE]
+ self._attrs[ATTR_LATITUDE] = self._airvisual.geography_data[
+ CONF_LATITUDE
+ ]
+ self._attrs[ATTR_LONGITUDE] = self._airvisual.geography_data[
+ CONF_LONGITUDE
+ ]
self._attrs.pop("lati", None)
self._attrs.pop("long", None)
else:
- self._attrs["lati"] = geography[CONF_LATITUDE]
- self._attrs["long"] = geography[CONF_LONGITUDE]
+ self._attrs["lati"] = self._airvisual.geography_data[CONF_LATITUDE]
+ self._attrs["long"] = self._airvisual.geography_data[CONF_LONGITUDE]
self._attrs.pop(ATTR_LATITUDE, None)
self._attrs.pop(ATTR_LONGITUDE, None)
diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json
index 6e94c393da6..8791e6d864d 100644
--- a/homeassistant/components/airvisual/strings.json
+++ b/homeassistant/components/airvisual/strings.json
@@ -16,7 +16,7 @@
"invalid_api_key": "Invalid API key"
},
"abort": {
- "already_configured": "This API key is already in use."
+ "already_configured": "These coordinates have already been registered."
}
},
"options": {
diff --git a/homeassistant/components/alarm_control_panel/.translations/ca.json b/homeassistant/components/alarm_control_panel/.translations/ca.json
index d60cf3173c7..5c33ac3c963 100644
--- a/homeassistant/components/alarm_control_panel/.translations/ca.json
+++ b/homeassistant/components/alarm_control_panel/.translations/ca.json
@@ -7,10 +7,17 @@
"disarm": "Desactiva {entity_name}",
"trigger": "Dispara {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} est\u00e0 activada en mode 'a fora'",
+ "is_armed_home": "{entity_name} est\u00e0 activada en mode 'a casa'",
+ "is_armed_night": "{entity_name} est\u00e0 activada en mode 'nocturn'",
+ "is_disarmed": "{entity_name} est\u00e0 desactivada",
+ "is_triggered": "{entity_name} est\u00e0 disparada"
+ },
"trigger_type": {
- "armed_away": "{entity_name} activada en mode a fora",
- "armed_home": "{entity_name} activada en mode a casa",
- "armed_night": "{entity_name} activada en mode nocturn",
+ "armed_away": "{entity_name} activada en mode 'a fora'",
+ "armed_home": "{entity_name} activada en mode 'a casa'",
+ "armed_night": "{entity_name} activada en mode 'nocturn'",
"disarmed": "{entity_name} desactivada",
"triggered": "{entity_name} disparat/ada"
}
diff --git a/homeassistant/components/alarm_control_panel/.translations/de.json b/homeassistant/components/alarm_control_panel/.translations/de.json
index 1787391c292..2b319c4a8a6 100644
--- a/homeassistant/components/alarm_control_panel/.translations/de.json
+++ b/homeassistant/components/alarm_control_panel/.translations/de.json
@@ -7,6 +7,13 @@
"disarm": "Deaktivere {entity_name}",
"trigger": "Ausl\u00f6ser {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} ist aktiviert - Unterwegs",
+ "is_armed_home": "{entity_name} ist aktiviert - Zuhause",
+ "is_armed_night": "{entity_name} ist aktiviert - Nacht",
+ "is_disarmed": "{entity_name} ist deaktiviert",
+ "is_triggered": "{entity_name} wurde ausgel\u00f6st"
+ },
"trigger_type": {
"armed_away": "{entity_name} Unterwegs",
"armed_home": "{entity_name} Zuhause",
diff --git a/homeassistant/components/alarm_control_panel/.translations/en.json b/homeassistant/components/alarm_control_panel/.translations/en.json
index a00e81feb92..85b6be1138c 100644
--- a/homeassistant/components/alarm_control_panel/.translations/en.json
+++ b/homeassistant/components/alarm_control_panel/.translations/en.json
@@ -7,6 +7,13 @@
"disarm": "Disarm {entity_name}",
"trigger": "Trigger {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} is armed away",
+ "is_armed_home": "{entity_name} is armed home",
+ "is_armed_night": "{entity_name} is armed night",
+ "is_disarmed": "{entity_name} is disarmed",
+ "is_triggered": "{entity_name} is triggered"
+ },
"trigger_type": {
"armed_away": "{entity_name} armed away",
"armed_home": "{entity_name} armed home",
diff --git a/homeassistant/components/alarm_control_panel/.translations/es.json b/homeassistant/components/alarm_control_panel/.translations/es.json
index 8200755de0f..0acc0e5c98c 100644
--- a/homeassistant/components/alarm_control_panel/.translations/es.json
+++ b/homeassistant/components/alarm_control_panel/.translations/es.json
@@ -7,6 +7,13 @@
"disarm": "Desarmar {entity_name}",
"trigger": "Lanzar {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} est\u00e1 armada fuera",
+ "is_armed_home": "{entity_name} est\u00e1 armada en casa",
+ "is_armed_night": "{entity_name} est\u00e1 armada noche",
+ "is_disarmed": "{entity_name} est\u00e1 desarmada",
+ "is_triggered": "{entity_name} est\u00e1 disparada"
+ },
"trigger_type": {
"armed_away": "{entity_name} armado fuera",
"armed_home": "{entity_name} armado en casa",
diff --git a/homeassistant/components/alarm_control_panel/.translations/fr.json b/homeassistant/components/alarm_control_panel/.translations/fr.json
index fbdc6a5605f..f87f1b79b87 100644
--- a/homeassistant/components/alarm_control_panel/.translations/fr.json
+++ b/homeassistant/components/alarm_control_panel/.translations/fr.json
@@ -7,6 +7,13 @@
"disarm": "D\u00e9sarmer {entity_name}",
"trigger": "D\u00e9clencheur {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} est arm\u00e9",
+ "is_armed_home": "{entity_name} est arm\u00e9 \u00e0 la maison",
+ "is_armed_night": "{entity_name} est arm\u00e9 la nuit",
+ "is_disarmed": "{entity_name} est d\u00e9sarm\u00e9",
+ "is_triggered": "{entity_name} est d\u00e9clench\u00e9"
+ },
"trigger_type": {
"armed_away": "Armer {entity_name} en mode \"sortie\"",
"armed_home": "Armer {entity_name} en mode \"maison\"",
diff --git a/homeassistant/components/alarm_control_panel/.translations/it.json b/homeassistant/components/alarm_control_panel/.translations/it.json
index 78a3f0b07e5..0857f0665aa 100644
--- a/homeassistant/components/alarm_control_panel/.translations/it.json
+++ b/homeassistant/components/alarm_control_panel/.translations/it.json
@@ -7,11 +7,18 @@
"disarm": "Disarmare {entity_name}",
"trigger": "Attivazione {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} \u00e8 attivo in modalit\u00e0 fuori casa",
+ "is_armed_home": "{entity_name} \u00e8 attivo in modalit\u00e0 a casa",
+ "is_armed_night": "{entity_name} \u00e8 attivo in modalit\u00e0 notte",
+ "is_disarmed": "{entity_name} \u00e8 disattivo",
+ "is_triggered": "{entity_name} \u00e8 attivato"
+ },
"trigger_type": {
- "armed_away": "{entity_name} armata modalit\u00e0 fuori casa",
- "armed_home": "{entity_name} armata modalit\u00e0 a casa",
- "armed_night": "{entity_name} armata modalit\u00e0 notte",
- "disarmed": "{entity_name} disarmato",
+ "armed_away": "{entity_name} attivato in modalit\u00e0 fuori casa",
+ "armed_home": "{entity_name} attivato in modalit\u00e0 a casa",
+ "armed_night": "{entity_name} attivato in modalit\u00e0 notte",
+ "disarmed": "{entity_name} disattivato",
"triggered": "{entity_name} attivato"
}
}
diff --git a/homeassistant/components/alarm_control_panel/.translations/ko.json b/homeassistant/components/alarm_control_panel/.translations/ko.json
index b70ae8dc025..321bc442444 100644
--- a/homeassistant/components/alarm_control_panel/.translations/ko.json
+++ b/homeassistant/components/alarm_control_panel/.translations/ko.json
@@ -7,6 +7,13 @@
"disarm": "{entity_name} \uacbd\ube44\ud574\uc81c",
"trigger": "{entity_name} \ud2b8\ub9ac\uac70"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uacbd\ube44\ubaa8\ub4dc \uc0c1\ud0dc\uc774\uba74",
+ "is_armed_home": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uacbd\ube44\ubaa8\ub4dc \uc0c1\ud0dc\uc774\uba74",
+ "is_armed_night": "{entity_name} \uc774(\uac00) \uc57c\uac04 \uacbd\ube44\ubaa8\ub4dc \uc0c1\ud0dc\uc774\uba74",
+ "is_disarmed": "{entity_name} \uc774(\uac00) \ud574\uc81c \uc0c1\ud0dc\uc774\uba74",
+ "is_triggered": "{entity_name} \uc774(\uac00) \ud2b8\ub9ac\uac70\ub418\uc5c8\uc73c\uba74"
+ },
"trigger_type": {
"armed_away": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c",
"armed_home": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c",
diff --git a/homeassistant/components/alarm_control_panel/.translations/lb.json b/homeassistant/components/alarm_control_panel/.translations/lb.json
index add11f5b8fe..6c0d32f42ad 100644
--- a/homeassistant/components/alarm_control_panel/.translations/lb.json
+++ b/homeassistant/components/alarm_control_panel/.translations/lb.json
@@ -7,6 +7,13 @@
"disarm": "{entity_name} entsch\u00e4rfen",
"trigger": "{entity_name} ausl\u00e9isen"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} ass ugeschalt fir Ennerwee",
+ "is_armed_home": "{entity_name} ass ugeschalt fir Doheem",
+ "is_armed_night": "{entity_name} ass ugeschalt fir Nuecht",
+ "is_disarmed": "{entity_name} ass entsch\u00e4rft",
+ "is_triggered": "{entity_name} ass ausgel\u00e9ist"
+ },
"trigger_type": {
"armed_away": "{entity_name} ugeschalt fir Ennerwee",
"armed_home": "{entity_name} ugeschalt fir Doheem",
diff --git a/homeassistant/components/alarm_control_panel/.translations/no.json b/homeassistant/components/alarm_control_panel/.translations/no.json
index 0b58064fe09..1177e130150 100644
--- a/homeassistant/components/alarm_control_panel/.translations/no.json
+++ b/homeassistant/components/alarm_control_panel/.translations/no.json
@@ -7,6 +7,13 @@
"disarm": "Deaktiver {entity_name}",
"trigger": "Utl\u00f8ser {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} aktivert borte",
+ "is_armed_home": "{entity_name} aktivert hjemme",
+ "is_armed_night": "{entity_name} aktivert natt",
+ "is_disarmed": "{entity_name} er deaktivert",
+ "is_triggered": "{entity_name} er utl\u00f8st"
+ },
"trigger_type": {
"armed_away": "{entity_name} aktivert borte",
"armed_home": "{entity_name} aktivert hjemme",
diff --git a/homeassistant/components/alarm_control_panel/.translations/pl.json b/homeassistant/components/alarm_control_panel/.translations/pl.json
index 024a0861c1c..c1125be31b6 100644
--- a/homeassistant/components/alarm_control_panel/.translations/pl.json
+++ b/homeassistant/components/alarm_control_panel/.translations/pl.json
@@ -7,6 +7,13 @@
"disarm": "rozbr\u00f3j {entity_name}",
"trigger": "wyzw\u00f3l {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} jest uzbrojony (poza domem)",
+ "is_armed_home": "{entity_name} jest uzbrojony (w domu)",
+ "is_armed_night": "{entity_name} jest uzbrojony (noc)",
+ "is_disarmed": "{entity_name} jest rozbrojony",
+ "is_triggered": "{entity_name} jest wyzwolony"
+ },
"trigger_type": {
"armed_away": "{entity_name} zostanie uzbrojony (poza domem)",
"armed_home": "{entity_name} zostanie uzbrojony (w domu)",
diff --git a/homeassistant/components/alarm_control_panel/.translations/ru.json b/homeassistant/components/alarm_control_panel/.translations/ru.json
index f9a0e859e11..36705dbcefd 100644
--- a/homeassistant/components/alarm_control_panel/.translations/ru.json
+++ b/homeassistant/components/alarm_control_panel/.translations/ru.json
@@ -7,6 +7,13 @@
"disarm": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0445\u0440\u0430\u043d\u0443 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
"trigger": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442"
},
+ "condition_type": {
+ "is_armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
+ "is_armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
+ "is_armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
+ "is_disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
+ "is_triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442"
+ },
"trigger_type": {
"armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
"armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}",
diff --git a/homeassistant/components/alarm_control_panel/.translations/sl.json b/homeassistant/components/alarm_control_panel/.translations/sl.json
index 855c50ab827..c817f7830ba 100644
--- a/homeassistant/components/alarm_control_panel/.translations/sl.json
+++ b/homeassistant/components/alarm_control_panel/.translations/sl.json
@@ -7,6 +7,13 @@
"disarm": "Razoro\u017ei {entity_name}",
"trigger": "Spro\u017ei {entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name} je oboro\u017een na \"zdoma\"",
+ "is_armed_home": "{entity_name} je oboro\u017een na \"dom\"",
+ "is_armed_night": "{entity_name} je oboro\u017een na \"no\u010d\"",
+ "is_disarmed": "{entity_name} razoro\u017een",
+ "is_triggered": "{entity_name} spro\u017een"
+ },
"trigger_type": {
"armed_away": "{entity_name} oboro\u017een - zdoma",
"armed_home": "{entity_name} oboro\u017een - dom",
diff --git a/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json b/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json
index 94729865c6f..a02ea1c1966 100644
--- a/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json
+++ b/homeassistant/components/alarm_control_panel/.translations/zh-Hant.json
@@ -7,6 +7,13 @@
"disarm": "\u89e3\u9664{entity_name}",
"trigger": "\u89f8\u767c{entity_name}"
},
+ "condition_type": {
+ "is_armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa",
+ "is_armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6",
+ "is_armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593",
+ "is_disarmed": "{entity_name}\u5df2\u89e3\u9664",
+ "is_triggered": "{entity_name}\u5df2\u89f8\u767c"
+ },
"trigger_type": {
"armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa",
"armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6",
diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py
index 77f7846fc34..2844cb286ab 100644
--- a/homeassistant/components/alarm_control_panel/const.py
+++ b/homeassistant/components/alarm_control_panel/const.py
@@ -5,3 +5,10 @@ SUPPORT_ALARM_ARM_AWAY = 2
SUPPORT_ALARM_ARM_NIGHT = 4
SUPPORT_ALARM_TRIGGER = 8
SUPPORT_ALARM_ARM_CUSTOM_BYPASS = 16
+
+CONDITION_TRIGGERED = "is_triggered"
+CONDITION_DISARMED = "is_disarmed"
+CONDITION_ARMED_HOME = "is_armed_home"
+CONDITION_ARMED_AWAY = "is_armed_away"
+CONDITION_ARMED_NIGHT = "is_armed_night"
+CONDITION_ARMED_CUSTOM_BYPASS = "is_armed_custom_bypass"
diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py
new file mode 100644
index 00000000000..c4d43d1b051
--- /dev/null
+++ b/homeassistant/components/alarm_control_panel/device_condition.py
@@ -0,0 +1,162 @@
+"""Provide the device automations for Alarm control panel."""
+from typing import Dict, List
+
+import voluptuous as vol
+
+from homeassistant.components.alarm_control_panel.const import (
+ SUPPORT_ALARM_ARM_AWAY,
+ SUPPORT_ALARM_ARM_CUSTOM_BYPASS,
+ SUPPORT_ALARM_ARM_HOME,
+ SUPPORT_ALARM_ARM_NIGHT,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ CONF_CONDITION,
+ CONF_DEVICE_ID,
+ CONF_DOMAIN,
+ CONF_ENTITY_ID,
+ CONF_TYPE,
+ STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_CUSTOM_BYPASS,
+ STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED,
+)
+from homeassistant.core import HomeAssistant
+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
+
+from . import DOMAIN
+from .const import (
+ CONDITION_ARMED_AWAY,
+ CONDITION_ARMED_CUSTOM_BYPASS,
+ CONDITION_ARMED_HOME,
+ CONDITION_ARMED_NIGHT,
+ CONDITION_DISARMED,
+ CONDITION_TRIGGERED,
+)
+
+CONDITION_TYPES = {
+ CONDITION_TRIGGERED,
+ CONDITION_DISARMED,
+ CONDITION_ARMED_HOME,
+ CONDITION_ARMED_AWAY,
+ CONDITION_ARMED_NIGHT,
+ CONDITION_ARMED_CUSTOM_BYPASS,
+}
+
+CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend(
+ {
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES),
+ }
+)
+
+
+async def async_get_conditions(
+ hass: HomeAssistant, device_id: str
+) -> List[Dict[str, str]]:
+ """List device conditions for Alarm control panel devices."""
+ registry = await entity_registry.async_get_registry(hass)
+ conditions = []
+
+ # Get all the integrations entities for this device
+ for entry in entity_registry.async_entries_for_device(registry, device_id):
+ if entry.domain != DOMAIN:
+ continue
+
+ state = hass.states.get(entry.entity_id)
+
+ # We need a state or else we can't populate the different armed conditions
+ if state is None:
+ continue
+
+ supported_features = state.attributes["supported_features"]
+
+ # Add conditions for each entity that belongs to this integration
+ conditions += [
+ {
+ CONF_CONDITION: "device",
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: CONDITION_DISARMED,
+ },
+ {
+ CONF_CONDITION: "device",
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: CONDITION_TRIGGERED,
+ },
+ ]
+ if supported_features & SUPPORT_ALARM_ARM_HOME:
+ conditions.append(
+ {
+ CONF_CONDITION: "device",
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: CONDITION_ARMED_HOME,
+ }
+ )
+ if supported_features & SUPPORT_ALARM_ARM_AWAY:
+ conditions.append(
+ {
+ CONF_CONDITION: "device",
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: CONDITION_ARMED_AWAY,
+ }
+ )
+ if supported_features & SUPPORT_ALARM_ARM_NIGHT:
+ conditions.append(
+ {
+ CONF_CONDITION: "device",
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: CONDITION_ARMED_NIGHT,
+ }
+ )
+ if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS:
+ conditions.append(
+ {
+ CONF_CONDITION: "device",
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS,
+ }
+ )
+
+ return conditions
+
+
+def async_condition_from_config(
+ config: ConfigType, config_validation: bool
+) -> condition.ConditionCheckerType:
+ """Create a function to test a device condition."""
+ if config_validation:
+ config = CONDITION_SCHEMA(config)
+ if config[CONF_TYPE] == CONDITION_TRIGGERED:
+ state = STATE_ALARM_TRIGGERED
+ elif config[CONF_TYPE] == CONDITION_DISARMED:
+ state = STATE_ALARM_DISARMED
+ elif config[CONF_TYPE] == CONDITION_ARMED_HOME:
+ state = STATE_ALARM_ARMED_HOME
+ elif config[CONF_TYPE] == CONDITION_ARMED_AWAY:
+ state = STATE_ALARM_ARMED_AWAY
+ elif config[CONF_TYPE] == CONDITION_ARMED_NIGHT:
+ state = STATE_ALARM_ARMED_NIGHT
+ elif config[CONF_TYPE] == CONDITION_ARMED_CUSTOM_BYPASS:
+ state = STATE_ALARM_ARMED_CUSTOM_BYPASS
+
+ 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)
+
+ return test_is_state
diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index cbca15c8cf6..4e14a8c2a3d 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -1,18 +1,25 @@
{
- "device_automation": {
- "action_type": {
- "arm_away": "Arm {entity_name} away",
- "arm_home": "Arm {entity_name} home",
- "arm_night": "Arm {entity_name} night",
- "disarm": "Disarm {entity_name}",
- "trigger": "Trigger {entity_name}"
- },
- "trigger_type": {
- "triggered": "{entity_name} triggered",
- "disarmed": "{entity_name} disarmed",
- "armed_home": "{entity_name} armed home",
- "armed_away": "{entity_name} armed away",
- "armed_night": "{entity_name} armed night"
+ "device_automation": {
+ "action_type": {
+ "arm_away": "Arm {entity_name} away",
+ "arm_home": "Arm {entity_name} home",
+ "arm_night": "Arm {entity_name} night",
+ "disarm": "Disarm {entity_name}",
+ "trigger": "Trigger {entity_name}"
+ },
+ "condition_type": {
+ "is_triggered": "{entity_name} is triggered",
+ "is_disarmed": "{entity_name} is disarmed",
+ "is_armed_home": "{entity_name} is armed home",
+ "is_armed_away": "{entity_name} is armed away",
+ "is_armed_night": "{entity_name} is armed night"
+ },
+ "trigger_type": {
+ "triggered": "{entity_name} triggered",
+ "disarmed": "{entity_name} disarmed",
+ "armed_home": "{entity_name} armed home",
+ "armed_away": "{entity_name} armed away",
+ "armed_night": "{entity_name} armed night"
+ }
}
- }
}
\ No newline at end of file
diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py
index a990de9bf98..5e143fcca81 100644
--- a/homeassistant/components/alarmdecoder/__init__.py
+++ b/homeassistant/components/alarmdecoder/__init__.py
@@ -33,6 +33,7 @@ CONF_ZONE_RFID = "rfid"
CONF_ZONES = "zones"
CONF_RELAY_ADDR = "relayaddr"
CONF_RELAY_CHAN = "relaychan"
+CONF_CODE_ARM_REQUIRED = "code_arm_required"
DEFAULT_DEVICE_TYPE = "socket"
DEFAULT_DEVICE_HOST = "localhost"
@@ -42,6 +43,7 @@ DEFAULT_DEVICE_BAUD = 115200
DEFAULT_AUTO_BYPASS = False
DEFAULT_PANEL_DISPLAY = False
+DEFAULT_CODE_ARM_REQUIRED = True
DEFAULT_ZONE_TYPE = "opening"
@@ -105,6 +107,9 @@ CONFIG_SCHEMA = vol.Schema(
CONF_PANEL_DISPLAY, default=DEFAULT_PANEL_DISPLAY
): cv.boolean,
vol.Optional(CONF_AUTO_BYPASS, default=DEFAULT_AUTO_BYPASS): cv.boolean,
+ vol.Optional(
+ CONF_CODE_ARM_REQUIRED, default=DEFAULT_CODE_ARM_REQUIRED
+ ): cv.boolean,
vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA},
}
)
@@ -121,6 +126,7 @@ def setup(hass, config):
device = conf[CONF_DEVICE]
display = conf[CONF_PANEL_DISPLAY]
auto_bypass = conf[CONF_AUTO_BYPASS]
+ code_arm_required = conf[CONF_CODE_ARM_REQUIRED]
zones = conf.get(CONF_ZONES)
device_type = device[CONF_DEVICE_TYPE]
@@ -206,7 +212,11 @@ def setup(hass, config):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder)
load_platform(
- hass, "alarm_control_panel", DOMAIN, {CONF_AUTO_BYPASS: auto_bypass}, config
+ hass,
+ "alarm_control_panel",
+ DOMAIN,
+ {CONF_AUTO_BYPASS: auto_bypass, CONF_CODE_ARM_REQUIRED: code_arm_required},
+ config,
)
if zones:
diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py
index 06783df674d..57004191064 100644
--- a/homeassistant/components/alarmdecoder/alarm_control_panel.py
+++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py
@@ -21,7 +21,13 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
-from . import CONF_AUTO_BYPASS, DATA_AD, DOMAIN, SIGNAL_PANEL_MESSAGE
+from . import (
+ CONF_AUTO_BYPASS,
+ CONF_CODE_ARM_REQUIRED,
+ DATA_AD,
+ DOMAIN,
+ SIGNAL_PANEL_MESSAGE,
+)
_LOGGER = logging.getLogger(__name__)
@@ -39,7 +45,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
return
auto_bypass = discovery_info[CONF_AUTO_BYPASS]
- entity = AlarmDecoderAlarmPanel(auto_bypass)
+ code_arm_required = discovery_info[CONF_CODE_ARM_REQUIRED]
+ entity = AlarmDecoderAlarmPanel(auto_bypass, code_arm_required)
add_entities([entity])
def alarm_toggle_chime_handler(service):
@@ -70,7 +77,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class AlarmDecoderAlarmPanel(AlarmControlPanel):
"""Representation of an AlarmDecoder-based alarm panel."""
- def __init__(self, auto_bypass):
+ def __init__(self, auto_bypass, code_arm_required):
"""Initialize the alarm panel."""
self._display = ""
self._name = "Alarm Panel"
@@ -85,6 +92,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel):
self._ready = None
self._zone_bypassed = None
self._auto_bypass = auto_bypass
+ self._code_arm_required = code_arm_required
async def async_added_to_hass(self):
"""Register callbacks."""
@@ -140,6 +148,11 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel):
"""Return the list of supported features."""
return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT
+ @property
+ def code_arm_required(self):
+ """Whether the code is required for arm actions."""
+ return self._code_arm_required
+
@property
def device_state_attributes(self):
"""Return the state attributes."""
@@ -153,6 +166,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel):
"programming_mode": self._programming_mode,
"ready": self._ready,
"zone_bypassed": self._zone_bypassed,
+ "code_arm_required": self._code_arm_required,
}
def alarm_disarm(self, code=None):
@@ -166,6 +180,8 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel):
if self._auto_bypass:
self.hass.data[DATA_AD].send(f"{code!s}6#")
self.hass.data[DATA_AD].send(f"{code!s}2")
+ elif not self._code_arm_required:
+ self.hass.data[DATA_AD].send("#2")
def alarm_arm_home(self, code=None):
"""Send arm home command."""
@@ -173,11 +189,15 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel):
if self._auto_bypass:
self.hass.data[DATA_AD].send(f"{code!s}6#")
self.hass.data[DATA_AD].send(f"{code!s}3")
+ elif not self._code_arm_required:
+ self.hass.data[DATA_AD].send("#3")
def alarm_arm_night(self, code=None):
"""Send arm night command."""
if code:
self.hass.data[DATA_AD].send(f"{code!s}7")
+ elif not self._code_arm_required:
+ self.hass.data[DATA_AD].send("#7")
def alarm_toggle_chime(self, code=None):
"""Send toggle chime command."""
diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json
index f146f6f4a7e..9824b20db2a 100644
--- a/homeassistant/components/alarmdecoder/manifest.json
+++ b/homeassistant/components/alarmdecoder/manifest.json
@@ -1,10 +1,8 @@
{
- "domain": "alarmdecoder",
- "name": "AlarmDecoder",
- "documentation": "https://www.home-assistant.io/integrations/alarmdecoder",
- "requirements": [
- "alarmdecoder==1.13.2"
- ],
- "dependencies": [],
- "codeowners": []
+ "domain": "alarmdecoder",
+ "name": "AlarmDecoder",
+ "documentation": "https://www.home-assistant.io/integrations/alarmdecoder",
+ "requirements": ["alarmdecoder==1.13.2"],
+ "dependencies": [],
+ "codeowners": ["@ajschmidt8"]
}
diff --git a/homeassistant/components/alarmdecoder/services.yaml b/homeassistant/components/alarmdecoder/services.yaml
index 12268d48bb7..1193f90ff8e 100644
--- a/homeassistant/components/alarmdecoder/services.yaml
+++ b/homeassistant/components/alarmdecoder/services.yaml
@@ -1,9 +1,6 @@
alarm_keypress:
description: Send custom keypresses to the alarm.
fields:
- entity_id:
- description: Name of the alarm control panel to trigger.
- example: 'alarm_control_panel.downstairs'
keypress:
description: 'String to send to the alarm panel.'
example: '*71'
@@ -11,9 +8,6 @@ alarm_keypress:
alarm_toggle_chime:
description: Send the alarm the toggle chime command.
fields:
- entity_id:
- description: Name of the alarm control panel to trigger.
- example: 'alarm_control_panel.downstairs'
code:
description: A required code to toggle the alarm control panel chime with.
example: 1234
diff --git a/homeassistant/components/alarmdotcom/__init__.py b/homeassistant/components/alarmdotcom/__init__.py
deleted file mode 100644
index 0a715230e9f..00000000000
--- a/homeassistant/components/alarmdotcom/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""The alarmdotcom component."""
diff --git a/homeassistant/components/alarmdotcom/alarm_control_panel.py b/homeassistant/components/alarmdotcom/alarm_control_panel.py
deleted file mode 100644
index e5ff550df9a..00000000000
--- a/homeassistant/components/alarmdotcom/alarm_control_panel.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""Interfaces with Alarm.com alarm control panels."""
-import logging
-import re
-
-from pyalarmdotcom import Alarmdotcom
-import voluptuous as vol
-
-import homeassistant.components.alarm_control_panel as alarm
-from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA
-from homeassistant.components.alarm_control_panel.const import (
- SUPPORT_ALARM_ARM_AWAY,
- SUPPORT_ALARM_ARM_HOME,
-)
-from homeassistant.const import (
- CONF_CODE,
- CONF_NAME,
- CONF_PASSWORD,
- CONF_USERNAME,
- STATE_ALARM_ARMED_AWAY,
- STATE_ALARM_ARMED_HOME,
- STATE_ALARM_DISARMED,
-)
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = "Alarm.com"
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Optional(CONF_CODE): cv.positive_int,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- }
-)
-
-
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up a Alarm.com control panel."""
- name = config.get(CONF_NAME)
- code = config.get(CONF_CODE)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
-
- alarmdotcom = AlarmDotCom(hass, name, code, username, password)
- await alarmdotcom.async_login()
- async_add_entities([alarmdotcom])
-
-
-class AlarmDotCom(alarm.AlarmControlPanel):
- """Representation of an Alarm.com status."""
-
- def __init__(self, hass, name, code, username, password):
- """Initialize the Alarm.com status."""
-
- _LOGGER.debug("Setting up Alarm.com...")
- self._hass = hass
- self._name = name
- self._code = str(code) if code else None
- self._username = username
- self._password = password
- self._websession = async_get_clientsession(self._hass)
- self._state = None
- self._alarm = Alarmdotcom(username, password, self._websession, hass.loop)
-
- async def async_login(self):
- """Login to Alarm.com."""
- await self._alarm.async_login()
-
- async def async_update(self):
- """Fetch the latest state."""
- await self._alarm.async_update()
- return self._alarm.state
-
- @property
- def name(self):
- """Return the name of the alarm."""
- return self._name
-
- @property
- def code_format(self):
- """Return one or more digits/characters."""
- if self._code is None:
- return None
- if isinstance(self._code, str) and re.search("^\\d+$", self._code):
- return alarm.FORMAT_NUMBER
- return alarm.FORMAT_TEXT
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._alarm.state.lower() == "disarmed":
- return STATE_ALARM_DISARMED
- if self._alarm.state.lower() == "armed stay":
- return STATE_ALARM_ARMED_HOME
- if self._alarm.state.lower() == "armed away":
- return STATE_ALARM_ARMED_AWAY
- return None
-
- @property
- def supported_features(self) -> int:
- """Return the list of supported features."""
- return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return {"sensor_status": self._alarm.sensor_status}
-
- async def async_alarm_disarm(self, code=None):
- """Send disarm command."""
- if self._validate_code(code):
- await self._alarm.async_alarm_disarm()
-
- async def async_alarm_arm_home(self, code=None):
- """Send arm home command."""
- if self._validate_code(code):
- await self._alarm.async_alarm_arm_home()
-
- async def async_alarm_arm_away(self, code=None):
- """Send arm away command."""
- if self._validate_code(code):
- await self._alarm.async_alarm_arm_away()
-
- def _validate_code(self, code):
- """Validate given code."""
- check = self._code is None or code == self._code
- if not check:
- _LOGGER.warning("Wrong code entered")
- return check
diff --git a/homeassistant/components/alarmdotcom/manifest.json b/homeassistant/components/alarmdotcom/manifest.json
deleted file mode 100644
index 9468649171a..00000000000
--- a/homeassistant/components/alarmdotcom/manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "domain": "alarmdotcom",
- "name": "Alarm.com",
- "documentation": "https://www.home-assistant.io/integrations/alarmdotcom",
- "requirements": ["pyalarmdotcom==0.3.2"],
- "dependencies": [],
- "codeowners": []
-}
diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py
index 1355b0123b8..de5a67087ca 100644
--- a/homeassistant/components/alexa/__init__.py
+++ b/homeassistant/components/alexa/__init__.py
@@ -98,11 +98,7 @@ async def async_setup(hass, config):
f"send command {data['request']['namespace']}/{data['request']['name']}"
)
- return {
- "name": "Amazon Alexa",
- "message": message,
- "entity_id": entity_id,
- }
+ return {"name": "Amazon Alexa", "message": message, "entity_id": entity_id}
hass.components.logbook.async_describe_event(
DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event
diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py
index 6ab086ddda3..63be7df2ead 100644
--- a/homeassistant/components/alexa/capabilities.py
+++ b/homeassistant/components/alexa/capabilities.py
@@ -169,6 +169,11 @@ class AlexaCapability:
"""Return the supportedOperations object."""
return []
+ @staticmethod
+ def camera_stream_configurations():
+ """Applicable only to CameraStreamController."""
+ return None
+
def serialize_discovery(self):
"""Serialize according to the Discovery API."""
result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"}
@@ -222,6 +227,10 @@ class AlexaCapability:
if inputs:
result["inputs"] = inputs
+ camera_stream_configurations = self.camera_stream_configurations()
+ if camera_stream_configurations:
+ result["cameraStreamConfigurations"] = camera_stream_configurations
+
return result
def serialize_properties(self):
@@ -1854,3 +1863,40 @@ class AlexaTimeHoldController(AlexaCapability):
When false, Alexa does not send the Resume directive.
"""
return {"allowRemoteResume": self._allow_remote_resume}
+
+
+class AlexaCameraStreamController(AlexaCapability):
+ """Implements Alexa.CameraStreamController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-camerastreamcontroller.html
+ """
+
+ supported_locales = {
+ "de-DE",
+ "en-AU",
+ "en-CA",
+ "en-GB",
+ "en-IN",
+ "en-US",
+ "es-ES",
+ "fr-FR",
+ "it-IT",
+ "ja-JP",
+ }
+
+ def name(self):
+ """Return the Alexa API name of this interface."""
+ return "Alexa.CameraStreamController"
+
+ def camera_stream_configurations(self):
+ """Return cameraStreamConfigurations object."""
+ camera_stream_configurations = [
+ {
+ "protocols": ["HLS"],
+ "resolutions": [{"width": 1280, "height": 720}],
+ "authorizationTypes": ["NONE"],
+ "videoCodecs": ["H264"],
+ "audioCodecs": ["AAC"],
+ }
+ ]
+ return camera_stream_configurations
diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py
index aa9fe40164c..fce05c8dc86 100644
--- a/homeassistant/components/alexa/entities.py
+++ b/homeassistant/components/alexa/entities.py
@@ -1,11 +1,14 @@
"""Alexa entity adapters."""
+import logging
from typing import List
+from urllib.parse import urlparse
from homeassistant.components import (
alarm_control_panel,
alert,
automation,
binary_sensor,
+ camera,
cover,
fan,
group,
@@ -33,11 +36,13 @@ from homeassistant.const import (
TEMP_FAHRENHEIT,
)
from homeassistant.core import callback
+from homeassistant.helpers import network
from homeassistant.util.decorator import Registry
from .capabilities import (
Alexa,
AlexaBrightnessController,
+ AlexaCameraStreamController,
AlexaChannelController,
AlexaColorController,
AlexaColorTemperatureController,
@@ -68,6 +73,8 @@ from .capabilities import (
)
from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES
+_LOGGER = logging.getLogger(__name__)
+
ENTITY_ADAPTERS = Registry()
TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None)
@@ -763,3 +770,41 @@ class VacuumCapabilities(AlexaEntity):
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.hass)
+
+
+@ENTITY_ADAPTERS.register(camera.DOMAIN)
+class CameraCapabilities(AlexaEntity):
+ """Class to represent Camera capabilities."""
+
+ def default_display_categories(self):
+ """Return the display categories for this entity."""
+ return [DisplayCategory.CAMERA]
+
+ def interfaces(self):
+ """Yield the supported interfaces."""
+ if self._check_requirements():
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & camera.SUPPORT_STREAM:
+ yield AlexaCameraStreamController(self.entity)
+
+ yield AlexaEndpointHealth(self.hass, self.entity)
+ yield Alexa(self.hass)
+
+ def _check_requirements(self):
+ """Check the hass URL for HTTPS scheme and port 443."""
+ if "stream" not in self.hass.config.components:
+ _LOGGER.error(
+ "%s requires stream component for AlexaCameraStreamController",
+ self.entity_id,
+ )
+ return False
+
+ url = urlparse(network.async_get_external_url(self.hass))
+ if url.scheme != "https" or (url.port is not None and url.port != 443):
+ _LOGGER.error(
+ "%s requires HTTPS support on port 443 for AlexaCameraStreamController",
+ self.entity_id,
+ )
+ return False
+
+ return True
diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py
index 67083607769..b3885588b0f 100644
--- a/homeassistant/components/alexa/handlers.py
+++ b/homeassistant/components/alexa/handlers.py
@@ -4,6 +4,7 @@ import math
from homeassistant import core as ha
from homeassistant.components import (
+ camera,
cover,
fan,
group,
@@ -41,6 +42,7 @@ from homeassistant.const import (
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
+from homeassistant.helpers import network
import homeassistant.util.color as color_util
from homeassistant.util.decorator import Registry
import homeassistant.util.dt as dt_util
@@ -1523,3 +1525,28 @@ async def async_api_resume(hass, config, directive, context):
)
return directive.response()
+
+
+@HANDLERS.register(("Alexa.CameraStreamController", "InitializeCameraStreams"))
+async def async_api_initialize_camera_stream(hass, config, directive, context):
+ """Process a InitializeCameraStreams request."""
+ entity = directive.entity
+ stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls")
+ camera_image = hass.states.get(entity.entity_id).attributes["entity_picture"]
+ external_url = network.async_get_external_url(hass)
+ payload = {
+ "cameraStreams": [
+ {
+ "uri": f"{external_url}{stream_source}",
+ "protocol": "HLS",
+ "resolution": {"width": 1280, "height": 720},
+ "authorizationType": "NONE",
+ "videoCodec": "H264",
+ "audioCodec": "AAC",
+ }
+ ],
+ "imageUri": f"{external_url}{camera_image}",
+ }
+ return directive.response(
+ name="Response", namespace="Alexa.CameraStreamController", payload=payload
+ )
diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json
index bf8d4b08ba4..d47e5dea96a 100644
--- a/homeassistant/components/alexa/manifest.json
+++ b/homeassistant/components/alexa/manifest.json
@@ -4,6 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/alexa",
"requirements": [],
"dependencies": ["http"],
- "after_dependencies": ["logbook"],
+ "after_dependencies": ["logbook", "camera"],
"codeowners": ["@home-assistant/cloud", "@ochlocracy"]
}
diff --git a/homeassistant/components/almond/.translations/no.json b/homeassistant/components/almond/.translations/no.json
index 47e32db0abe..63e1d99f7a9 100644
--- a/homeassistant/components/almond/.translations/no.json
+++ b/homeassistant/components/almond/.translations/no.json
@@ -14,6 +14,6 @@
"title": "Velg autentiseringsmetode"
}
},
- "title": "Almond"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/no.json b/homeassistant/components/ambiclimate/.translations/no.json
index e84de4ffc22..3f8e8444cf0 100644
--- a/homeassistant/components/ambiclimate/.translations/no.json
+++ b/homeassistant/components/ambiclimate/.translations/no.json
@@ -18,6 +18,6 @@
"title": "Autensiere Ambiclimate"
}
},
- "title": "Ambiclimate"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/bg.json b/homeassistant/components/ambient_station/.translations/bg.json
index 2099038f004..df9fe8866ac 100644
--- a/homeassistant/components/ambient_station/.translations/bg.json
+++ b/homeassistant/components/ambient_station/.translations/bg.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Application \u0438/\u0438\u043b\u0438 API \u043a\u043b\u044e\u0447\u044a\u0442 \u0432\u0435\u0447\u0435 \u0441\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u0438",
"invalid_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447 \u0438/\u0438\u043b\u0438 Application \u043a\u043b\u044e\u0447",
"no_devices": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0430"
},
diff --git a/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant/components/ambient_station/.translations/ca.json
index 280a90354b0..0991c74b0a5 100644
--- a/homeassistant/components/ambient_station/.translations/ca.json
+++ b/homeassistant/components/ambient_station/.translations/ca.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "No s'ha trobat cap dispositiu al compte"
},
diff --git a/homeassistant/components/ambient_station/.translations/da.json b/homeassistant/components/ambient_station/.translations/da.json
index 6428508687d..5028a84eb31 100644
--- a/homeassistant/components/ambient_station/.translations/da.json
+++ b/homeassistant/components/ambient_station/.translations/da.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "Ingen enheder fundet i konto"
},
diff --git a/homeassistant/components/ambient_station/.translations/de.json b/homeassistant/components/ambient_station/.translations/de.json
index 451a2e70e68..9213007e935 100644
--- a/homeassistant/components/ambient_station/.translations/de.json
+++ b/homeassistant/components/ambient_station/.translations/de.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "Keine Ger\u00e4te im Konto gefunden"
},
diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json
index 8b8e71d5316..c3e2a40ab13 100644
--- a/homeassistant/components/ambient_station/.translations/en.json
+++ b/homeassistant/components/ambient_station/.translations/en.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "No devices found in account"
},
diff --git a/homeassistant/components/ambient_station/.translations/es-419.json b/homeassistant/components/ambient_station/.translations/es-419.json
index 268a6ba001e..4cca42afbf4 100644
--- a/homeassistant/components/ambient_station/.translations/es-419.json
+++ b/homeassistant/components/ambient_station/.translations/es-419.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Clave de aplicaci\u00f3n y/o clave de API ya registrada",
"invalid_key": "Clave de API y/o clave de aplicaci\u00f3n no v\u00e1lida",
"no_devices": "No se han encontrado dispositivos en la cuenta."
},
diff --git a/homeassistant/components/ambient_station/.translations/es.json b/homeassistant/components/ambient_station/.translations/es.json
index d575db2ba71..ae8b829d56e 100644
--- a/homeassistant/components/ambient_station/.translations/es.json
+++ b/homeassistant/components/ambient_station/.translations/es.json
@@ -4,7 +4,6 @@
"already_configured": "Esta clave API ya est\u00e1 en uso."
},
"error": {
- "identifier_exists": "La clave API y/o la clave de aplicaci\u00f3n ya est\u00e1 registrada",
"invalid_key": "Clave API y/o clave de aplicaci\u00f3n no v\u00e1lida",
"no_devices": "No se han encontrado dispositivos en la cuenta"
},
diff --git a/homeassistant/components/ambient_station/.translations/fr.json b/homeassistant/components/ambient_station/.translations/fr.json
index b28cb374eac..34490332c12 100644
--- a/homeassistant/components/ambient_station/.translations/fr.json
+++ b/homeassistant/components/ambient_station/.translations/fr.json
@@ -1,7 +1,9 @@
{
"config": {
+ "abort": {
+ "already_configured": "Cette cl\u00e9 d'application est d\u00e9j\u00e0 utilis\u00e9e."
+ },
"error": {
- "identifier_exists": "Cl\u00e9 d'application et / ou cl\u00e9 API d\u00e9j\u00e0 enregistr\u00e9e",
"invalid_key": "Cl\u00e9 d'API et / ou cl\u00e9 d'application non valide",
"no_devices": "Aucun appareil trouv\u00e9 dans le compte"
},
diff --git a/homeassistant/components/ambient_station/.translations/hu.json b/homeassistant/components/ambient_station/.translations/hu.json
index 222b512c39f..6febc6ec20d 100644
--- a/homeassistant/components/ambient_station/.translations/hu.json
+++ b/homeassistant/components/ambient_station/.translations/hu.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Alkalmaz\u00e1s kulcsot \u00e9s/vagy az API kulcsot m\u00e1r regisztr\u00e1lt\u00e1k",
"invalid_key": "\u00c9rv\u00e9nytelen API kulcs \u00e9s / vagy alkalmaz\u00e1skulcs",
"no_devices": "Nincs a fi\u00f3kodban tal\u00e1lhat\u00f3 eszk\u00f6z"
},
diff --git a/homeassistant/components/ambient_station/.translations/it.json b/homeassistant/components/ambient_station/.translations/it.json
index 6bfaaac8f01..e5c27bd3939 100644
--- a/homeassistant/components/ambient_station/.translations/it.json
+++ b/homeassistant/components/ambient_station/.translations/it.json
@@ -4,7 +4,6 @@
"already_configured": "Questa chiave dell'app \u00e8 gi\u00e0 in uso."
},
"error": {
- "identifier_exists": "API Key e/o Application Key gi\u00e0 registrata",
"invalid_key": "API Key e/o Application Key non valida",
"no_devices": "Nessun dispositivo trovato nell'account"
},
diff --git a/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant/components/ambient_station/.translations/ko.json
index 3379411678b..2aa38688957 100644
--- a/homeassistant/components/ambient_station/.translations/ko.json
+++ b/homeassistant/components/ambient_station/.translations/ko.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4"
},
diff --git a/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant/components/ambient_station/.translations/lb.json
index 891051bae00..1c6f9224c57 100644
--- a/homeassistant/components/ambient_station/.translations/lb.json
+++ b/homeassistant/components/ambient_station/.translations/lb.json
@@ -4,7 +4,6 @@
"already_configured": "D\u00ebsen App Schl\u00ebssel g\u00ebtt scho benotzt"
},
"error": {
- "identifier_exists": "Applikatioun's Schl\u00ebssel an/oder API Schl\u00ebssel ass scho registr\u00e9iert",
"invalid_key": "Ong\u00ebltegen API Schl\u00ebssel an/oder Applikatioun's Schl\u00ebssel",
"no_devices": "Keng Apparater am Kont fonnt"
},
diff --git a/homeassistant/components/ambient_station/.translations/nl.json b/homeassistant/components/ambient_station/.translations/nl.json
index a070128eefe..bc8f90057e3 100644
--- a/homeassistant/components/ambient_station/.translations/nl.json
+++ b/homeassistant/components/ambient_station/.translations/nl.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Applicatiesleutel en/of API-sleutel al geregistreerd",
"invalid_key": "Ongeldige API-sleutel en/of applicatiesleutel",
"no_devices": "Geen apparaten gevonden in account"
},
diff --git a/homeassistant/components/ambient_station/.translations/no.json b/homeassistant/components/ambient_station/.translations/no.json
index 4a089eba4c0..b69081286ed 100644
--- a/homeassistant/components/ambient_station/.translations/no.json
+++ b/homeassistant/components/ambient_station/.translations/no.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "Ingen enheter funnet i kontoen"
},
@@ -17,6 +16,6 @@
"title": "Fyll ut informasjonen din"
}
},
- "title": "Ambient PWS"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/pl.json b/homeassistant/components/ambient_station/.translations/pl.json
index 5da886f05cd..45d98e64dbb 100644
--- a/homeassistant/components/ambient_station/.translations/pl.json
+++ b/homeassistant/components/ambient_station/.translations/pl.json
@@ -4,7 +4,6 @@
"already_configured": "Ten klucz aplikacji jest ju\u017c w u\u017cyciu."
},
"error": {
- "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/pt-BR.json b/homeassistant/components/ambient_station/.translations/pt-BR.json
index 61f5cea5e26..533d46ca8b7 100644
--- a/homeassistant/components/ambient_station/.translations/pt-BR.json
+++ b/homeassistant/components/ambient_station/.translations/pt-BR.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Chave de aplicativo e / ou chave de API j\u00e1 registrada",
"invalid_key": "Chave de API e / ou chave de aplicativo inv\u00e1lidas",
"no_devices": "Nenhum dispositivo encontrado na conta"
},
diff --git a/homeassistant/components/ambient_station/.translations/pt.json b/homeassistant/components/ambient_station/.translations/pt.json
index 92746b29f3d..61d8bf3ae1c 100644
--- a/homeassistant/components/ambient_station/.translations/pt.json
+++ b/homeassistant/components/ambient_station/.translations/pt.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Chave de aplica\u00e7\u00e3o e/ou chave de API j\u00e1 registradas.",
"invalid_key": "Chave de API e/ou chave de aplica\u00e7\u00e3o inv\u00e1lidas",
"no_devices": "Nenhum dispositivo encontrado na conta"
},
diff --git a/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant/components/ambient_station/.translations/ru.json
index 07f3907eea1..e1f01d1567f 100644
--- a/homeassistant/components/ambient_station/.translations/ru.json
+++ b/homeassistant/components/ambient_station/.translations/ru.json
@@ -4,7 +4,6 @@
"already_configured": "\u042d\u0442\u043e\u0442 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f."
},
"error": {
- "identifier_exists": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d.",
"invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.",
"no_devices": "\u0412 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b."
},
diff --git a/homeassistant/components/ambient_station/.translations/sl.json b/homeassistant/components/ambient_station/.translations/sl.json
index 906a6b404c4..4f9389e7e49 100644
--- a/homeassistant/components/ambient_station/.translations/sl.json
+++ b/homeassistant/components/ambient_station/.translations/sl.json
@@ -1,7 +1,9 @@
{
"config": {
+ "abort": {
+ "already_configured": "Ta klju\u010d za aplikacijo je \u017ee v uporabi."
+ },
"error": {
- "identifier_exists": "Aplikacijski klju\u010d in / ali klju\u010d API je \u017ee registriran",
"invalid_key": "Neveljaven klju\u010d API in / ali klju\u010d aplikacije",
"no_devices": "V ra\u010dunu ni najdene nobene naprave"
},
diff --git a/homeassistant/components/ambient_station/.translations/sv.json b/homeassistant/components/ambient_station/.translations/sv.json
index c429d439503..2f68fe4332d 100644
--- a/homeassistant/components/ambient_station/.translations/sv.json
+++ b/homeassistant/components/ambient_station/.translations/sv.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Applikationsnyckel och/eller API-nyckel \u00e4r redan registrerade",
"invalid_key": "Ogiltigt API-nyckel och/eller applikationsnyckel",
"no_devices": "Inga enheter hittades i kontot"
},
diff --git a/homeassistant/components/ambient_station/.translations/zh-Hans.json b/homeassistant/components/ambient_station/.translations/zh-Hans.json
index 866c06316f1..dc6f2d51ee9 100644
--- a/homeassistant/components/ambient_station/.translations/zh-Hans.json
+++ b/homeassistant/components/ambient_station/.translations/zh-Hans.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Application Key \u548c/\u6216 API Key \u5df2\u6ce8\u518c",
"invalid_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5\u548c/\u6216 Application Key",
"no_devices": "\u6ca1\u6709\u5728\u5e10\u6237\u4e2d\u627e\u5230\u8bbe\u5907"
},
diff --git a/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant/components/ambient_station/.translations/zh-Hant.json
index 6de1579f6ff..fdc7b87aa6b 100644
--- a/homeassistant/components/ambient_station/.translations/zh-Hant.json
+++ b/homeassistant/components/ambient_station/.translations/zh-Hant.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u8a2d\u5099"
},
diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py
index 4fd6590b286..f3f2397d214 100644
--- a/homeassistant/components/ambient_station/__init__.py
+++ b/homeassistant/components/ambient_station/__init__.py
@@ -41,7 +41,6 @@ _LOGGER = logging.getLogger(__name__)
DATA_CONFIG = "config"
DEFAULT_SOCKET_MIN_RETRY = 15
-DEFAULT_WATCHDOG_SECONDS = 5 * 60
TYPE_24HOURRAININ = "24hourrainin"
TYPE_BAROMABSIN = "baromabsin"
@@ -342,7 +341,6 @@ class AmbientStation:
self._config_entry = config_entry
self._entry_setup_complete = False
self._hass = hass
- self._watchdog_listener = None
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
self.client = client
self.stations = {}
@@ -359,21 +357,9 @@ class AmbientStation:
async def ws_connect(self):
"""Register handlers and connect to the websocket."""
- async def _ws_reconnect(event_time):
- """Forcibly disconnect from and reconnect to the websocket."""
- _LOGGER.debug("Watchdog expired; forcing socket reconnection")
- await self.client.websocket.disconnect()
- await self._attempt_connect()
-
def on_connect():
"""Define a handler to fire when the websocket is connected."""
_LOGGER.info("Connected to websocket")
- _LOGGER.debug("Watchdog starting")
- if self._watchdog_listener is not None:
- self._watchdog_listener()
- self._watchdog_listener = async_call_later(
- self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect
- )
def on_data(data):
"""Define a handler to fire when the data is received."""
@@ -385,12 +371,6 @@ class AmbientStation:
self._hass, f"ambient_station_data_update_{mac_address}"
)
- _LOGGER.debug("Resetting watchdog")
- self._watchdog_listener()
- self._watchdog_listener = async_call_later(
- self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect
- )
-
def on_disconnect():
"""Define a handler to fire when the websocket is disconnected."""
_LOGGER.info("Disconnected from websocket")
@@ -520,13 +500,22 @@ class AmbientWeatherEntity(Entity):
@callback
def update():
"""Update the state."""
- self.async_schedule_update_ha_state(True)
+ self.update_from_latest_data()
+ self.async_write_ha_state()
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, f"ambient_station_data_update_{self._mac_address}", update
)
+ self.update_from_latest_data()
+
async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
+ self._async_unsub_dispatcher_connect = None
+
+ @callback
+ def update_from_latest_data(self):
+ """Update the entity from the latest data."""
+ raise NotImplementedError
diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py
index e4c1c8ccdac..d1b1f9b8f1d 100644
--- a/homeassistant/components/ambient_station/binary_sensor.py
+++ b/homeassistant/components/ambient_station/binary_sensor.py
@@ -3,6 +3,7 @@ import logging
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import ATTR_NAME
+from homeassistant.core import callback
from . import (
SENSOR_TYPES,
@@ -76,7 +77,8 @@ class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorDevice):
return self._state == 1
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Fetch new state data for the entity."""
self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get(
self._sensor_type
diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json
index a6572070a5e..e73190bb580 100644
--- a/homeassistant/components/ambient_station/manifest.json
+++ b/homeassistant/components/ambient_station/manifest.json
@@ -3,7 +3,6 @@
"name": "Ambient Weather Station",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ambient_station",
- "requirements": ["aioambient==1.0.4"],
- "dependencies": [],
+ "requirements": ["aioambient==1.1.1"],
"codeowners": ["@bachya"]
}
diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py
index c400d2ec97b..b3b76715368 100644
--- a/homeassistant/components/ambient_station/sensor.py
+++ b/homeassistant/components/ambient_station/sensor.py
@@ -2,6 +2,7 @@
import logging
from homeassistant.const import ATTR_NAME
+from homeassistant.core import callback
from . import (
SENSOR_TYPES,
@@ -74,7 +75,8 @@ class AmbientWeatherSensor(AmbientWeatherEntity):
"""Return the unit of measurement."""
return self._unit
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Fetch new state data for the sensor."""
if self._sensor_type == TYPE_SOLARRADIATION_LX:
# If the user requests the solarradiation_lx sensor, use the
diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py
index b4b3e1866b4..be2a6b78f30 100644
--- a/homeassistant/components/amcrest/__init__.py
+++ b/homeassistant/components/amcrest/__init__.py
@@ -42,6 +42,8 @@ from .const import (
DATA_AMCREST,
DEVICES,
DOMAIN,
+ SENSOR_EVENT_CODE,
+ SERVICE_EVENT,
SERVICE_UPDATE,
)
from .helpers import service_signal
@@ -96,9 +98,11 @@ AMCREST_SCHEMA = vol.Schema(
vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period,
vol.Optional(CONF_BINARY_SENSORS): vol.All(
- cv.ensure_list, [vol.In(BINARY_SENSORS)]
+ cv.ensure_list, [vol.In(BINARY_SENSORS)], vol.Unique()
+ ),
+ vol.Optional(CONF_SENSORS): vol.All(
+ cv.ensure_list, [vol.In(SENSORS)], vol.Unique()
),
- vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]),
vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean,
}
)
@@ -119,6 +123,8 @@ class AmcrestChecker(Http):
self._wrap_errors = 0
self._wrap_lock = threading.Lock()
self._wrap_login_err = False
+ self._wrap_event_flag = threading.Event()
+ self._wrap_event_flag.set()
self._unsub_recheck = None
super().__init__(
host,
@@ -134,16 +140,22 @@ class AmcrestChecker(Http):
"""Return if camera's API is responding."""
return self._wrap_errors <= MAX_ERRORS and not self._wrap_login_err
+ @property
+ def available_flag(self):
+ """Return threading event flag that indicates if camera's API is responding."""
+ return self._wrap_event_flag
+
def _start_recovery(self):
+ self._wrap_event_flag.clear()
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):
+ def command(self, *args, **kwargs):
"""amcrest.Http.command wrapper to catch errors."""
try:
- ret = super().command(cmd, retries, timeout_cmd, stream)
+ ret = super().command(*args, **kwargs)
except LoginError as ex:
with self._wrap_lock:
was_online = self.available
@@ -172,6 +184,7 @@ class AmcrestChecker(Http):
self._unsub_recheck()
self._unsub_recheck = None
_LOGGER.error("%s camera back online", self._wrap_name)
+ self._wrap_event_flag.set()
dispatcher_send(self._hass, service_signal(SERVICE_UPDATE, self._wrap_name))
return ret
@@ -184,6 +197,31 @@ class AmcrestChecker(Http):
pass
+def _monitor_events(hass, name, api, event_codes):
+ event_codes = ",".join(event_codes)
+ while True:
+ api.available_flag.wait()
+ try:
+ for code, start in api.event_actions(event_codes, retries=5):
+ signal = service_signal(SERVICE_EVENT, name, code)
+ _LOGGER.debug("Sending signal: '%s': %s", signal, start)
+ dispatcher_send(hass, signal, start)
+ except AmcrestError as error:
+ _LOGGER.warning(
+ "Error while processing events from %s camera: %r", name, error
+ )
+
+
+def _start_event_monitor(hass, name, api, event_codes):
+ thread = threading.Thread(
+ target=_monitor_events,
+ name=f"Amcrest {name}",
+ args=(hass, name, api, event_codes),
+ daemon=True,
+ )
+ thread.start()
+
+
def setup(hass, config):
"""Set up the Amcrest IP Camera component."""
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
@@ -230,6 +268,13 @@ def setup(hass, config):
{CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors},
config,
)
+ event_codes = [
+ BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE]
+ for sensor_type in binary_sensors
+ if BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] is not None
+ ]
+ if event_codes:
+ _start_event_monitor(hass, name, api, event_codes)
if sensors:
discovery.load_platform(
diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py
index 809b448876c..40cb755bd98 100644
--- a/homeassistant/components/amcrest/binary_sensor.py
+++ b/homeassistant/components/amcrest/binary_sensor.py
@@ -10,12 +10,17 @@ from homeassistant.components.binary_sensor import (
BinarySensorDevice,
)
from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME
+from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
BINARY_SENSOR_SCAN_INTERVAL_SECS,
DATA_AMCREST,
DEVICES,
+ SENSOR_DEVICE_CLASS,
+ SENSOR_EVENT_CODE,
+ SENSOR_NAME,
+ SERVICE_EVENT,
SERVICE_UPDATE,
)
from .helpers import log_update_error, service_signal
@@ -26,11 +31,20 @@ SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS)
BINARY_SENSOR_MOTION_DETECTED = "motion_detected"
BINARY_SENSOR_ONLINE = "online"
-# Binary sensor types are defined like: Name, device class
BINARY_SENSORS = {
- BINARY_SENSOR_MOTION_DETECTED: ("Motion Detected", DEVICE_CLASS_MOTION),
- BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY),
+ BINARY_SENSOR_MOTION_DETECTED: (
+ "Motion Detected",
+ DEVICE_CLASS_MOTION,
+ "VideoMotion",
+ ),
+ BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY, None),
}
+BINARY_SENSORS = {
+ k: dict(zip((SENSOR_NAME, SENSOR_DEVICE_CLASS, SENSOR_EVENT_CODE), v))
+ for k, v in BINARY_SENSORS.items()
+}
+
+_UPDATE_MSG = "Updating %s binary sensor"
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
@@ -54,18 +68,19 @@ class AmcrestBinarySensor(BinarySensorDevice):
def __init__(self, name, device, sensor_type):
"""Initialize entity."""
- self._name = f"{name} {BINARY_SENSORS[sensor_type][0]}"
+ self._name = f"{name} {BINARY_SENSORS[sensor_type][SENSOR_NAME]}"
self._signal_name = name
self._api = device.api
self._sensor_type = sensor_type
self._state = None
- self._device_class = BINARY_SENSORS[sensor_type][1]
- self._unsub_dispatcher = None
+ self._device_class = BINARY_SENSORS[sensor_type][SENSOR_DEVICE_CLASS]
+ self._event_code = BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE]
+ self._unsub_dispatcher = []
@property
def should_poll(self):
"""Return True if entity has to be polled for state."""
- return self._sensor_type != BINARY_SENSOR_ONLINE
+ return self._sensor_type == BINARY_SENSOR_ONLINE
@property
def name(self):
@@ -89,16 +104,34 @@ class AmcrestBinarySensor(BinarySensorDevice):
def update(self):
"""Update entity."""
+ if self._sensor_type == BINARY_SENSOR_ONLINE:
+ self._update_online()
+ else:
+ self._update_others()
+
+ def _update_online(self):
+ if not (self._api.available or self.is_on):
+ return
+ _LOGGER.debug(_UPDATE_MSG, self._name)
+ if self._api.available:
+ # Send a command to the camera to test if we can still communicate with it.
+ # Override of Http.command() in __init__.py will set self._api.available
+ # accordingly.
+ try:
+ self._api.current_time
+ except AmcrestError:
+ pass
+ self._state = self._api.available
+
+ def _update_others(self):
if not self.available:
return
- _LOGGER.debug("Updating %s binary sensor", self._name)
+ _LOGGER.debug(_UPDATE_MSG, self._name)
try:
- if self._sensor_type == BINARY_SENSOR_MOTION_DETECTED:
- self._state = self._api.is_motion_detected
-
- elif self._sensor_type == BINARY_SENSOR_ONLINE:
- self._state = self._api.available
+ self._state = "channels" in self._api.event_channels_happened(
+ self._event_code
+ )
except AmcrestError as error:
log_update_error(_LOGGER, "update", self.name, "binary sensor", error)
@@ -106,14 +139,32 @@ class AmcrestBinarySensor(BinarySensorDevice):
"""Update state."""
self.async_schedule_update_ha_state(True)
+ @callback
+ def async_event_received(self, start):
+ """Update state from received event."""
+ _LOGGER.debug(_UPDATE_MSG, self._name)
+ self._state = start
+ self.async_write_ha_state()
+
async def async_added_to_hass(self):
- """Subscribe to update signal."""
- self._unsub_dispatcher = async_dispatcher_connect(
- self.hass,
- service_signal(SERVICE_UPDATE, self._signal_name),
- self.async_on_demand_update,
+ """Subscribe to signals."""
+ self._unsub_dispatcher.append(
+ async_dispatcher_connect(
+ self.hass,
+ service_signal(SERVICE_UPDATE, self._signal_name),
+ self.async_on_demand_update,
+ )
)
+ if self._event_code:
+ self._unsub_dispatcher.append(
+ async_dispatcher_connect(
+ self.hass,
+ service_signal(SERVICE_EVENT, self._signal_name, self._event_code),
+ self.async_event_received,
+ )
+ )
async def async_will_remove_from_hass(self):
"""Disconnect from update signal."""
- self._unsub_dispatcher()
+ for unsub_dispatcher in self._unsub_dispatcher:
+ unsub_dispatcher()
diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py
index f9515256403..4b3640c1543 100644
--- a/homeassistant/components/amcrest/camera.py
+++ b/homeassistant/components/amcrest/camera.py
@@ -21,6 +21,7 @@ from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_web,
async_get_clientsession,
)
+import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
@@ -51,6 +52,28 @@ _SRV_CBW = "set_color_bw"
_SRV_TOUR_ON = "start_tour"
_SRV_TOUR_OFF = "stop_tour"
+_SRV_PTZ_CTRL = "ptz_control"
+_ATTR_PTZ_TT = "travel_time"
+_ATTR_PTZ_MOV = "movement"
+_MOV = [
+ "zoom_out",
+ "zoom_in",
+ "right",
+ "left",
+ "up",
+ "down",
+ "right_down",
+ "right_up",
+ "left_down",
+ "left_up",
+]
+_ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"]
+_MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"]
+_MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"]
+_ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS
+
+_DEFAULT_TT = 0.2
+
_ATTR_PRESET = "preset"
_ATTR_COLOR_BW = "color_bw"
@@ -65,6 +88,12 @@ _SRV_GOTO_SCHEMA = CAMERA_SERVICE_SCHEMA.extend(
_SRV_CBW_SCHEMA = CAMERA_SERVICE_SCHEMA.extend(
{vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)}
)
+_SRV_PTZ_SCHEMA = CAMERA_SERVICE_SCHEMA.extend(
+ {
+ vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV),
+ vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
+ }
+)
CAMERA_SERVICES = {
_SRV_EN_REC: (CAMERA_SERVICE_SCHEMA, "async_enable_recording", ()),
@@ -77,6 +106,11 @@ CAMERA_SERVICES = {
_SRV_CBW: (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
_SRV_TOUR_ON: (CAMERA_SERVICE_SCHEMA, "async_start_tour", ()),
_SRV_TOUR_OFF: (CAMERA_SERVICE_SCHEMA, "async_stop_tour", ()),
+ _SRV_PTZ_CTRL: (
+ _SRV_PTZ_SCHEMA,
+ "async_ptz_control",
+ (_ATTR_PTZ_MOV, _ATTR_PTZ_TT),
+ ),
}
_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
@@ -406,6 +440,29 @@ class AmcrestCam(Camera):
"""Call the job and stop camera tour."""
await self.hass.async_add_executor_job(self._start_tour, False)
+ async def async_ptz_control(self, movement, travel_time):
+ """Move or zoom camera in specified direction."""
+ code = _ACTION[_MOV.index(movement)]
+
+ kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0}
+ if code in _MOVE_1_ACTIONS:
+ kwargs["arg2"] = 1
+ elif code in _MOVE_2_ACTIONS:
+ kwargs["arg1"] = kwargs["arg2"] = 1
+
+ try:
+ await self.hass.async_add_executor_job(
+ partial(self._api.ptz_control_command, action="start", **kwargs)
+ )
+ await asyncio.sleep(travel_time)
+ await self.hass.async_add_executor_job(
+ partial(self._api.ptz_control_command, action="stop", **kwargs)
+ )
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, "move", self.name, f"camera PTZ {movement}", error
+ )
+
# Methods to send commands to Amcrest camera and handle errors
def _enable_video_stream(self, enable):
diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py
index 38ff8a8894e..da7e5456786 100644
--- a/homeassistant/components/amcrest/const.py
+++ b/homeassistant/components/amcrest/const.py
@@ -4,11 +4,16 @@ DATA_AMCREST = DOMAIN
CAMERAS = "cameras"
DEVICES = "devices"
-BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
+BINARY_SENSOR_SCAN_INTERVAL_SECS = 60
CAMERA_WEB_SESSION_TIMEOUT = 10
COMM_RETRIES = 1
COMM_TIMEOUT = 6.05
SENSOR_SCAN_INTERVAL_SECS = 10
SNAPSHOT_TIMEOUT = 20
+SERVICE_EVENT = "event"
SERVICE_UPDATE = "update"
+
+SENSOR_DEVICE_CLASS = "class"
+SENSOR_EVENT_CODE = "code"
+SENSOR_NAME = "name"
diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py
index 57d1a73c97e..884d39abd70 100644
--- a/homeassistant/components/amcrest/helpers.py
+++ b/homeassistant/components/amcrest/helpers.py
@@ -2,12 +2,9 @@
from .const import DOMAIN
-def service_signal(service, ident=None):
- """Encode service and identifier into signal."""
- signal = f"{DOMAIN}_{service}"
- if ident:
- signal += f"_{ident.replace('.', '_')}"
- return signal
+def service_signal(service, *args):
+ """Encode signal."""
+ return "_".join([DOMAIN, service, *args])
def log_update_error(logger, action, name, entity_type, error):
diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json
index 38e19e4ec26..0b6fbbdc09a 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.6"],
+ "requirements": ["amcrest==1.7.0"],
"dependencies": ["ffmpeg"],
"codeowners": ["@pnbruckner"]
}
diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml
index d6e7a02a4f9..820f965c533 100644
--- a/homeassistant/components/amcrest/services.yaml
+++ b/homeassistant/components/amcrest/services.yaml
@@ -73,3 +73,16 @@ stop_tour:
entity_id:
description: "Name(s) of the cameras, or 'all' for all cameras."
example: 'camera.house_front'
+
+ptz_control:
+ description: Move (Pan/Tilt) and/or Zoom a PTZ camera
+ fields:
+ entity_id:
+ description: "Name of the camera, or 'all' for all cameras."
+ example: 'camera.house_front'
+ movement:
+ description: "up, down, right, left, right_up, right_down, left_up, left_down, zoom_in, zoom_out"
+ example: 'right'
+ travel_time:
+ description: "(optional) Travel time in fractional seconds: from 0 to 1. Default: .2"
+ example: '.5'
diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py
index 93666958919..f9ec68c8742 100644
--- a/homeassistant/components/androidtv/media_player.py
+++ b/homeassistant/components/androidtv/media_player.py
@@ -1,4 +1,6 @@
"""Support for functionality to interact with Android TV / Fire TV devices."""
+import binascii
+from datetime import datetime
import functools
import logging
import os
@@ -475,6 +477,34 @@ class ADBDevice(MediaPlayerDevice):
"""Return the device unique id."""
return self._unique_id
+ async def async_get_media_image(self):
+ """Fetch current playing image."""
+ if self.state in [STATE_OFF, None] or not self.available:
+ return None, None
+
+ media_data = await self.hass.async_add_executor_job(self.get_raw_media_data)
+ if media_data:
+ return media_data, "image/png"
+ return None, None
+
+ @adb_decorator()
+ def get_raw_media_data(self):
+ """Raw base64 image data."""
+ try:
+ response = self.aftv.adb_shell("screencap -p | base64")
+ except UnicodeDecodeError:
+ return None
+
+ if isinstance(response, str) and response.strip():
+ return binascii.a2b_base64(response.strip().replace("\n", ""))
+
+ return None
+
+ @property
+ def media_image_hash(self):
+ """Hash value for media image."""
+ return f"{datetime.now().timestamp()}"
+
@adb_decorator()
def media_play(self):
"""Send play command."""
diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json
index 0895c2af1f9..ba934b804d7 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.4"],
+ "requirements": ["apprise==0.8.5"],
"dependencies": [],
"codeowners": ["@caronc"]
}
diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py
index a0afbed69f1..446fe898aaa 100644
--- a/homeassistant/components/asuswrt/__init__.py
+++ b/homeassistant/components/asuswrt/__init__.py
@@ -14,6 +14,7 @@ from homeassistant.const import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.event import async_call_later
_LOGGER = logging.getLogger(__name__)
@@ -31,6 +32,9 @@ DEFAULT_SSH_PORT = 22
DEFAULT_INTERFACE = "eth0"
DEFAULT_DNSMASQ = "/var/lib/misc"
+FIRST_RETRY_TIME = 60
+MAX_RETRY_TIME = 900
+
SECRET_GROUP = "Password or SSH Key"
SENSOR_TYPES = ["upload_speed", "download_speed", "download", "upload"]
@@ -51,7 +55,7 @@ CONFIG_SCHEMA = vol.Schema(
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,
+ vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.string,
}
)
},
@@ -59,7 +63,7 @@ CONFIG_SCHEMA = vol.Schema(
)
-async def async_setup(hass, config):
+async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME):
"""Set up the asuswrt component."""
conf = config[DOMAIN]
@@ -77,9 +81,29 @@ async def async_setup(hass, config):
dnsmasq=conf[CONF_DNSMASQ],
)
- await api.connection.async_connect()
+ try:
+ await api.connection.async_connect()
+ except OSError as ex:
+ _LOGGER.warning(
+ "Error [%s] connecting %s to %s. Will retry in %s seconds...",
+ str(ex),
+ DOMAIN,
+ conf[CONF_HOST],
+ retry_delay,
+ )
+
+ async def retry_setup(now):
+ """Retry setup if a error happens on asuswrt API."""
+ await async_setup(
+ hass, config, retry_delay=min(2 * retry_delay, MAX_RETRY_TIME)
+ )
+
+ async_call_later(hass, retry_delay, retry_setup)
+
+ return True
+
if not api.is_connected:
- _LOGGER.error("Unable to setup component")
+ _LOGGER.error("Error connecting %s to %s.", DOMAIN, conf[CONF_HOST])
return False
hass.data[DATA_ASUSWRT] = api
diff --git a/homeassistant/components/august/.translations/de.json b/homeassistant/components/august/.translations/de.json
index dd3b2ea9f44..8d34eaaf5ee 100644
--- a/homeassistant/components/august/.translations/de.json
+++ b/homeassistant/components/august/.translations/de.json
@@ -16,6 +16,7 @@
"timeout": "Zeit\u00fcberschreitung (Sekunden)",
"username": "Benutzername"
},
+ "description": "Wenn die Anmeldemethode \"E-Mail\" lautet, ist Benutzername die E-Mail-Adresse. Wenn die Anmeldemethode \"Telefon\" ist, ist Benutzername die Telefonnummer im Format \"+ NNNNNNNNN\".",
"title": "Richten Sie ein August-Konto ein"
},
"validation": {
diff --git a/homeassistant/components/august/.translations/fr.json b/homeassistant/components/august/.translations/fr.json
new file mode 100644
index 00000000000..89a35b28f1d
--- /dev/null
+++ b/homeassistant/components/august/.translations/fr.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification non valide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "login_method": "M\u00e9thode de connexion",
+ "password": "Mot de passe",
+ "timeout": "D\u00e9lai d'expiration (secondes)",
+ "username": "Nom d'utilisateur"
+ }
+ },
+ "validation": {
+ "data": {
+ "code": "Code de v\u00e9rification"
+ },
+ "title": "Authentification \u00e0 deux facteurs"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/ko.json b/homeassistant/components/august/.translations/ko.json
new file mode 100644
index 00000000000..018bb9d6a56
--- /dev/null
+++ b/homeassistant/components/august/.translations/ko.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \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": {
+ "login_method": "\ub85c\uadf8\uc778 \ubc29\ubc95",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "timeout": "\uc81c\ud55c \uc2dc\uac04 (\ucd08)",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "description": "\ub85c\uadf8\uc778 \ubc29\ubc95\uc774 '\uc774\uba54\uc77c'\uc778 \uacbd\uc6b0, \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 \uc774\uba54\uc77c \uc8fc\uc18c\uc785\ub2c8\ub2e4. \ub85c\uadf8\uc778 \ubc29\ubc95\uc774 'phone'\uc778 \uacbd\uc6b0, \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 '+NNNNNNNNN' \ud615\uc2dd\uc758 \uc804\ud654\ubc88\ud638\uc785\ub2c8\ub2e4.",
+ "title": "August \uacc4\uc815 \uc124\uc815"
+ },
+ "validation": {
+ "data": {
+ "code": "\uc778\uc99d \ucf54\ub4dc"
+ },
+ "description": "{login_method} ({username}) \uc744(\ub97c) \ud655\uc778\ud558\uace0 \uc544\ub798\uc5d0 \uc778\uc99d \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694",
+ "title": "2\ub2e8\uacc4 \uc778\uc99d"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/no.json b/homeassistant/components/august/.translations/no.json
index 61193656b51..449989bade1 100644
--- a/homeassistant/components/august/.translations/no.json
+++ b/homeassistant/components/august/.translations/no.json
@@ -23,10 +23,10 @@
"data": {
"code": "Bekreftelseskode"
},
- "description": "Kontroller {login_method} ( {username} ) og skriv inn bekreftelseskoden nedenfor",
+ "description": "Kontroller {login_method} ({username}) og skriv inn bekreftelseskoden nedenfor",
"title": "To-faktor autentisering"
}
},
- "title": "August"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/pl.json b/homeassistant/components/august/.translations/pl.json
new file mode 100644
index 00000000000..70654e12566
--- /dev/null
+++ b/homeassistant/components/august/.translations/pl.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Konto jest ju\u017c skonfigurowane."
+ },
+ "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": {
+ "login_method": "Metoda logowania",
+ "password": "Has\u0142o",
+ "username": "Nazwa u\u017cytkownika"
+ }
+ },
+ "validation": {
+ "data": {
+ "code": "Kod weryfikacyjny"
+ },
+ "title": "Uwierzytelnianie dwusk\u0142adnikowe"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/sl.json b/homeassistant/components/august/.translations/sl.json
new file mode 100644
index 00000000000..d0497278fee
--- /dev/null
+++ b/homeassistant/components/august/.translations/sl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ra\u010dun je \u017ee nastavljen"
+ },
+ "error": {
+ "cannot_connect": "Povezava ni uspela, poskusite znova",
+ "invalid_auth": "Neveljavna avtentikacija",
+ "unknown": "Nepri\u010dakovana napaka"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "login_method": "Na\u010din prijave",
+ "password": "Geslo",
+ "timeout": "\u010casovna omejitev (sekunde)",
+ "username": "Uporabni\u0161ko ime"
+ },
+ "description": "\u010ce je metoda za prijavo 'e-po\u0161ta', je e-po\u0161tni naslov uporabni\u0161ko ime. V kolikor je na\u010din prijave \"telefon\", je uporabni\u0161ko ime telefonska \u0161tevilka v obliki \" +NNNNNNNNN\".",
+ "title": "Nastavite ra\u010dun August"
+ },
+ "validation": {
+ "data": {
+ "code": "Koda za preverjanje"
+ },
+ "description": "Preverite svoj {login_method} ({username}) in spodaj vnesite verifikacijsko kodo",
+ "title": "Dvofaktorska avtentikacija"
+ }
+ },
+ "title": "Avgust"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py
index fc3fff47514..29aea64c9c5 100644
--- a/homeassistant/components/automation/state.py
+++ b/homeassistant/components/automation/state.py
@@ -6,10 +6,14 @@ from typing import Dict
import voluptuous as vol
from homeassistant import exceptions
-from homeassistant.const import CONF_FOR, CONF_PLATFORM, MATCH_ALL
+from homeassistant.const import CONF_FOR, CONF_PLATFORM, EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, template
-from homeassistant.helpers.event import async_track_same_state, async_track_state_change
+from homeassistant.helpers.event import (
+ Event,
+ async_track_same_state,
+ process_state_match,
+)
# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs
# mypy: no-check-untyped-defs
@@ -56,10 +60,30 @@ async def async_attach_trigger(
match_all = from_state == MATCH_ALL and to_state == MATCH_ALL
unsub_track_same = {}
period: Dict[str, timedelta] = {}
+ match_from_state = process_state_match(from_state)
+ match_to_state = process_state_match(to_state)
@callback
- def state_automation_listener(entity, from_s, to_s):
+ def state_automation_listener(event: Event):
"""Listen for state changes and calls action."""
+ entity: str = event.data["entity_id"]
+ if entity not in entity_id:
+ return
+
+ from_s = event.data.get("old_state")
+ to_s = event.data.get("new_state")
+
+ if (
+ (from_s is not None and not match_from_state(from_s.state))
+ or (to_s is not None and not match_to_state(to_s.state))
+ or (
+ not match_all
+ and from_s is not None
+ and to_s is not None
+ and from_s.state == to_s.state
+ )
+ ):
+ return
@callback
def call_action():
@@ -75,7 +99,7 @@ async def async_attach_trigger(
"for": time_delta if not time_delta else period[entity],
}
},
- context=to_s.context,
+ context=event.context,
)
)
@@ -120,17 +144,16 @@ async def async_attach_trigger(
)
return
+ def _check_same_state(_, _2, new_st):
+ if new_st is None:
+ return False
+ return new_st.state == to_s.state
+
unsub_track_same[entity] = async_track_same_state(
- hass,
- period[entity],
- call_action,
- lambda _, _2, to_state: to_state.state == to_s.state,
- entity_ids=entity,
+ hass, period[entity], call_action, _check_same_state, entity_ids=entity,
)
- unsub = async_track_state_change(
- hass, entity_id, state_automation_listener, from_state, to_state
- )
+ unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, state_automation_listener)
@callback
def async_remove():
diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json
index 58f5c0e4ad2..b391af0e609 100644
--- a/homeassistant/components/axis/.translations/ca.json
+++ b/homeassistant/components/axis/.translations/ca.json
@@ -4,8 +4,7 @@
"already_configured": "El dispositiu ja est\u00e0 configurat",
"bad_config_file": "Dades incorrectes del fitxer de configuraci\u00f3",
"link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible",
- "not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis",
- "updated_configuration": "S'ha actualitzat la configuraci\u00f3 del dispositiu amb l'adre\u00e7a nova"
+ "not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis"
},
"error": {
"already_configured": "El dispositiu ja est\u00e0 configurat",
diff --git a/homeassistant/components/axis/.translations/da.json b/homeassistant/components/axis/.translations/da.json
index 355dbad83d5..21f33d120f7 100644
--- a/homeassistant/components/axis/.translations/da.json
+++ b/homeassistant/components/axis/.translations/da.json
@@ -4,8 +4,7 @@
"already_configured": "Enheden er allerede konfigureret",
"bad_config_file": "Forkerte data fra konfigurationsfilen",
"link_local_address": "Link lokale adresser underst\u00f8ttes ikke",
- "not_axis_device": "Fundet enhed ikke en Axis enhed",
- "updated_configuration": "Opdaterede enhedskonfiguration med ny v\u00e6rtsadresse"
+ "not_axis_device": "Fundet enhed ikke en Axis enhed"
},
"error": {
"already_configured": "Enheden er allerede konfigureret",
diff --git a/homeassistant/components/axis/.translations/de.json b/homeassistant/components/axis/.translations/de.json
index a92c948a2a7..f238b00e847 100644
--- a/homeassistant/components/axis/.translations/de.json
+++ b/homeassistant/components/axis/.translations/de.json
@@ -4,8 +4,7 @@
"already_configured": "Ger\u00e4t ist bereits konfiguriert",
"bad_config_file": "Fehlerhafte Daten aus der Konfigurationsdatei",
"link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt",
- "not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t",
- "updated_configuration": "Ger\u00e4tekonfiguration mit neuer Hostadresse aktualisiert"
+ "not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t"
},
"error": {
"already_configured": "Ger\u00e4t ist bereits konfiguriert",
diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json
index 1f00800422c..b56cb0c5b74 100644
--- a/homeassistant/components/axis/.translations/en.json
+++ b/homeassistant/components/axis/.translations/en.json
@@ -4,8 +4,7 @@
"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",
- "updated_configuration": "Updated device configuration with new host address"
+ "not_axis_device": "Discovered device not an Axis device"
},
"error": {
"already_configured": "Device is already configured",
diff --git a/homeassistant/components/axis/.translations/es.json b/homeassistant/components/axis/.translations/es.json
index 885e8f68913..3f7db674fdf 100644
--- a/homeassistant/components/axis/.translations/es.json
+++ b/homeassistant/components/axis/.translations/es.json
@@ -4,8 +4,7 @@
"already_configured": "El dispositivo ya est\u00e1 configurado",
"bad_config_file": "Datos err\u00f3neos en el archivo de configuraci\u00f3n",
"link_local_address": "Las direcciones de enlace locales no son compatibles",
- "not_axis_device": "El dispositivo descubierto no es un dispositivo de Axis",
- "updated_configuration": "Configuraci\u00f3n del dispositivo actualizada con la nueva direcci\u00f3n de host"
+ "not_axis_device": "El dispositivo descubierto no es un dispositivo de Axis"
},
"error": {
"already_configured": "El dispositivo ya est\u00e1 configurado",
diff --git a/homeassistant/components/axis/.translations/fr.json b/homeassistant/components/axis/.translations/fr.json
index 07cfbd46504..608e12d020a 100644
--- a/homeassistant/components/axis/.translations/fr.json
+++ b/homeassistant/components/axis/.translations/fr.json
@@ -4,8 +4,7 @@
"already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
"bad_config_file": "Mauvaises donn\u00e9es du fichier de configuration",
"link_local_address": "Les adresses locales ne sont pas prises en charge",
- "not_axis_device": "L'appareil d\u00e9couvert n'est pas un appareil Axis",
- "updated_configuration": "Mise \u00e0 jour de la configuration du dispositif avec la nouvelle adresse de l'h\u00f4te"
+ "not_axis_device": "L'appareil d\u00e9couvert n'est pas un appareil Axis"
},
"error": {
"already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/axis/.translations/hu.json
index 4f05087cad8..b6347e21744 100644
--- a/homeassistant/components/axis/.translations/hu.json
+++ b/homeassistant/components/axis/.translations/hu.json
@@ -1,8 +1,5 @@
{
"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",
diff --git a/homeassistant/components/axis/.translations/it.json b/homeassistant/components/axis/.translations/it.json
index 9e2eecf5747..3f303140c68 100644
--- a/homeassistant/components/axis/.translations/it.json
+++ b/homeassistant/components/axis/.translations/it.json
@@ -4,8 +4,7 @@
"already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
"bad_config_file": "Dati errati dal file di configurazione",
"link_local_address": "Gli indirizzi locali di collegamento non sono supportati",
- "not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis",
- "updated_configuration": "Configurazione del dispositivo aggiornata con nuovo indirizzo host"
+ "not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis"
},
"error": {
"already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json
index 3f1aa97f266..648bd3cfd7d 100644
--- a/homeassistant/components/axis/.translations/ko.json
+++ b/homeassistant/components/axis/.translations/ko.json
@@ -4,8 +4,7 @@
"already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\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"
+ "not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4"
},
"error": {
"already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
diff --git a/homeassistant/components/axis/.translations/lb.json b/homeassistant/components/axis/.translations/lb.json
index 589932cd68e..24ee0e24125 100644
--- a/homeassistant/components/axis/.translations/lb.json
+++ b/homeassistant/components/axis/.translations/lb.json
@@ -4,8 +4,7 @@
"already_configured": "Apparat ass scho konfigur\u00e9iert",
"bad_config_file": "Feelerhaft Donn\u00e9e\u00eb aus der Konfiguratioun's Datei",
"link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt",
- "not_axis_device": "Entdeckten Apparat ass keen Axis Apparat",
- "updated_configuration": "Konfiguratioun vum Apparat gouf mat der neier Adress aktualis\u00e9iert"
+ "not_axis_device": "Entdeckten Apparat ass keen Axis Apparat"
},
"error": {
"already_configured": "Apparat ass scho konfigur\u00e9iert",
diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json
index b512690e2a3..10fc8c02d66 100644
--- a/homeassistant/components/axis/.translations/nl.json
+++ b/homeassistant/components/axis/.translations/nl.json
@@ -4,8 +4,7 @@
"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",
- "updated_configuration": "Bijgewerkte apparaatconfiguratie met nieuw hostadres"
+ "not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat"
},
"error": {
"already_configured": "Apparaat is al geconfigureerd",
diff --git a/homeassistant/components/axis/.translations/no.json b/homeassistant/components/axis/.translations/no.json
index 32e1cc2fd40..1ad7a446cfa 100644
--- a/homeassistant/components/axis/.translations/no.json
+++ b/homeassistant/components/axis/.translations/no.json
@@ -4,8 +4,7 @@
"already_configured": "Enheten er allerede konfigurert",
"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"
+ "not_axis_device": "Oppdaget enhet ikke en Axis enhet"
},
"error": {
"already_configured": "Enheten er allerede konfigurert",
@@ -19,7 +18,7 @@
"data": {
"host": "Vert",
"password": "Passord",
- "port": "Port",
+ "port": "",
"username": "Brukernavn"
},
"title": "Sett opp Axis enhet"
diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json
index 9f7bb55147d..dd1a63039e2 100644
--- a/homeassistant/components/axis/.translations/pl.json
+++ b/homeassistant/components/axis/.translations/pl.json
@@ -4,8 +4,7 @@
"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"
+ "not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis"
},
"error": {
"already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.",
diff --git a/homeassistant/components/axis/.translations/pt-BR.json b/homeassistant/components/axis/.translations/pt-BR.json
index ceb6325af60..453c8fa3643 100644
--- a/homeassistant/components/axis/.translations/pt-BR.json
+++ b/homeassistant/components/axis/.translations/pt-BR.json
@@ -4,8 +4,7 @@
"already_configured": "O dispositivo j\u00e1 est\u00e1 configurado",
"bad_config_file": "Dados incorretos do arquivo de configura\u00e7\u00e3o",
"link_local_address": "Link de endere\u00e7os locais n\u00e3o s\u00e3o suportados",
- "not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis",
- "updated_configuration": "Configura\u00e7\u00e3o do dispositivo atualizada com novo endere\u00e7o de host"
+ "not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis"
},
"error": {
"already_configured": "O dispositivo j\u00e1 est\u00e1 configurado",
diff --git a/homeassistant/components/axis/.translations/ru.json b/homeassistant/components/axis/.translations/ru.json
index b0da189d20f..d9e3a40d304 100644
--- a/homeassistant/components/axis/.translations/ru.json
+++ b/homeassistant/components/axis/.translations/ru.json
@@ -4,8 +4,7 @@
"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.",
"bad_config_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438.",
"link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.",
- "not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis.",
- "updated_configuration": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d."
+ "not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis."
},
"error": {
"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.",
diff --git a/homeassistant/components/axis/.translations/sl.json b/homeassistant/components/axis/.translations/sl.json
index 43a352c4bc0..9d66831b91a 100644
--- a/homeassistant/components/axis/.translations/sl.json
+++ b/homeassistant/components/axis/.translations/sl.json
@@ -2,10 +2,9 @@
"config": {
"abort": {
"already_configured": "Naprava je \u017ee konfigurirana",
- "bad_config_file": "Napa\u010dni podatki iz konfiguracijske datoteke",
+ "bad_config_file": "Slabi podatki iz konfiguracijske datoteke",
"link_local_address": "Lokalni naslovi povezave niso podprti",
- "not_axis_device": "Odkrita naprava ni naprava Axis",
- "updated_configuration": "Posodobljena konfiguracija naprave z novim naslovom gostitelja"
+ "not_axis_device": "Odkrita naprava ni naprava Axis"
},
"error": {
"already_configured": "Naprava je \u017ee konfigurirana",
diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json
index 59761c7202f..76ceaf7cbd7 100644
--- a/homeassistant/components/axis/.translations/sv.json
+++ b/homeassistant/components/axis/.translations/sv.json
@@ -4,8 +4,7 @@
"already_configured": "Enheten \u00e4r redan konfigurerad",
"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",
- "updated_configuration": "Uppdaterad enhetskonfiguration med ny v\u00e4rdadress"
+ "not_axis_device": "Uppt\u00e4ckte enhet som inte \u00e4r en Axis enhet"
},
"error": {
"already_configured": "Enheten \u00e4r redan konfigurerad",
diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json
index ac552afe583..41ecfdb80b7 100644
--- a/homeassistant/components/axis/.translations/zh-Hant.json
+++ b/homeassistant/components/axis/.translations/zh-Hant.json
@@ -4,8 +4,7 @@
"already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
"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"
+ "not_axis_device": "\u6240\u767c\u73fe\u7684\u8a2d\u5099\u4e26\u975e Axis \u8a2d\u5099"
},
"error": {
"already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py
index 29658c19c5b..37141d6017a 100644
--- a/homeassistant/components/axis/config_flow.py
+++ b/homeassistant/components/axis/config_flow.py
@@ -1,5 +1,7 @@
"""Config flow to configure Axis devices."""
+from ipaddress import ip_address
+
import voluptuous as vol
from homeassistant import config_entries
@@ -11,6 +13,7 @@ from homeassistant.const import (
CONF_PORT,
CONF_USERNAME,
)
+from homeassistant.util.network import is_link_local
from .const import CONF_MODEL, DOMAIN
from .device import get_device
@@ -129,7 +132,7 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if serial_number[:6] not in AXIS_OUI:
return self.async_abort(reason="not_axis_device")
- if discovery_info[CONF_HOST].startswith("169.254"):
+ if is_link_local(ip_address(discovery_info[CONF_HOST])):
return self.async_abort(reason="link_local_address")
await self.async_set_unique_id(serial_number)
diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py
index c2e9e220a20..b922c966ff5 100644
--- a/homeassistant/components/bayesian/binary_sensor.py
+++ b/homeassistant/components/bayesian/binary_sensor.py
@@ -1,6 +1,5 @@
"""Use Bayesian Inference to trigger a binary sensor."""
from collections import OrderedDict
-from itertools import chain
import voluptuous as vol
@@ -88,10 +87,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-def update_probability(prior, prob_true, prob_false):
+def update_probability(prior, prob_given_true, prob_given_false):
"""Update probability using Bayes' rule."""
- numerator = prob_true * prior
- denominator = numerator + prob_false * (1 - prior)
+ numerator = prob_given_true * prior
+ denominator = numerator + prob_given_false * (1 - prior)
probability = numerator / denominator
return probability
@@ -127,84 +126,124 @@ class BayesianBinarySensor(BinarySensorDevice):
self.prior = prior
self.probability = prior
- self.current_obs = OrderedDict({})
- self.entity_obs_dict = []
+ self.current_observations = OrderedDict({})
- 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())
- )
+ self.observations_by_entity = self._build_observations_by_entity()
- to_observe = set()
- for obs in self._observations:
- if "entity_id" in obs:
- to_observe.update(set([obs.get("entity_id")]))
- if "value_template" in obs:
- to_observe.update(set(obs.get(CONF_VALUE_TEMPLATE).extract_entities()))
- self.entity_obs = {key: [] for key in to_observe}
-
- for ind, obs in enumerate(self._observations):
- obs["id"] = ind
- if "entity_id" in obs:
- self.entity_obs[obs["entity_id"]].append(obs)
- if "value_template" in obs:
- for ent in obs.get(CONF_VALUE_TEMPLATE).extract_entities():
- self.entity_obs[ent].append(obs)
-
- self.watchers = {
+ self.observation_handlers = {
"numeric_state": self._process_numeric_state,
"state": self._process_state,
"template": self._process_template,
}
async def async_added_to_hass(self):
- """Call when entity about to be added."""
+ """
+ Call when entity about to be added.
+
+ All relevant update logic for instance attributes occurs within this closure.
+ Other methods in this class are designed to avoid directly modifying instance
+ attributes, by instead focusing on returning relevant data back to this method.
+
+ The goal of this method is to ensure that `self.current_observations` and `self.probability`
+ are set on a best-effort basis when this entity is register with hass.
+
+ In addition, this method must register the state listener defined within, which
+ will be called any time a relevant entity changes its state.
+ """
@callback
- def async_threshold_sensor_state_listener(entity, old_state, new_state):
- """Handle sensor state changes."""
+ def async_threshold_sensor_state_listener(entity, _old_state, new_state):
+ """
+ Handle sensor state changes.
+
+ When a state changes, we must update our list of current observations,
+ then calculate the new probability.
+ """
if new_state.state == STATE_UNKNOWN:
return
- entity_obs_list = self.entity_obs[entity]
-
- for entity_obs in entity_obs_list:
- platform = entity_obs["platform"]
-
- self.watchers[platform](entity_obs)
-
- prior = self.prior
- for obs in self.current_obs.values():
- prior = update_probability(prior, obs["prob_true"], obs["prob_false"])
- self.probability = prior
+ self.current_observations.update(self._record_entity_observations(entity))
+ self.probability = self._calculate_new_probability()
self.hass.async_add_job(self.async_update_ha_state, True)
+ self.current_observations.update(self._initialize_current_observations())
+ self.probability = self._calculate_new_probability()
async_track_state_change(
- self.hass, self.entity_obs, async_threshold_sensor_state_listener
+ self.hass,
+ self.observations_by_entity,
+ async_threshold_sensor_state_listener,
)
- def _update_current_obs(self, entity_observation, should_trigger):
- """Update current observation."""
- obs_id = entity_observation["id"]
+ def _initialize_current_observations(self):
+ local_observations = OrderedDict({})
+ for entity in self.observations_by_entity:
+ local_observations.update(self._record_entity_observations(entity))
+ return local_observations
- if should_trigger:
- prob_true = entity_observation["prob_given_true"]
- prob_false = entity_observation.get("prob_given_false", 1 - prob_true)
+ def _record_entity_observations(self, entity):
+ local_observations = OrderedDict({})
+ entity_obs_list = self.observations_by_entity[entity]
- self.current_obs[obs_id] = {
- "prob_true": prob_true,
- "prob_false": prob_false,
- }
+ for entity_obs in entity_obs_list:
+ platform = entity_obs["platform"]
- else:
- self.current_obs.pop(obs_id, None)
+ should_trigger = self.observation_handlers[platform](entity_obs)
+
+ if should_trigger:
+ obs_entry = {"entity_id": entity, **entity_obs}
+ else:
+ obs_entry = None
+
+ local_observations[entity_obs["id"]] = obs_entry
+
+ return local_observations
+
+ def _calculate_new_probability(self):
+ prior = self.prior
+
+ for obs in self.current_observations.values():
+ if obs is not None:
+ prior = update_probability(
+ prior,
+ obs["prob_given_true"],
+ obs.get("prob_given_false", 1 - obs["prob_given_true"]),
+ )
+
+ return prior
+
+ def _build_observations_by_entity(self):
+ """
+ Build and return data structure of the form below.
+
+ {
+ "sensor.sensor1": [{"id": 0, ...}, {"id": 1, ...}],
+ "sensor.sensor2": [{"id": 2, ...}],
+ ...
+ }
+
+ Each "observation" must be recognized uniquely, and it should be possible
+ for all relevant observations to be looked up via their `entity_id`.
+ """
+
+ observations_by_entity = {}
+ for ind, obs in enumerate(self._observations):
+ obs["id"] = ind
+
+ if "entity_id" in obs:
+ entity_ids = [obs["entity_id"]]
+ elif "value_template" in obs:
+ entity_ids = obs.get(CONF_VALUE_TEMPLATE).extract_entities()
+
+ for e_id in entity_ids:
+ obs_list = observations_by_entity.get(e_id, [])
+ obs_list.append(obs)
+ observations_by_entity[e_id] = obs_list
+
+ return observations_by_entity
def _process_numeric_state(self, entity_observation):
- """Add entity to current_obs if numeric state conditions are met."""
+ """Return True if numeric condition is met."""
entity = entity_observation["entity_id"]
should_trigger = condition.async_numeric_state(
@@ -215,27 +254,26 @@ class BayesianBinarySensor(BinarySensorDevice):
None,
entity_observation,
)
-
- self._update_current_obs(entity_observation, should_trigger)
+ return should_trigger
def _process_state(self, entity_observation):
- """Add entity to current observations if state conditions are met."""
+ """Return True if state conditions are met."""
entity = entity_observation["entity_id"]
should_trigger = condition.state(
self.hass, entity, entity_observation.get("to_state")
)
- self._update_current_obs(entity_observation, should_trigger)
+ return should_trigger
def _process_template(self, entity_observation):
- """Add entity to current_obs if template is true."""
+ """Return True if template condition is True."""
template = entity_observation.get(CONF_VALUE_TEMPLATE)
template.hass = self.hass
should_trigger = condition.async_template(
self.hass, template, entity_observation
)
- self._update_current_obs(entity_observation, should_trigger)
+ return should_trigger
@property
def name(self):
@@ -260,13 +298,15 @@ class BayesianBinarySensor(BinarySensorDevice):
@property
def device_state_attributes(self):
"""Return the state attributes of the sensor."""
+ print(self.current_observations)
+ print(self.observations_by_entity)
return {
- ATTR_OBSERVATIONS: list(self.current_obs.values()),
+ ATTR_OBSERVATIONS: list(self.current_observations.values()),
ATTR_OCCURRED_OBSERVATION_ENTITIES: list(
set(
- chain.from_iterable(
- self.entity_obs_dict[obs] for obs in self.current_obs.keys()
- )
+ obs.get("entity_id")
+ for obs in self.current_observations.values()
+ if obs is not None
)
),
ATTR_PROBABILITY: round(self.probability, 2),
diff --git a/homeassistant/components/binary_sensor/.translations/bg.json b/homeassistant/components/binary_sensor/.translations/bg.json
index 373866ecd8c..3006b8cadbc 100644
--- a/homeassistant/components/binary_sensor/.translations/bg.json
+++ b/homeassistant/components/binary_sensor/.translations/bg.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430 \u0431\u0430\u0442\u0435\u0440\u0438\u044f",
- "closed": "{entity_name} \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d",
"cold": "{entity_name} \u0441\u0435 \u0438\u0437\u0441\u0442\u0443\u0434\u0438",
"connected": "{entity_name} \u0441\u0432\u044a\u0440\u0437\u0430\u043d",
"gas": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437",
@@ -54,7 +53,6 @@
"light": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430",
"locked": "{entity_name} \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d",
"moist": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0432\u043b\u0430\u0436\u0435\u043d",
- "moist\u00a7": "{entity_name} \u0441\u0442\u0430\u0432\u0430 \u0432\u043b\u0430\u0436\u0435\u043d",
"motion": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435 \u043d\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435",
"moving": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435",
"no_gas": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437",
diff --git a/homeassistant/components/binary_sensor/.translations/ca.json b/homeassistant/components/binary_sensor/.translations/ca.json
index 8bbd19a0d45..3a3485a3be7 100644
--- a/homeassistant/components/binary_sensor/.translations/ca.json
+++ b/homeassistant/components/binary_sensor/.translations/ca.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "Bateria de {entity_name} baixa",
- "closed": "{entity_name} est\u00e0 tancat",
"cold": "{entity_name} es torna fred",
"connected": "{entity_name} est\u00e0 connectat",
"gas": "{entity_name} ha comen\u00e7at a detectar gas",
@@ -54,7 +53,6 @@
"light": "{entity_name} ha comen\u00e7at a detectar llum",
"locked": "{entity_name} est\u00e0 bloquejat",
"moist": "{entity_name} es torna humit",
- "moist\u00a7": "{entity_name} es torna humit",
"motion": "{entity_name} ha comen\u00e7at a detectar moviment",
"moving": "{entity_name} ha comen\u00e7at a moure's",
"no_gas": "{entity_name} ha deixat de detectar gas",
diff --git a/homeassistant/components/binary_sensor/.translations/da.json b/homeassistant/components/binary_sensor/.translations/da.json
index 19229c16cb3..ffa68b094be 100644
--- a/homeassistant/components/binary_sensor/.translations/da.json
+++ b/homeassistant/components/binary_sensor/.translations/da.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} lavt batteriniveau",
- "closed": "{entity_name} lukket",
"cold": "{entity_name} blev kold",
"connected": "{entity_name} tilsluttet",
"gas": "{entity_name} begyndte at registrere gas",
@@ -54,7 +53,6 @@
"light": "{entity_name} begyndte at registrere lys",
"locked": "{entity_name} l\u00e5st",
"moist": "{entity_name} blev fugtig",
- "moist\u00a7": "{entity_name} blev fugtig",
"motion": "{entity_name} begyndte at registrere bev\u00e6gelse",
"moving": "{entity_name} begyndte at bev\u00e6ge sig",
"no_gas": "{entity_name} stoppede med at registrere gas",
diff --git a/homeassistant/components/binary_sensor/.translations/de.json b/homeassistant/components/binary_sensor/.translations/de.json
index e246198864b..55a079ca42a 100644
--- a/homeassistant/components/binary_sensor/.translations/de.json
+++ b/homeassistant/components/binary_sensor/.translations/de.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} Batterie schwach",
- "closed": "{entity_name} geschlossen",
"cold": "{entity_name} wurde kalt",
"connected": "{entity_name} verbunden",
"gas": "{entity_name} hat Gas detektiert",
@@ -54,7 +53,6 @@
"light": "{entity_name} hat Licht detektiert",
"locked": "{entity_name} gesperrt",
"moist": "{entity_name} wurde feucht",
- "moist\u00a7": "{entity_name} wurde feucht",
"motion": "{entity_name} hat Bewegungen detektiert",
"moving": "{entity_name} hat angefangen sich zu bewegen",
"no_gas": "{entity_name} hat kein Gas mehr erkannt",
diff --git a/homeassistant/components/binary_sensor/.translations/en.json b/homeassistant/components/binary_sensor/.translations/en.json
index 93b61893980..213d947236c 100644
--- a/homeassistant/components/binary_sensor/.translations/en.json
+++ b/homeassistant/components/binary_sensor/.translations/en.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} battery low",
- "closed": "{entity_name} closed",
"cold": "{entity_name} became cold",
"connected": "{entity_name} connected",
"gas": "{entity_name} started detecting gas",
@@ -54,7 +53,6 @@
"light": "{entity_name} started detecting light",
"locked": "{entity_name} locked",
"moist": "{entity_name} became moist",
- "moist\u00a7": "{entity_name} became moist",
"motion": "{entity_name} started detecting motion",
"moving": "{entity_name} started moving",
"no_gas": "{entity_name} stopped detecting gas",
diff --git a/homeassistant/components/binary_sensor/.translations/es-419.json b/homeassistant/components/binary_sensor/.translations/es-419.json
index e727e18775a..18b5e060818 100644
--- a/homeassistant/components/binary_sensor/.translations/es-419.json
+++ b/homeassistant/components/binary_sensor/.translations/es-419.json
@@ -44,7 +44,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} bater\u00eda baja",
- "closed": "{entity_name} cerrado",
"cold": "{entity_name} se enfri\u00f3",
"connected": "{entity_name} conectado",
"gas": "{entity_name} comenz\u00f3 a detectar gas",
@@ -52,7 +51,6 @@
"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",
"no_gas": "{entity_name} dej\u00f3 de detectar gas",
diff --git a/homeassistant/components/binary_sensor/.translations/es.json b/homeassistant/components/binary_sensor/.translations/es.json
index 9720fb974f6..02fbc465252 100644
--- a/homeassistant/components/binary_sensor/.translations/es.json
+++ b/homeassistant/components/binary_sensor/.translations/es.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} bater\u00eda baja",
- "closed": "{entity_name} cerrado",
"cold": "{entity_name} se enfri\u00f3",
"connected": "{entity_name} conectado",
"gas": "{entity_name} empez\u00f3 a detectar gas",
@@ -54,7 +53,6 @@
"light": "{entity_name} empez\u00f3 a detectar la luz",
"locked": "{entity_name} bloqueado",
"moist": "{entity_name} se humedece",
- "moist\u00a7": "{entity_name} se humedeci\u00f3",
"motion": "{entity_name} comenz\u00f3 a detectar movimiento",
"moving": "{entity_name} empez\u00f3 a moverse",
"no_gas": "{entity_name} dej\u00f3 de detectar gas",
diff --git a/homeassistant/components/binary_sensor/.translations/fr.json b/homeassistant/components/binary_sensor/.translations/fr.json
index 65abfbcd0bd..f5b2e2bfd97 100644
--- a/homeassistant/components/binary_sensor/.translations/fr.json
+++ b/homeassistant/components/binary_sensor/.translations/fr.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} batterie faible",
- "closed": "{entity_name} ferm\u00e9",
"cold": "{entity_name} est devenu froid",
"connected": "{entity_name} connect\u00e9",
"gas": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du gaz",
@@ -54,7 +53,6 @@
"light": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter la lumi\u00e8re",
"locked": "{entity_name} verrouill\u00e9",
"moist": "{entity_name} est devenu humide",
- "moist\u00a7": "{entity_name} est devenu humide",
"motion": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du mouvement",
"moving": "{entity_name} a commenc\u00e9 \u00e0 se d\u00e9placer",
"no_gas": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter le gaz",
diff --git a/homeassistant/components/binary_sensor/.translations/hu.json b/homeassistant/components/binary_sensor/.translations/hu.json
index 7ec9b5268e2..d53e869e075 100644
--- a/homeassistant/components/binary_sensor/.translations/hu.json
+++ b/homeassistant/components/binary_sensor/.translations/hu.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} akkufesz\u00fclts\u00e9g alacsony",
- "closed": "{entity_name} be lett csukva",
"cold": "{entity_name} hideg lett",
"connected": "{entity_name} csatlakozik",
"gas": "{entity_name} g\u00e1zt \u00e9rz\u00e9kel",
@@ -54,7 +53,6 @@
"light": "{entity_name} f\u00e9nyt \u00e9rz\u00e9kel",
"locked": "{entity_name} be lett z\u00e1rva",
"moist": "{entity_name} nedves lett",
- "moist\u00a7": "{entity_name} nedves lett",
"motion": "{entity_name} mozg\u00e1st \u00e9rz\u00e9kel",
"moving": "{entity_name} mozog",
"no_gas": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel g\u00e1zt",
diff --git a/homeassistant/components/binary_sensor/.translations/it.json b/homeassistant/components/binary_sensor/.translations/it.json
index 74d295f3055..db897b68da0 100644
--- a/homeassistant/components/binary_sensor/.translations/it.json
+++ b/homeassistant/components/binary_sensor/.translations/it.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} batteria scarica",
- "closed": "{entity_name} \u00e8 chiuso",
"cold": "{entity_name} \u00e8 diventato freddo",
"connected": "{entity_name} connesso",
"gas": "{entity_name} ha iniziato a rilevare il gas",
@@ -54,7 +53,6 @@
"light": "{entity_name} ha iniziato a rilevare la luce",
"locked": "{entity_name} bloccato",
"moist": "{entity_name} diventato umido",
- "moist\u00a7": "{entity_name} \u00e8 diventato umido",
"motion": "{entity_name} ha iniziato a rilevare il movimento",
"moving": "{entity_name} ha iniziato a muoversi",
"no_gas": "{entity_name} ha smesso la rilevazione di gas",
diff --git a/homeassistant/components/binary_sensor/.translations/ko.json b/homeassistant/components/binary_sensor/.translations/ko.json
index 4c1cba2bec5..733d3a8de8f 100644
--- a/homeassistant/components/binary_sensor/.translations/ko.json
+++ b/homeassistant/components/binary_sensor/.translations/ko.json
@@ -25,13 +25,13 @@
"is_not_locked": "{entity_name} \uc774(\uac00) \uc7a0\uaca8\uc788\uc9c0 \uc54a\uc73c\uba74",
"is_not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud558\uba74",
"is_not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc73c\uba74",
- "is_not_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774\uc9c0 \uc54a\uc73c\uba74",
+ "is_not_occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \uc544\ub2c8\uba74",
"is_not_open": "{entity_name} \uc774(\uac00) \ub2eb\ud600 \uc788\uc73c\uba74",
"is_not_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \ubf51\ud600 \uc788\uc73c\uba74",
"is_not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc73c\uba74",
"is_not_present": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc911\uc774\uba74",
"is_not_unsafe": "{entity_name} \uc774(\uac00) \uc548\uc804\ud558\uba74",
- "is_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774\uba74",
+ "is_occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uc774\uba74",
"is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74",
"is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74",
"is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub824 \uc788\uc73c\uba74",
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} \ubc30\ud130\ub9ac \uc794\ub7c9\uc774 \ubd80\uc871\ud574\uc9c8 \ub54c",
- "closed": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c",
"cold": "{entity_name} \uc774(\uac00) \ucc28\uac00\uc6cc\uc9c8 \ub54c",
"connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub420 \ub54c",
"gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud560 \ub54c",
@@ -54,7 +53,6 @@
"light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud560 \ub54c",
"locked": "{entity_name} \uc774(\uac00) \uc7a0\uae38 \ub54c",
"moist": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9c8 \ub54c",
- "moist\u00a7": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9c8 \ub54c",
"motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud560 \ub54c",
"moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc77c \ub54c",
"no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c",
@@ -71,13 +69,13 @@
"not_locked": "{entity_name} \uc758 \uc7a0\uae08\uc774 \ud574\uc81c\ub420 \ub54c",
"not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud574\uc9c8 \ub54c",
"not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc744 \ub54c",
- "not_occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774\uc9c0 \uc54a\uac8c \ub420 \ub54c",
+ "not_occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \uc544\ub2c8\uac8c \ub420 \ub54c",
"not_opened": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c",
"not_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \ubf51\ud790 \ub54c",
"not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc744 \ub54c",
"not_present": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc0c1\ud0dc\uac00 \ub420 \ub54c",
"not_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud574\uc9c8 \ub54c",
- "occupied": "{entity_name} \uc774(\uac00) \uc0ac\uc6a9 \uc911\uc774 \ub420 \ub54c",
+ "occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \ub420 \ub54c",
"opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9b4 \ub54c",
"plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \uaf3d\ud790 \ub54c",
"powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub420 \ub54c",
diff --git a/homeassistant/components/binary_sensor/.translations/lb.json b/homeassistant/components/binary_sensor/.translations/lb.json
index c65ae94396b..7120b1bb289 100644
--- a/homeassistant/components/binary_sensor/.translations/lb.json
+++ b/homeassistant/components/binary_sensor/.translations/lb.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} Batterie niddereg",
- "closed": "{entity_name} gouf zougemaach",
"cold": "{entity_name} gouf kal",
"connected": "{entity_name} ass verbonnen",
"gas": "{entity_name} huet ugefaangen Gas z'entdecken",
@@ -54,7 +53,6 @@
"light": "{entity_name} huet ugefange Luucht z'entdecken",
"locked": "{entity_name} gespaart",
"moist": "{entity_name} gouf fiicht",
- "moist\u00a7": "{entity_name} gouf fiicht",
"motion": "{entity_name} huet ugefaange Beweegung z'entdecken",
"moving": "{entity_name} huet ugefaangen sech ze beweegen",
"no_gas": "{entity_name} huet opgehale Gas z'entdecken",
diff --git a/homeassistant/components/binary_sensor/.translations/nl.json b/homeassistant/components/binary_sensor/.translations/nl.json
index 508a06b38a2..04d40ecf9b8 100644
--- a/homeassistant/components/binary_sensor/.translations/nl.json
+++ b/homeassistant/components/binary_sensor/.translations/nl.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} batterij bijna leeg",
- "closed": "{entity_name} gesloten",
"cold": "{entity_name} werd koud",
"connected": "{entity_name} verbonden",
"gas": "{entity_name} begon gas te detecteren",
@@ -54,7 +53,6 @@
"light": "{entity_name} begon licht te detecteren",
"locked": "{entity_name} vergrendeld",
"moist": "{entity_name} werd vochtig",
- "moist\u00a7": "{entity_name} werd vochtig",
"motion": "{entity_name} begon beweging te detecteren",
"moving": "{entity_name} begon te bewegen",
"no_gas": "{entity_name} is gestopt met het detecteren van gas",
diff --git a/homeassistant/components/binary_sensor/.translations/no.json b/homeassistant/components/binary_sensor/.translations/no.json
index 4194102948b..b82dd8b0533 100644
--- a/homeassistant/components/binary_sensor/.translations/no.json
+++ b/homeassistant/components/binary_sensor/.translations/no.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} lavt batteri",
- "closed": "{entity_name} stengt",
"cold": "{entity_name} ble kald",
"connected": "{entity_name} tilkoblet",
"gas": "{entity_name} begynte \u00e5 registrere gass",
@@ -54,7 +53,6 @@
"light": "{entity_name} begynte \u00e5 registrere lys",
"locked": "{entity_name} l\u00e5st",
"moist": "{entity_name} ble fuktig",
- "moist\u00a7": "{entity_name} ble fuktig",
"motion": "{entity_name} begynte \u00e5 registrere bevegelse",
"moving": "{entity_name} begynte \u00e5 bevege seg",
"no_gas": "{entity_name} sluttet \u00e5 registrere gass",
diff --git a/homeassistant/components/binary_sensor/.translations/pl.json b/homeassistant/components/binary_sensor/.translations/pl.json
index bc474e3d514..ef174e72336 100644
--- a/homeassistant/components/binary_sensor/.translations/pl.json
+++ b/homeassistant/components/binary_sensor/.translations/pl.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "nast\u0105pi roz\u0142adowanie baterii {entity_name}",
- "closed": "nast\u0105pi zamkni\u0119cie {entity_name}",
"cold": "sensor {entity_name} wykryje zimno",
"connected": "nast\u0105pi pod\u0142\u0105czenie {entity_name}",
"gas": "sensor {entity_name} wykryje gaz",
@@ -54,7 +53,6 @@
"light": "sensor {entity_name} wykryje \u015bwiat\u0142o",
"locked": "nast\u0105pi zamkni\u0119cie {entity_name}",
"moist": "nast\u0105pi wykrycie wilgoci {entity_name}",
- "moist\u00a7": "sensor {entity_name} wykryje wilgo\u0107",
"motion": "sensor {entity_name} wykryje ruch",
"moving": "sensor {entity_name} zacznie porusza\u0107 si\u0119",
"no_gas": "sensor {entity_name} przestanie wykrywa\u0107 gaz",
diff --git a/homeassistant/components/binary_sensor/.translations/pt.json b/homeassistant/components/binary_sensor/.translations/pt.json
index aa16576d2c1..caea4c6c97a 100644
--- a/homeassistant/components/binary_sensor/.translations/pt.json
+++ b/homeassistant/components/binary_sensor/.translations/pt.json
@@ -17,7 +17,6 @@
"is_vibration": "{entity_name} est\u00e1 a detectar vibra\u00e7\u00f5es"
},
"trigger_type": {
- "closed": "{entity_name} est\u00e1 fechado",
"moist": "ficou h\u00famido {entity_name}",
"not_opened": "fechado {entity_name}",
"not_plugged_in": "{entity_name} desligado",
diff --git a/homeassistant/components/binary_sensor/.translations/ru.json b/homeassistant/components/binary_sensor/.translations/ru.json
index 4c9cfb99a1c..fe1323c6744 100644
--- a/homeassistant/components/binary_sensor/.translations/ru.json
+++ b/homeassistant/components/binary_sensor/.translations/ru.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0438\u0437\u043a\u0438\u0439 \u0437\u0430\u0440\u044f\u0434",
- "closed": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f",
"cold": "{entity_name} \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0435\u0442\u0441\u044f",
"connected": "{entity_name} \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f",
"gas": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437",
@@ -54,7 +53,6 @@
"light": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0441\u0432\u0435\u0442",
"locked": "{entity_name} \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f",
"moist": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443",
- "moist\u00a7": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443",
"motion": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435",
"moving": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435",
"no_gas": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437",
diff --git a/homeassistant/components/binary_sensor/.translations/sl.json b/homeassistant/components/binary_sensor/.translations/sl.json
index 2004caeb342..234146e2e6f 100644
--- a/homeassistant/components/binary_sensor/.translations/sl.json
+++ b/homeassistant/components/binary_sensor/.translations/sl.json
@@ -46,7 +46,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} ima prazno baterijo",
- "closed": "{entity_name} zaprto",
"cold": "{entity_name} je postal hladen",
"connected": "{entity_name} povezan",
"gas": "{entity_name} za\u010del zaznavati plin",
@@ -54,7 +53,6 @@
"light": "{entity_name} za\u010del zaznavati svetlobo",
"locked": "{entity_name} zaklenjen",
"moist": "{entity_name} postal vla\u017een",
- "moist\u00a7": "{entity_name} postal vla\u017een",
"motion": "{entity_name} za\u010del zaznavati gibanje",
"moving": "{entity_name} se je za\u010del premikati",
"no_gas": "{entity_name} prenehal zaznavati plin",
diff --git a/homeassistant/components/binary_sensor/.translations/sv.json b/homeassistant/components/binary_sensor/.translations/sv.json
index 5df2ce17c92..ec5d57daa79 100644
--- a/homeassistant/components/binary_sensor/.translations/sv.json
+++ b/homeassistant/components/binary_sensor/.translations/sv.json
@@ -46,7 +46,6 @@
},
"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",
@@ -54,7 +53,6 @@
"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",
diff --git a/homeassistant/components/binary_sensor/.translations/zh-Hans.json b/homeassistant/components/binary_sensor/.translations/zh-Hans.json
index aeb24e5056a..9ad8e67e6b8 100644
--- a/homeassistant/components/binary_sensor/.translations/zh-Hans.json
+++ b/homeassistant/components/binary_sensor/.translations/zh-Hans.json
@@ -44,7 +44,6 @@
},
"trigger_type": {
"bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u4f4e",
- "closed": "{entity_name} \u5df2\u5173\u95ed",
"cold": "{entity_name} \u53d8\u51b7",
"connected": "{entity_name} \u5df2\u8fde\u63a5",
"gas": "{entity_name} \u5f00\u59cb\u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f",
diff --git a/homeassistant/components/binary_sensor/.translations/zh-Hant.json b/homeassistant/components/binary_sensor/.translations/zh-Hant.json
index 712c0fbd7c1..7b48833dd7b 100644
--- a/homeassistant/components/binary_sensor/.translations/zh-Hant.json
+++ b/homeassistant/components/binary_sensor/.translations/zh-Hant.json
@@ -46,7 +46,6 @@
},
"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",
@@ -54,7 +53,6 @@
"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",
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index 3ca9cb1f623..e2cc0dd31e2 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -777,7 +777,7 @@ class BluesoundPlayer(MediaPlayerDevice):
def supported_features(self):
"""Flag of media commands that are supported."""
if self._status is None:
- return None
+ return 0
if self.is_grouped and not self.is_master:
return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE
@@ -1021,16 +1021,16 @@ class BluesoundPlayer(MediaPlayerDevice):
async def async_volume_up(self):
"""Volume up the media player."""
current_vol = self.volume_level
- if not current_vol or current_vol < 0:
+ if not current_vol or current_vol >= 1:
return
- return self.async_set_volume_level(((current_vol * 100) + 1) / 100)
+ return await self.async_set_volume_level(current_vol + 0.01)
async def async_volume_down(self):
"""Volume down the media player."""
current_vol = self.volume_level
- if not current_vol or current_vol < 0:
+ if not current_vol or current_vol <= 0:
return
- return self.async_set_volume_level(((current_vol * 100) - 1) / 100)
+ return await self.async_set_volume_level(current_vol - 0.01)
async def async_set_volume_level(self, volume):
"""Send volume_up command to media player."""
diff --git a/homeassistant/components/bmp280/__init__.py b/homeassistant/components/bmp280/__init__.py
new file mode 100644
index 00000000000..0c884eafbf1
--- /dev/null
+++ b/homeassistant/components/bmp280/__init__.py
@@ -0,0 +1 @@
+"""The Bosch BMP280 sensor integration."""
diff --git a/homeassistant/components/bmp280/manifest.json b/homeassistant/components/bmp280/manifest.json
new file mode 100644
index 00000000000..d7d3752392b
--- /dev/null
+++ b/homeassistant/components/bmp280/manifest.json
@@ -0,0 +1,9 @@
+{
+ "domain": "bmp280",
+ "name": "Bosch BMP280 Environmental Sensor",
+ "documentation": "https://www.home-assistant.io/integrations/bmp280",
+ "dependencies": [],
+ "codeowners": ["@belidzs"],
+ "requirements": ["adafruit-circuitpython-bmp280==3.1.1", "RPi.GPIO==0.7.0"],
+ "quality_scale": "silver"
+}
diff --git a/homeassistant/components/bmp280/sensor.py b/homeassistant/components/bmp280/sensor.py
new file mode 100644
index 00000000000..70efbce7d85
--- /dev/null
+++ b/homeassistant/components/bmp280/sensor.py
@@ -0,0 +1,159 @@
+"""Platform for Bosch BMP280 Environmental Sensor integration."""
+from datetime import timedelta
+import logging
+
+from adafruit_bmp280 import Adafruit_BMP280_I2C
+import board
+from busio import I2C
+import voluptuous as vol
+
+from homeassistant.components.sensor import (
+ DEVICE_CLASS_PRESSURE,
+ DEVICE_CLASS_TEMPERATURE,
+ PLATFORM_SCHEMA,
+)
+from homeassistant.const import CONF_NAME, PRESSURE_HPA, TEMP_CELSIUS
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "BMP280"
+SCAN_INTERVAL = timedelta(seconds=15)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3)
+
+MIN_I2C_ADDRESS = 0x76
+MAX_I2C_ADDRESS = 0x77
+
+CONF_I2C_ADDRESS = "i2c_address"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_I2C_ADDRESS): vol.All(
+ vol.Coerce(int), vol.Range(min=MIN_I2C_ADDRESS, max=MAX_I2C_ADDRESS)
+ ),
+ }
+)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the sensor platform."""
+ try:
+ # initializing I2C bus using the auto-detected pins
+ i2c = I2C(board.SCL, board.SDA)
+ # initializing the sensor
+ bmp280 = Adafruit_BMP280_I2C(i2c, address=config[CONF_I2C_ADDRESS])
+ except ValueError as error:
+ # this usually happens when the board is I2C capable, but the device can't be found at the configured address
+ if str(error.args[0]).startswith("No I2C device at address"):
+ _LOGGER.error(
+ "%s. Hint: Check wiring and make sure that the SDO pin is tied to either ground (0x76) or VCC (0x77)",
+ error.args[0],
+ )
+ raise PlatformNotReady()
+ _LOGGER.error(error)
+ return
+ # use custom name if there's any
+ name = config[CONF_NAME]
+ # BMP280 has both temperature and pressure sensing capability
+ add_entities(
+ [Bmp280TemperatureSensor(bmp280, name), Bmp280PressureSensor(bmp280, name)]
+ )
+
+
+class Bmp280Sensor(Entity):
+ """Base class for BMP280 entities."""
+
+ def __init__(
+ self,
+ bmp280: Adafruit_BMP280_I2C,
+ name: str,
+ unit_of_measurement: str,
+ device_class: str,
+ ):
+ """Initialize the sensor."""
+ self._bmp280 = bmp280
+ self._name = name
+ self._unit_of_measurement = unit_of_measurement
+ self._device_class = device_class
+ self._state = None
+ self._errored = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is currently available."""
+ return not self._errored
+
+
+class Bmp280TemperatureSensor(Bmp280Sensor):
+ """Representation of a Bosch BMP280 Temperature Sensor."""
+
+ def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str):
+ """Initialize the entity."""
+ super().__init__(
+ bmp280, f"{name} Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE
+ )
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Fetch new state data for the sensor."""
+ try:
+ self._state = round(self._bmp280.temperature, 1)
+ if self._errored:
+ _LOGGER.warning("Communication restored with temperature sensor")
+ self._errored = False
+ except OSError:
+ # this is thrown when a working sensor is unplugged between two updates
+ _LOGGER.warning(
+ "Unable to read temperature data due to a communication problem"
+ )
+ self._errored = True
+
+
+class Bmp280PressureSensor(Bmp280Sensor):
+ """Representation of a Bosch BMP280 Barometric Pressure Sensor."""
+
+ def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str):
+ """Initialize the entity."""
+ super().__init__(
+ bmp280, f"{name} Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE
+ )
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Fetch new state data for the sensor."""
+ try:
+ self._state = round(self._bmp280.pressure)
+ if self._errored:
+ _LOGGER.warning("Communication restored with pressure sensor")
+ self._errored = False
+ except OSError:
+ # this is thrown when a working sensor is unplugged between two updates
+ _LOGGER.warning(
+ "Unable to read pressure data due to a communication problem"
+ )
+ self._errored = True
diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py
index 2916bb319f8..f9362799224 100644
--- a/homeassistant/components/braviatv/media_player.py
+++ b/homeassistant/components/braviatv/media_player.py
@@ -75,7 +75,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
pin = host_config["pin"]
mac = host_config["mac"]
name = config.get(CONF_NAME)
- add_entities([BraviaTVDevice(host, mac, name, pin)])
+ braviarc = BraviaRC(host, mac)
+ braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
+ unique_id = braviarc.get_system_info()["cid"].lower()
+
+ add_entities([BraviaTVDevice(braviarc, name, pin, unique_id)])
return
setup_bravia(config, pin, hass, add_entities)
@@ -111,8 +115,11 @@ def setup_bravia(config, pin, hass, add_entities):
hass.config.path(BRAVIA_CONFIG_FILE),
{host: {"pin": pin, "host": host, "mac": mac}},
)
+ braviarc = BraviaRC(host, mac)
+ braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
+ unique_id = braviarc.get_system_info()["cid"].lower()
- add_entities([BraviaTVDevice(host, mac, name, pin)])
+ add_entities([BraviaTVDevice(braviarc, name, pin, unique_id)])
def request_configuration(config, hass, add_entities):
@@ -154,11 +161,11 @@ def request_configuration(config, hass, add_entities):
class BraviaTVDevice(MediaPlayerDevice):
"""Representation of a Sony Bravia TV."""
- def __init__(self, host, mac, name, pin):
+ def __init__(self, client, name, pin, unique_id):
"""Initialize the Sony Bravia device."""
self._pin = pin
- self._braviarc = BraviaRC(host, mac)
+ self._braviarc = client
self._name = name
self._state = STATE_OFF
self._muted = False
@@ -171,15 +178,14 @@ class BraviaTVDevice(MediaPlayerDevice):
self._content_mapping = {}
self._duration = None
self._content_uri = None
- self._id = None
self._playing = False
self._start_date_time = None
self._program_media_type = None
self._min_volume = None
self._max_volume = None
self._volume = None
+ self._unique_id = unique_id
- self._braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
if self._braviarc.is_connected():
self.update()
else:
@@ -212,8 +218,8 @@ class BraviaTVDevice(MediaPlayerDevice):
self._channel_name = playing_info.get("title")
self._program_media_type = playing_info.get("programMediaType")
self._channel_number = playing_info.get("dispNum")
- self._source = playing_info.get("source")
self._content_uri = playing_info.get("uri")
+ self._source = self._get_source()
self._duration = playing_info.get("durationSec")
self._start_date_time = playing_info.get("startDateTime")
else:
@@ -223,6 +229,12 @@ class BraviaTVDevice(MediaPlayerDevice):
_LOGGER.error(exception_instance)
self._state = STATE_OFF
+ def _get_source(self):
+ """Return the name of the source."""
+ for key, value in self._content_mapping.items():
+ if value == self._content_uri:
+ return key
+
def _reset_playing_info(self):
self._program_name = None
self._channel_name = None
@@ -254,6 +266,11 @@ class BraviaTVDevice(MediaPlayerDevice):
"""Return the name of the device."""
return self._name
+ @property
+ def unique_id(self):
+ """Return a unique_id for this entity."""
+ return self._unique_id
+
@property
def state(self):
"""Return the state of the device."""
diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json
index 3af11f47aad..a179ca9c066 100644
--- a/homeassistant/components/broadlink/manifest.json
+++ b/homeassistant/components/broadlink/manifest.json
@@ -2,7 +2,7 @@
"domain": "broadlink",
"name": "Broadlink",
"documentation": "https://www.home-assistant.io/integrations/broadlink",
- "requirements": ["broadlink==0.12.0"],
+ "requirements": ["broadlink==0.13.0"],
"dependencies": [],
"codeowners": ["@danielhiversen", "@felipediel"]
}
diff --git a/homeassistant/components/brother/.translations/zh-Hant.json b/homeassistant/components/brother/.translations/zh-Hant.json
index 0ef813dffea..987a15f8a2f 100644
--- a/homeassistant/components/brother/.translations/zh-Hant.json
+++ b/homeassistant/components/brother/.translations/zh-Hant.json
@@ -24,7 +24,7 @@
"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": "\u81ea\u52d5\u63a2\u7d22\u5230 Brother \u5370\u8868\u6a5f"
}
},
"title": "Brother \u5370\u8868\u6a5f"
diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py
index ada740c5f10..5daf54a568c 100644
--- a/homeassistant/components/brother/__init__.py
+++ b/homeassistant/components/brother/__init__.py
@@ -9,20 +9,19 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.core import Config, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.util import Throttle
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
PLATFORMS = ["sensor"]
-DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
+SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: Config):
"""Set up the Brother component."""
- hass.data[DOMAIN] = {}
return True
@@ -31,14 +30,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
host = entry.data[CONF_HOST]
kind = entry.data[CONF_TYPE]
- brother = BrotherPrinterData(host, kind)
+ coordinator = BrotherDataUpdateCoordinator(hass, host=host, kind=kind)
+ await coordinator.async_refresh()
- await brother.async_update()
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
- if not brother.available:
- raise ConfigEntryNotReady()
-
- hass.data[DOMAIN][entry.entry_id] = brother
+ hass.data.setdefault(DOMAIN, {})
+ hass.data[DOMAIN][entry.entry_id] = coordinator
for component in PLATFORMS:
hass.async_create_task(
@@ -64,39 +63,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
return unload_ok
-class BrotherPrinterData:
- """Define an object to hold sensor data."""
+class BrotherDataUpdateCoordinator(DataUpdateCoordinator):
+ """Class to manage fetching Brother data from the printer."""
- def __init__(self, host, kind):
+ def __init__(self, hass, host, kind):
"""Initialize."""
- self._brother = Brother(host, kind=kind)
- self.host = host
- self.model = None
- self.serial = None
- self.firmware = None
- self.available = False
- self.data = {}
- self.unavailable_logged = False
+ self.brother = Brother(host, kind=kind)
- @Throttle(DEFAULT_SCAN_INTERVAL)
- async def async_update(self):
+ super().__init__(
+ hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL,
+ )
+
+ async def _async_update_data(self):
"""Update data via library."""
try:
- await self._brother.async_update()
+ await self.brother.async_update()
except (ConnectionError, SnmpError, UnsupportedModel) 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 = self._brother.available
- return
-
- self.model = self._brother.model
- self.serial = self._brother.serial
- self.firmware = self._brother.firmware
- self.available = self._brother.available
- self.data = self._brother.data
- if self.available and self.unavailable_logged:
- _LOGGER.info("Printer %s is available again", self.host)
- self.unavailable_logged = False
+ raise UpdateFailed(error)
+ return self.brother.data
diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py
index 94d88162d76..d5bceaa2653 100644
--- a/homeassistant/components/brother/const.py
+++ b/homeassistant/components/brother/const.py
@@ -48,7 +48,7 @@ PRINTER_TYPES = ["laser", "ink"]
SENSOR_TYPES = {
ATTR_STATUS: {
- ATTR_ICON: "icon:mdi:printer",
+ ATTR_ICON: "mdi:printer",
ATTR_LABEL: ATTR_STATUS.title(),
ATTR_UNIT: None,
},
diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json
index ec87adacb5f..7f48c7ee22c 100644
--- a/homeassistant/components/brother/manifest.json
+++ b/homeassistant/components/brother/manifest.json
@@ -4,7 +4,8 @@
"documentation": "https://www.home-assistant.io/integrations/brother",
"dependencies": [],
"codeowners": ["@bieniu"],
- "requirements": ["brother==0.1.8"],
+ "requirements": ["brother==0.1.11"],
"zeroconf": ["_printer._tcp.local."],
- "config_flow": true
+ "config_flow": true,
+ "quality_scale": "platinum"
}
diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py
index e118e65e9a5..b8142ac0c34 100644
--- a/homeassistant/components/brother/sensor.py
+++ b/homeassistant/components/brother/sensor.py
@@ -28,54 +28,55 @@ from .const import (
)
ATTR_COUNTER = "counter"
+ATTR_FIRMWARE = "firmware"
+ATTR_MODEL = "model"
ATTR_REMAINING_PAGES = "remaining_pages"
+ATTR_SERIAL = "serial"
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add Brother entities from a config_entry."""
- brother = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = hass.data[DOMAIN][config_entry.entry_id]
sensors = []
- name = brother.model
device_info = {
- "identifiers": {(DOMAIN, brother.serial)},
- "name": brother.model,
+ "identifiers": {(DOMAIN, coordinator.data[ATTR_SERIAL])},
+ "name": coordinator.data[ATTR_MODEL],
"manufacturer": ATTR_MANUFACTURER,
- "model": brother.model,
- "sw_version": brother.firmware,
+ "model": coordinator.data[ATTR_MODEL],
+ "sw_version": coordinator.data.get(ATTR_FIRMWARE),
}
for sensor in SENSOR_TYPES:
- if sensor in brother.data:
- sensors.append(BrotherPrinterSensor(brother, name, sensor, device_info))
- async_add_entities(sensors, True)
+ if sensor in coordinator.data:
+ sensors.append(BrotherPrinterSensor(coordinator, sensor, device_info))
+ async_add_entities(sensors, False)
class BrotherPrinterSensor(Entity):
"""Define an Brother Printer sensor."""
- def __init__(self, printer, name, kind, device_info):
+ def __init__(self, coordinator, kind, device_info):
"""Initialize."""
- self.printer = printer
- self._name = name
+ self._name = f"{coordinator.data[ATTR_MODEL]} {SENSOR_TYPES[kind][ATTR_LABEL]}"
+ self._unique_id = f"{coordinator.data[ATTR_SERIAL].lower()}_{kind}"
self._device_info = device_info
- self._unique_id = f"{self.printer.serial.lower()}_{kind}"
+ self.coordinator = coordinator
self.kind = kind
- self._state = None
self._attrs = {}
@property
def name(self):
"""Return the name."""
- return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}"
+ return self._name
@property
def state(self):
"""Return the state."""
- return self._state
+ return self.coordinator.data.get(self.kind)
@property
def device_state_attributes(self):
@@ -98,8 +99,10 @@ class BrotherPrinterSensor(Entity):
remaining_pages = ATTR_YELLOW_DRUM_REMAINING_PAGES
drum_counter = ATTR_YELLOW_DRUM_COUNTER
if remaining_pages and drum_counter:
- self._attrs[ATTR_REMAINING_PAGES] = self.printer.data.get(remaining_pages)
- self._attrs[ATTR_COUNTER] = self.printer.data.get(drum_counter)
+ self._attrs[ATTR_REMAINING_PAGES] = self.coordinator.data.get(
+ remaining_pages
+ )
+ self._attrs[ATTR_COUNTER] = self.coordinator.data.get(drum_counter)
return self._attrs
@property
@@ -120,15 +123,31 @@ class BrotherPrinterSensor(Entity):
@property
def available(self):
"""Return True if entity is available."""
- return self.printer.available
+ return self.coordinator.last_update_success
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement of the entity."""
+ return False
@property
def device_info(self):
"""Return the device info."""
return self._device_info
- async def async_update(self):
- """Update the data from printer."""
- await self.printer.async_update()
+ @property
+ def entity_registry_enabled_default(self):
+ """Return if the entity should be enabled when first added to the entity registry."""
+ return True
- self._state = self.printer.data.get(self.kind)
+ async def async_added_to_hass(self):
+ """Connect to dispatcher listening for entity data notifications."""
+ self.coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
+
+ async def async_update(self):
+ """Update Brother entity."""
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/cast/.translations/no.json b/homeassistant/components/cast/.translations/no.json
index 6b8166f23c0..6c733896d27 100644
--- a/homeassistant/components/cast/.translations/no.json
+++ b/homeassistant/components/cast/.translations/no.json
@@ -7,7 +7,7 @@
"step": {
"confirm": {
"description": "\u00d8nsker du \u00e5 sette opp Google Cast?",
- "title": "Google Cast"
+ "title": ""
}
},
"title": "Google Cast"
diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py
index 0b8633e1916..c933136d140 100644
--- a/homeassistant/components/cast/home_assistant_cast.py
+++ b/homeassistant/components/cast/home_assistant_cast.py
@@ -12,6 +12,7 @@ from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW
SERVICE_SHOW_VIEW = "show_lovelace_view"
ATTR_VIEW_PATH = "view_path"
+ATTR_URL_PATH = "dashboard_path"
async def async_setup_ha_cast(
@@ -63,11 +64,18 @@ async def async_setup_ha_cast(
controller,
call.data[ATTR_ENTITY_ID],
call.data[ATTR_VIEW_PATH],
+ call.data.get(ATTR_URL_PATH),
)
hass.helpers.service.async_register_admin_service(
DOMAIN,
SERVICE_SHOW_VIEW,
handle_show_view,
- vol.Schema({ATTR_ENTITY_ID: cv.entity_id, ATTR_VIEW_PATH: str}),
+ vol.Schema(
+ {
+ ATTR_ENTITY_ID: cv.entity_id,
+ ATTR_VIEW_PATH: str,
+ vol.Optional(ATTR_URL_PATH): str,
+ }
+ ),
)
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
index 4e259038f14..e0c48062dfb 100644
--- a/homeassistant/components/cast/media_player.py
+++ b/homeassistant/components/cast/media_player.py
@@ -948,7 +948,11 @@ class CastDevice(MediaPlayerDevice):
await self._async_disconnect()
def _handle_signal_show_view(
- self, controller: HomeAssistantController, entity_id: str, view_path: str
+ self,
+ controller: HomeAssistantController,
+ entity_id: str,
+ view_path: str,
+ url_path: Optional[str],
):
"""Handle a show view signal."""
if entity_id != self.entity_id:
@@ -958,4 +962,4 @@ class CastDevice(MediaPlayerDevice):
self._hass_cast_controller = controller
self._chromecast.register_handler(controller)
- self._hass_cast_controller.show_lovelace_view(view_path)
+ self._hass_cast_controller.show_lovelace_view(view_path, url_path)
diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml
index 24bc7b16a90..d1c29281aad 100644
--- a/homeassistant/components/cast/services.yaml
+++ b/homeassistant/components/cast/services.yaml
@@ -4,6 +4,9 @@ show_lovelace_view:
entity_id:
description: Media Player entity to show the Lovelace view on.
example: "media_player.kitchen"
+ dashboard_path:
+ description: The url path of the Lovelace dashboard to show.
+ example: lovelace-cast
view_path:
description: The path of the Lovelace view to show.
example: downstairs
diff --git a/homeassistant/components/cert_expiry/.translations/bg.json b/homeassistant/components/cert_expiry/.translations/bg.json
index a4a36cb04dc..cf89911071b 100644
--- a/homeassistant/components/cert_expiry/.translations/bg.json
+++ b/homeassistant/components/cert_expiry/.translations/bg.json
@@ -1,15 +1,8 @@
{
"config": {
- "abort": {
- "host_port_exists": "\u0422\u0430\u0437\u0438 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u043e\u0442 \u0430\u0434\u0440\u0435\u0441 \u0438 \u043f\u043e\u0440\u0442 \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430"
- },
"error": {
- "certificate_error": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044a\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0432\u0430\u043b\u0438\u0434\u0438\u0440\u0430\u043d",
- "certificate_fetch_failed": "\u041d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0441\u0435 \u043c\u0438\u0437\u0432\u043b\u0435\u0447\u0435 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043e\u0442 \u0442\u0430\u0437\u0438 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u043e\u0442 \u0430\u0434\u0440\u0435\u0441 \u0438 \u043f\u043e\u0440\u0442",
"connection_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0442\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441",
- "host_port_exists": "\u0422\u0430\u0437\u0438 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u043e\u0442 \u0430\u0434\u0440\u0435\u0441 \u0438 \u043f\u043e\u0440\u0442 \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430",
- "resolve_failed": "\u0422\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d",
- "wrong_host": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044a\u0442 \u043d\u0435 \u0441\u044a\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0430 \u043d\u0430 \u0438\u043c\u0435\u0442\u043e \u043d\u0430 \u0445\u043e\u0441\u0442\u0430"
+ "resolve_failed": "\u0422\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/ca.json b/homeassistant/components/cert_expiry/.translations/ca.json
index f1df9a06be1..dce3519f09f 100644
--- a/homeassistant/components/cert_expiry/.translations/ca.json
+++ b/homeassistant/components/cert_expiry/.translations/ca.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "Aquesta combinaci\u00f3 d'amfitri\u00f3 i port ja est\u00e0 configurada"
+ "already_configured": "Aquesta combinaci\u00f3 d'amfitri\u00f3 i port ja est\u00e0 configurada",
+ "import_failed": "La importaci\u00f3 des de configuraci\u00f3 ha fallat"
},
"error": {
- "certificate_error": "El certificat no ha pogut ser validat",
- "certificate_fetch_failed": "No s'ha pogut obtenir el certificat des d'aquesta combinaci\u00f3 d'amfitri\u00f3 i port",
+ "connection_refused": "La connexi\u00f3 s'ha rebutjat en connectar-se a l'amfitri\u00f3",
"connection_timeout": "S'ha acabat el temps d'espera durant la connexi\u00f3 amb l'amfitri\u00f3.",
- "host_port_exists": "Aquesta combinaci\u00f3 d'amfitri\u00f3 i port ja est\u00e0 configurada",
- "resolve_failed": "No s'ha pogut resoldre l'amfitri\u00f3",
- "wrong_host": "El certificat no coincideix amb el nom de l'amfitri\u00f3"
+ "resolve_failed": "No s'ha pogut resoldre l'amfitri\u00f3"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/da.json b/homeassistant/components/cert_expiry/.translations/da.json
index 26ee436860a..cf5f42338c3 100644
--- a/homeassistant/components/cert_expiry/.translations/da.json
+++ b/homeassistant/components/cert_expiry/.translations/da.json
@@ -1,15 +1,8 @@
{
"config": {
- "abort": {
- "host_port_exists": "Denne v\u00e6rt- og portkombination er allerede konfigureret"
- },
"error": {
- "certificate_error": "Certifikatet kunne ikke valideres",
- "certificate_fetch_failed": "Kan ikke hente certifikat fra denne v\u00e6rt- og portkombination",
"connection_timeout": "Timeout ved tilslutning til denne v\u00e6rt",
- "host_port_exists": "Denne v\u00e6rt- og portkombination er allerede konfigureret",
- "resolve_failed": "V\u00e6rten kunne ikke findes",
- "wrong_host": "Certifikatet stemmer ikke overens med v\u00e6rtsnavnet"
+ "resolve_failed": "V\u00e6rten kunne ikke findes"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/de.json b/homeassistant/components/cert_expiry/.translations/de.json
index e344e2dfd29..119d172690a 100644
--- a/homeassistant/components/cert_expiry/.translations/de.json
+++ b/homeassistant/components/cert_expiry/.translations/de.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert."
+ "already_configured": "Diese Kombination aus Host und Port ist bereits konfiguriert.",
+ "import_failed": "Import aus Konfiguration fehlgeschlagen"
},
"error": {
- "certificate_error": "Zertifikat konnte nicht validiert werden",
- "certificate_fetch_failed": "Zertifikat kann von dieser Kombination aus Host und Port nicht abgerufen werden",
+ "connection_refused": "Verbindung beim Herstellen einer Verbindung zum Host abgelehnt",
"connection_timeout": "Zeit\u00fcberschreitung beim Herstellen einer Verbindung mit diesem Host",
- "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert.",
- "resolve_failed": "Dieser Host kann nicht aufgel\u00f6st werden",
- "wrong_host": "Zertifikat stimmt nicht mit Hostname \u00fcberein"
+ "resolve_failed": "Dieser Host kann nicht aufgel\u00f6st werden"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/en.json b/homeassistant/components/cert_expiry/.translations/en.json
index 1c1b9a882e3..5aca41f7f78 100644
--- a/homeassistant/components/cert_expiry/.translations/en.json
+++ b/homeassistant/components/cert_expiry/.translations/en.json
@@ -2,17 +2,12 @@
"config": {
"abort": {
"already_configured": "This host and port combination is already configured",
- "host_port_exists": "This host and port combination is already configured",
"import_failed": "Import from config failed"
},
"error": {
- "certificate_error": "Certificate could not be validated",
- "certificate_fetch_failed": "Can not fetch certificate from this host and port combination",
"connection_refused": "Connection refused when connecting to host",
"connection_timeout": "Timeout when connecting to this host",
- "host_port_exists": "This host and port combination is already configured",
- "resolve_failed": "This host can not be resolved",
- "wrong_host": "Certificate does not match hostname"
+ "resolve_failed": "This host can not be resolved"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/es-419.json b/homeassistant/components/cert_expiry/.translations/es-419.json
index e350faffcb3..4e0b1ffca5d 100644
--- a/homeassistant/components/cert_expiry/.translations/es-419.json
+++ b/homeassistant/components/cert_expiry/.translations/es-419.json
@@ -1,15 +1,8 @@
{
"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"
+ "resolve_failed": "Este host no puede resolverse"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/es.json b/homeassistant/components/cert_expiry/.translations/es.json
index 628f2b22e21..7cc44d7038a 100644
--- a/homeassistant/components/cert_expiry/.translations/es.json
+++ b/homeassistant/components/cert_expiry/.translations/es.json
@@ -2,17 +2,12 @@
"config": {
"abort": {
"already_configured": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada",
- "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada",
"import_failed": "No se pudo importar desde la configuraci\u00f3n"
},
"error": {
- "certificate_error": "El certificado no pudo ser validado",
- "certificate_fetch_failed": "No se puede obtener el certificado de esta combinaci\u00f3n de host y puerto",
"connection_refused": "Conexi\u00f3n rechazada al conectarse al host",
"connection_timeout": "Tiempo de espera agotado al conectar a este host",
- "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada",
- "resolve_failed": "Este host no se puede resolver",
- "wrong_host": "El certificado no coincide con el nombre de host"
+ "resolve_failed": "Este host no se puede resolver"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/fr.json b/homeassistant/components/cert_expiry/.translations/fr.json
index 9e7df5564a2..18398a2b048 100644
--- a/homeassistant/components/cert_expiry/.translations/fr.json
+++ b/homeassistant/components/cert_expiry/.translations/fr.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "Cette combinaison h\u00f4te / port est d\u00e9j\u00e0 configur\u00e9e"
+ "already_configured": "Cette combinaison h\u00f4te et port est d\u00e9j\u00e0 configur\u00e9e",
+ "import_failed": "\u00c9chec de l'importation \u00e0 partir de la configuration"
},
"error": {
- "certificate_error": "Le certificat n'a pas pu \u00eatre valid\u00e9",
- "certificate_fetch_failed": "Impossible de r\u00e9cup\u00e9rer le certificat de cette combinaison h\u00f4te / port",
+ "connection_refused": "Connexion refus\u00e9e lors de la connexion \u00e0 l'h\u00f4te",
"connection_timeout": "D\u00e9lai d'attente lors de la connexion \u00e0 cet h\u00f4te",
- "host_port_exists": "Cette combinaison h\u00f4te / port est d\u00e9j\u00e0 configur\u00e9e",
- "resolve_failed": "Cet h\u00f4te ne peut pas \u00eatre r\u00e9solu",
- "wrong_host": "Le certificat ne correspond pas au nom d'h\u00f4te"
+ "resolve_failed": "Cet h\u00f4te ne peut pas \u00eatre r\u00e9solu"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/it.json b/homeassistant/components/cert_expiry/.translations/it.json
index d95b9cd84a1..e88afa7caef 100644
--- a/homeassistant/components/cert_expiry/.translations/it.json
+++ b/homeassistant/components/cert_expiry/.translations/it.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata"
+ "already_configured": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata",
+ "import_failed": "Importazione dalla configurazione non riuscita"
},
"error": {
- "certificate_error": "Il certificato non pu\u00f2 essere convalidato",
- "certificate_fetch_failed": "Non \u00e8 possibile recuperare il certificato da questa combinazione di host e porta",
+ "connection_refused": "Connessione rifiutata durante la connessione all'host",
"connection_timeout": "Tempo scaduto collegandosi a questo host",
- "host_port_exists": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata",
- "resolve_failed": "Questo host non pu\u00f2 essere risolto",
- "wrong_host": "Il certificato non corrisponde al nome host"
+ "resolve_failed": "Questo host non pu\u00f2 essere risolto"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/ko.json b/homeassistant/components/cert_expiry/.translations/ko.json
index 25c518f8629..962f9ebe42c 100644
--- a/homeassistant/components/cert_expiry/.translations/ko.json
+++ b/homeassistant/components/cert_expiry/.translations/ko.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ "already_configured": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "import_failed": "\uad6c\uc131\uc5d0\uc11c \uac00\uc838\uc624\uae30 \uc2e4\ud328"
},
"error": {
- "certificate_error": "\uc778\uc99d\uc11c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
- "certificate_fetch_failed": "\ud574\ub2f9 \ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uc5d0\uc11c \uc778\uc99d\uc11c\ub97c \uac00\uc838 \uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "connection_refused": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\uc774 \uac70\ubd80\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
"connection_timeout": "\ud638\uc2a4\ud2b8 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4",
- "host_port_exists": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
- "resolve_failed": "\ud638\uc2a4\ud2b8\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
- "wrong_host": "\uc778\uc99d\uc11c\uac00 \ud638\uc2a4\ud2b8 \uc774\ub984\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4"
+ "resolve_failed": "\ud638\uc2a4\ud2b8\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/lb.json b/homeassistant/components/cert_expiry/.translations/lb.json
index 14d12967a38..55ac013f96a 100644
--- a/homeassistant/components/cert_expiry/.translations/lb.json
+++ b/homeassistant/components/cert_expiry/.translations/lb.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "D\u00ebsen Host an Port sinn scho konfigur\u00e9iert"
+ "already_configured": "D\u00ebs Kombinatioun vun Host an Port sinn scho konfigur\u00e9iert",
+ "import_failed": "Import vun der Konfiguratioun feelgeschloen"
},
"error": {
- "certificate_error": "Zertifikat konnt net valid\u00e9iert ginn",
- "certificate_fetch_failed": "Kann keen Zertifikat vun d\u00ebsen Host a Port recuper\u00e9ieren",
+ "connection_refused": "Verbindung refus\u00e9iert beim verbannen mam Host",
"connection_timeout": "Z\u00e4it Iwwerschreidung beim verbannen.",
- "host_port_exists": "D\u00ebsen Host an Port sinn scho konfigur\u00e9iert",
- "resolve_failed": "D\u00ebsen Host kann net opgel\u00e9ist ginn",
- "wrong_host": "Zertifikat entspr\u00e9cht net den Numm vum Apparat"
+ "resolve_failed": "D\u00ebsen Host kann net opgel\u00e9ist ginn"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/nl.json b/homeassistant/components/cert_expiry/.translations/nl.json
index 0544c8c02c1..7c9fbe67565 100644
--- a/homeassistant/components/cert_expiry/.translations/nl.json
+++ b/homeassistant/components/cert_expiry/.translations/nl.json
@@ -1,15 +1,8 @@
{
"config": {
- "abort": {
- "host_port_exists": "Deze combinatie van host en poort is al geconfigureerd"
- },
"error": {
- "certificate_error": "Certificaat kon niet worden gevalideerd",
- "certificate_fetch_failed": "Kan certificaat niet ophalen van deze combinatie van host en poort",
"connection_timeout": "Time-out bij verbinding maken met deze host",
- "host_port_exists": "Deze combinatie van host en poort is al geconfigureerd",
- "resolve_failed": "Deze host kon niet gevonden worden",
- "wrong_host": "Certificaat komt niet overeen met hostnaam"
+ "resolve_failed": "Deze host kon niet gevonden worden"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/no.json b/homeassistant/components/cert_expiry/.translations/no.json
index e5faab74995..a798ead27b6 100644
--- a/homeassistant/components/cert_expiry/.translations/no.json
+++ b/homeassistant/components/cert_expiry/.translations/no.json
@@ -2,17 +2,12 @@
"config": {
"abort": {
"already_configured": "Denne verts- og portkombinasjonen er allerede konfigurert",
- "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert",
"import_failed": "Import fra config mislyktes"
},
"error": {
- "certificate_error": "Sertifikatet kunne ikke valideres",
- "certificate_fetch_failed": "Kan ikke hente sertifikat fra denne verts- og portkombinasjonen",
"connection_refused": "Tilkoblingen ble nektet da den koblet til verten",
"connection_timeout": "Tidsavbrudd n\u00e5r du kobler til denne verten",
- "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert",
- "resolve_failed": "Denne verten kan ikke l\u00f8ses",
- "wrong_host": "Sertifikatet samsvarer ikke med vertsnavn"
+ "resolve_failed": "Denne verten kan ikke l\u00f8ses"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/pl.json b/homeassistant/components/cert_expiry/.translations/pl.json
index 2e50a9f8cbc..510b75658a2 100644
--- a/homeassistant/components/cert_expiry/.translations/pl.json
+++ b/homeassistant/components/cert_expiry/.translations/pl.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "Ten host z tym portem jest ju\u017c skonfigurowany."
+ "already_configured": "Ta kombinacja hosta i portu jest ju\u017c skonfigurowana",
+ "import_failed": "Import z konfiguracji nie powi\u00f3d\u0142 si\u0119"
},
"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_refused": "Po\u0142\u0105czenie odrzucone podczas \u0142\u0105czenia z hostem",
"connection_timeout": "Przekroczono limit czasu po\u0142\u0105czenia z hostem.",
- "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"
+ "resolve_failed": "Tego hosta nie mo\u017cna rozwi\u0105za\u0107"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/pt-BR.json b/homeassistant/components/cert_expiry/.translations/pt-BR.json
index 06534314e00..0c0e272e23b 100644
--- a/homeassistant/components/cert_expiry/.translations/pt-BR.json
+++ b/homeassistant/components/cert_expiry/.translations/pt-BR.json
@@ -1,12 +1,7 @@
{
"config": {
- "abort": {
- "host_port_exists": "Essa combina\u00e7\u00e3o de host e porta j\u00e1 est\u00e1 configurada"
- },
"error": {
- "certificate_fetch_failed": "N\u00e3o \u00e9 poss\u00edvel buscar o certificado dessa combina\u00e7\u00e3o de host e porta",
"connection_timeout": "Tempo limite ao conectar-se a este host",
- "host_port_exists": "Essa combina\u00e7\u00e3o de host e porta j\u00e1 est\u00e1 configurada",
"resolve_failed": "Este host n\u00e3o pode ser resolvido"
},
"step": {
diff --git a/homeassistant/components/cert_expiry/.translations/ru.json b/homeassistant/components/cert_expiry/.translations/ru.json
index 04a41704500..39c78acc4c0 100644
--- a/homeassistant/components/cert_expiry/.translations/ru.json
+++ b/homeassistant/components/cert_expiry/.translations/ru.json
@@ -2,17 +2,12 @@
"config": {
"abort": {
"already_configured": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.",
- "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.",
"import_failed": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u0430 \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438."
},
"error": {
- "certificate_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442.",
- "certificate_fetch_failed": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u0441 \u044d\u0442\u043e\u0439 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u0438 \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430.",
"connection_refused": "\u041f\u0440\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0445\u043e\u0441\u0442\u0443 \u0431\u044b\u043b\u043e \u043e\u0442\u043a\u0430\u0437\u0430\u043d\u043e \u0432 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438.",
"connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0445\u043e\u0441\u0442\u0443.",
- "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.",
- "resolve_failed": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u0442\u044c \u0445\u043e\u0441\u0442.",
- "wrong_host": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u043c\u0443 \u0438\u043c\u0435\u043d\u0438."
+ "resolve_failed": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u0442\u044c \u0445\u043e\u0441\u0442."
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/sl.json b/homeassistant/components/cert_expiry/.translations/sl.json
index d375c626c66..605eb0b8182 100644
--- a/homeassistant/components/cert_expiry/.translations/sl.json
+++ b/homeassistant/components/cert_expiry/.translations/sl.json
@@ -1,15 +1,13 @@
{
"config": {
"abort": {
- "host_port_exists": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana"
+ "already_configured": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana",
+ "import_failed": "Uvoz iz konfiguracije ni uspel"
},
"error": {
- "certificate_error": "Certifikata ni bilo mogo\u010de preveriti",
- "certificate_fetch_failed": "Iz te kombinacije gostitelja in vrat ni mogo\u010de pridobiti potrdila",
+ "connection_refused": "Povezava zavrnjena, ko ste se povezali z gostiteljem",
"connection_timeout": "\u010casovna omejitev za povezavo s tem gostiteljem je potekla",
- "host_port_exists": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana",
- "resolve_failed": "Tega gostitelja ni mogo\u010de razre\u0161iti",
- "wrong_host": "Potrdilo se ne ujema z imenom gostitelja"
+ "resolve_failed": "Tega gostitelja ni mogo\u010de razre\u0161iti"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/sv.json b/homeassistant/components/cert_expiry/.translations/sv.json
index bdccf51b2cd..2655bb40f08 100644
--- a/homeassistant/components/cert_expiry/.translations/sv.json
+++ b/homeassistant/components/cert_expiry/.translations/sv.json
@@ -1,15 +1,8 @@
{
"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"
+ "resolve_failed": "Denna v\u00e4rd kan inte resolveras"
},
"step": {
"user": {
diff --git a/homeassistant/components/cert_expiry/.translations/zh-Hant.json b/homeassistant/components/cert_expiry/.translations/zh-Hant.json
index 833e2370dde..f08e3e277e9 100644
--- a/homeassistant/components/cert_expiry/.translations/zh-Hant.json
+++ b/homeassistant/components/cert_expiry/.translations/zh-Hant.json
@@ -2,17 +2,12 @@
"config": {
"abort": {
"already_configured": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
- "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
"import_failed": "\u532f\u5165\u8a2d\u5b9a\u5931\u6557"
},
"error": {
- "certificate_error": "\u8a8d\u8b49\u7121\u6cd5\u78ba\u8a8d",
- "certificate_fetch_failed": "\u7121\u6cd5\u81ea\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u7372\u5f97\u8a8d\u8b49",
"connection_refused": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u6642\u906d\u62d2\u7d55",
"connection_timeout": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef\u903e\u6642",
- "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
- "resolve_failed": "\u4e3b\u6a5f\u7aef\u7121\u6cd5\u89e3\u6790",
- "wrong_host": "\u8a8d\u8b49\u8207\u4e3b\u6a5f\u540d\u7a31\u4e0d\u7b26\u5408"
+ "resolve_failed": "\u4e3b\u6a5f\u7aef\u7121\u6cd5\u89e3\u6790"
},
"step": {
"user": {
diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py
index 361367ffb4d..d5bbb60e27d 100644
--- a/homeassistant/components/config/auth.py
+++ b/homeassistant/components/config/auth.py
@@ -13,11 +13,6 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str}
)
-WS_TYPE_CREATE = "config/auth/create"
-SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
- {vol.Required("type"): WS_TYPE_CREATE, vol.Required("name"): str}
-)
-
async def async_setup(hass):
"""Enable the Home Assistant views."""
@@ -27,9 +22,7 @@ async def async_setup(hass):
hass.components.websocket_api.async_register_command(
WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE
)
- hass.components.websocket_api.async_register_command(
- WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE
- )
+ hass.components.websocket_api.async_register_command(websocket_create)
hass.components.websocket_api.async_register_command(websocket_update)
return True
@@ -70,9 +63,16 @@ async def websocket_delete(hass, connection, msg):
@websocket_api.require_admin
@websocket_api.async_response
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "config/auth/create",
+ vol.Required("name"): str,
+ vol.Optional("group_ids"): [str],
+ }
+)
async def websocket_create(hass, connection, msg):
"""Create a user."""
- user = await hass.auth.async_create_user(msg["name"])
+ user = await hass.auth.async_create_user(msg["name"], msg.get("group_ids"))
connection.send_message(
websocket_api.result_message(msg["id"], {"user": _user_info(user)})
diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py
index e1e6181d8ca..d03dbe1fe7b 100644
--- a/homeassistant/components/configurator/__init__.py
+++ b/homeassistant/components/configurator/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
EVENT_TIME_CHANGED,
)
-from homeassistant.core import callback as async_callback
+from homeassistant.core import Event, callback as async_callback
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.loader import bind_hass
from homeassistant.util.async_ import run_callback_threadsafe
@@ -214,9 +214,9 @@ class Configurator:
# it shortly after so that it is deleted when the client updates.
self.hass.states.async_set(entity_id, STATE_CONFIGURED)
- def deferred_remove(event):
+ def deferred_remove(event: Event):
"""Remove the request state."""
- self.hass.states.async_remove(entity_id)
+ self.hass.states.async_remove(entity_id, context=event.context)
self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove)
diff --git a/homeassistant/components/coolmaster/.translations/no.json b/homeassistant/components/coolmaster/.translations/no.json
index 90c40aaa617..e9859d23989 100644
--- a/homeassistant/components/coolmaster/.translations/no.json
+++ b/homeassistant/components/coolmaster/.translations/no.json
@@ -18,6 +18,6 @@
"title": "Konfigurer informasjonen om CoolMasterNet-tilkoblingen."
}
},
- "title": "CoolMasterNet"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/ca.json b/homeassistant/components/coronavirus/.translations/ca.json
new file mode 100644
index 00000000000..43bd868d0c4
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/ca.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Aquest pa\u00eds ja est\u00e0 configurat."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Pa\u00eds"
+ },
+ "title": "Tria un pa\u00eds a monitoritzar"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/da.json b/homeassistant/components/coronavirus/.translations/da.json
new file mode 100644
index 00000000000..5f3dc09cf20
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/da.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dette land er allerede konfigureret."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Land"
+ },
+ "title": "V\u00e6lg et land at overv\u00e5ge"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/de.json b/homeassistant/components/coronavirus/.translations/de.json
new file mode 100644
index 00000000000..d3602540349
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/de.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dieses Land ist bereits konfiguriert."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Land"
+ },
+ "title": "W\u00e4hlen Sie ein Land aus, das \u00fcberwacht werden soll"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/fr.json b/homeassistant/components/coronavirus/.translations/fr.json
new file mode 100644
index 00000000000..923a4cdc819
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/fr.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Pays"
+ },
+ "title": "Choisissez un pays \u00e0 surveiller"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/hu.json b/homeassistant/components/coronavirus/.translations/hu.json
new file mode 100644
index 00000000000..171aedc801d
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/hu.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ez az orsz\u00e1g m\u00e1r konfigur\u00e1lva van."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Orsz\u00e1g"
+ },
+ "title": "V\u00e1lassz egy orsz\u00e1got a megfigyel\u00e9shez"
+ }
+ },
+ "title": "Koronav\u00edrus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/it.json b/homeassistant/components/coronavirus/.translations/it.json
new file mode 100644
index 00000000000..6fc6bd8f811
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/it.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Questa Nazione \u00e8 gi\u00e0 configurata."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Nazione"
+ },
+ "title": "Scegliere una Nazione da monitorare"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/ko.json b/homeassistant/components/coronavirus/.translations/ko.json
new file mode 100644
index 00000000000..8c03db18527
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/ko.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc774 \uad6d\uac00\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "\uad6d\uac00"
+ },
+ "title": "\ubaa8\ub2c8\ud130\ub9c1 \ud560 \uad6d\uac00\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694"
+ }
+ },
+ "title": "\ucf54\ub85c\ub098 \ubc14\uc774\ub7ec\uc2a4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/no.json b/homeassistant/components/coronavirus/.translations/no.json
index ef5d75ac2a9..03a3ff49916 100644
--- a/homeassistant/components/coronavirus/.translations/no.json
+++ b/homeassistant/components/coronavirus/.translations/no.json
@@ -11,6 +11,6 @@
"title": "Velg et land du vil overv\u00e5ke"
}
},
- "title": "Coronavirus"
+ "title": "Koronavirus"
}
}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/pl.json b/homeassistant/components/coronavirus/.translations/pl.json
new file mode 100644
index 00000000000..9862d924ca4
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/pl.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ten kraj jest ju\u017c skonfigurowany."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Kraj"
+ },
+ "title": "Wybierz kraj do monitorowania"
+ }
+ },
+ "title": "Koronawirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/sl.json b/homeassistant/components/coronavirus/.translations/sl.json
new file mode 100644
index 00000000000..180de6d8c18
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/sl.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ta dr\u017eava je \u017ee nastavljena."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Dr\u017eava"
+ },
+ "title": "Izberite dr\u017eavo za spremljanje"
+ }
+ },
+ "title": "Koronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cover/.translations/ca.json b/homeassistant/components/cover/.translations/ca.json
index bbff63722d2..1a0f0544698 100644
--- a/homeassistant/components/cover/.translations/ca.json
+++ b/homeassistant/components/cover/.translations/ca.json
@@ -2,7 +2,9 @@
"device_automation": {
"action_type": {
"close": "Tanca {entity_name}",
+ "close_tilt": "Inclinaci\u00f3 {entity_name} tancat/ada",
"open": "Obre {entity_name}",
+ "open_tilt": "Inclinaci\u00f3 {entity_name} obert/a",
"set_position": "Estableix la posici\u00f3 de {entity_name}",
"set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}"
},
diff --git a/homeassistant/components/cover/.translations/da.json b/homeassistant/components/cover/.translations/da.json
index 64b89be5267..29691b4154b 100644
--- a/homeassistant/components/cover/.translations/da.json
+++ b/homeassistant/components/cover/.translations/da.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Luk {entity_name}",
+ "close_tilt": "Luk vippeposition for {entity_name}",
+ "open": "\u00c5bn {entity_name}",
+ "open_tilt": "\u00c5bn vippeposition for {entity_name}",
+ "set_position": "Indstil {entity_name}-position",
+ "set_tilt_position": "Angiv vippeposition for {entity_name}"
+ },
"condition_type": {
"is_closed": "{entity_name} er lukket",
"is_closing": "{entity_name} lukker",
diff --git a/homeassistant/components/cover/.translations/de.json b/homeassistant/components/cover/.translations/de.json
index 24589c733b8..9a9f0be21e2 100644
--- a/homeassistant/components/cover/.translations/de.json
+++ b/homeassistant/components/cover/.translations/de.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Schlie\u00dfe {entity_name}",
+ "close_tilt": "{entity_name} gekippt schlie\u00dfen",
+ "open": "\u00d6ffne {entity_name}",
+ "open_tilt": "{entity_name} gekippt \u00f6ffnen",
+ "set_position": "Position von {entity_name} setzen",
+ "set_tilt_position": "Neigeposition von {entity_name} einstellen"
+ },
"condition_type": {
"is_closed": "{entity_name} ist geschlossen",
"is_closing": "{entity_name} wird geschlossen",
diff --git a/homeassistant/components/cover/.translations/fr.json b/homeassistant/components/cover/.translations/fr.json
index 3aa877637d9..83bd5df826e 100644
--- a/homeassistant/components/cover/.translations/fr.json
+++ b/homeassistant/components/cover/.translations/fr.json
@@ -1,5 +1,8 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Fermer {entity_name}"
+ },
"condition_type": {
"is_closed": "{entity_name} est ferm\u00e9",
"is_closing": "{entity_name} se ferme",
diff --git a/homeassistant/components/cover/.translations/hu.json b/homeassistant/components/cover/.translations/hu.json
index d460c53109d..5e91736a263 100644
--- a/homeassistant/components/cover/.translations/hu.json
+++ b/homeassistant/components/cover/.translations/hu.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "{entity_name} z\u00e1r\u00e1sa",
+ "close_tilt": "{entity_name} d\u00f6nt\u00e9s z\u00e1r\u00e1sa",
+ "open": "{entity_name} nyit\u00e1sa",
+ "open_tilt": "{entity_name} d\u00f6nt\u00e9s nyit\u00e1sa",
+ "set_position": "{entity_name} poz\u00edci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa",
+ "set_tilt_position": "{entity_name} d\u00f6nt\u00e9si poz\u00edci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa"
+ },
"condition_type": {
"is_closed": "{entity_name} z\u00e1rva van",
"is_closing": "{entity_name} z\u00e1r\u00f3dik",
diff --git a/homeassistant/components/cover/.translations/ko.json b/homeassistant/components/cover/.translations/ko.json
index 145938b6f24..ae67663f46f 100644
--- a/homeassistant/components/cover/.translations/ko.json
+++ b/homeassistant/components/cover/.translations/ko.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "{entity_name} \ub2eb\uae30",
+ "close_tilt": "{entity_name} \ub2eb\uae30",
+ "open": "{entity_name} \uc5f4\uae30",
+ "open_tilt": "{entity_name} \uc5f4\uae30",
+ "set_position": "{entity_name} \uac1c\ud3d0 \uc704\uce58 \uc124\uc815\ud558\uae30",
+ "set_tilt_position": "{entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30 \uc124\uc815\ud558\uae30"
+ },
"condition_type": {
"is_closed": "{entity_name} \uc774(\uac00) \ub2eb\ud600 \uc788\uc73c\uba74",
"is_closing": "{entity_name} \uc774(\uac00) \ub2eb\ud788\ub294 \uc911\uc774\uba74",
diff --git a/homeassistant/components/cover/.translations/lb.json b/homeassistant/components/cover/.translations/lb.json
index 41c29adf91d..4cbbf348872 100644
--- a/homeassistant/components/cover/.translations/lb.json
+++ b/homeassistant/components/cover/.translations/lb.json
@@ -1,7 +1,10 @@
{
"device_automation": {
"action_type": {
+ "close": "{entity_name} zoumaachen",
+ "close_tilt": "{entity_name} Kipp zoumaachen",
"open": "{entity_name} opmaachen",
+ "open_tilt": "{entity_name} op Kipp stelle",
"set_position": "{entity_name} positioun programm\u00e9ieren",
"set_tilt_position": "{entity_name} kipp positioun programm\u00e9ieren"
},
@@ -10,8 +13,8 @@
"is_closing": "{entity_name} g\u00ebtt zougemaach",
"is_open": "{entity_name} ass op",
"is_opening": "{entity_name} g\u00ebtt opgemaach",
- "is_position": "{entity_name} positioun ass",
- "is_tilt_position": "{entity_name} kipp positioun ass"
+ "is_position": "Aktuell {entity_name} positioun ass",
+ "is_tilt_position": "Aktuell {entity_name} kipp positioun ass"
},
"trigger_type": {
"closed": "{entity_name} gouf zougemaach",
diff --git a/homeassistant/components/cover/.translations/pl.json b/homeassistant/components/cover/.translations/pl.json
index 718c4b86fbd..ce035b2533e 100644
--- a/homeassistant/components/cover/.translations/pl.json
+++ b/homeassistant/components/cover/.translations/pl.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "zamknij {entity_name}",
+ "close_tilt": "zamknij pochylenie {entity_name}",
+ "open": "otw\u00f3rz {entity_name}",
+ "open_tilt": "otw\u00f3rz {entity_name} do pochylenia",
+ "set_position": "ustaw pozycj\u0119 {entity_name}",
+ "set_tilt_position": "ustaw pochylenie {entity_name}"
+ },
"condition_type": {
"is_closed": "pokrywa {entity_name} jest zamkni\u0119ta",
"is_closing": "{entity_name} si\u0119 zamyka",
diff --git a/homeassistant/components/cover/.translations/sl.json b/homeassistant/components/cover/.translations/sl.json
index cd3570d39ba..818f17d58fe 100644
--- a/homeassistant/components/cover/.translations/sl.json
+++ b/homeassistant/components/cover/.translations/sl.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Zapri {entity_name}",
+ "close_tilt": "Zapri {entity_name} nagib",
+ "open": "Odprite {entity_name}",
+ "open_tilt": "Odprite {entity_name} nagib",
+ "set_position": "Nastavite polo\u017eaj {entity_name}",
+ "set_tilt_position": "Nastavite {entity_name} nagibni polo\u017eaj"
+ },
"condition_type": {
"is_closed": "{entity_name} je/so zaprt/a",
"is_closing": "{entity_name} se zapira/jo",
diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py
index abefd3263bc..e63054d23b2 100644
--- a/homeassistant/components/cover/__init__.py
+++ b/homeassistant/components/cover/__init__.py
@@ -46,9 +46,11 @@ DEVICE_CLASS_CURTAIN = "curtain"
DEVICE_CLASS_DAMPER = "damper"
DEVICE_CLASS_DOOR = "door"
DEVICE_CLASS_GARAGE = "garage"
+DEVICE_CLASS_GATE = "gate"
DEVICE_CLASS_SHADE = "shade"
DEVICE_CLASS_SHUTTER = "shutter"
DEVICE_CLASS_WINDOW = "window"
+
DEVICE_CLASSES = [
DEVICE_CLASS_AWNING,
DEVICE_CLASS_BLIND,
@@ -56,6 +58,7 @@ DEVICE_CLASSES = [
DEVICE_CLASS_DAMPER,
DEVICE_CLASS_DOOR,
DEVICE_CLASS_GARAGE,
+ DEVICE_CLASS_GATE,
DEVICE_CLASS_SHADE,
DEVICE_CLASS_SHUTTER,
DEVICE_CLASS_WINDOW,
diff --git a/homeassistant/components/daikin/.translations/no.json b/homeassistant/components/daikin/.translations/no.json
index 806106c5e52..30feb3b5acc 100644
--- a/homeassistant/components/daikin/.translations/no.json
+++ b/homeassistant/components/daikin/.translations/no.json
@@ -14,6 +14,6 @@
"title": "Konfigurer Daikin AC"
}
},
- "title": "Daikin AC"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json
index fb75fc81f5f..3bcbb592301 100644
--- a/homeassistant/components/deconz/.translations/bg.json
+++ b/homeassistant/components/deconz/.translations/bg.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee \u0448\u043b\u044e\u0437 ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u043d\u0438 \u0441\u0435\u043d\u0437\u043e\u0440\u0438",
- "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0438 \u043e\u0442 deCONZ"
- },
"description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0437\u0432\u0430 \u0441 deCONZ \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f, \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430 \u0437\u0430 hass.io {addon}?",
"title": "deCONZ Zigbee \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430"
},
@@ -31,13 +27,6 @@
"link": {
"description": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438 deCONZ \u0448\u043b\u044e\u0437\u0430 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430 \u0441 Home Assistant.\n\n1. \u041e\u0442\u0438\u0434\u0435\u0442\u0435 \u043d\u0430 deCONZ Settings -> Gateway -> Advanced\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Authenticate app\"",
"title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u043d\u0438 \u0441\u0435\u043d\u0437\u043e\u0440\u0438",
- "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0438 \u043e\u0442 deCONZ"
- },
- "title": "\u0414\u043e\u043f\u044a\u043b\u043d\u0438\u0442\u0435\u043b\u043d\u0438 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 deCONZ"
}
},
"title": "deCONZ Zigbee \u0448\u043b\u044e\u0437"
@@ -90,13 +79,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ CLIP \u0441\u0435\u043d\u0437\u043e\u0440\u0438",
- "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u043d\u0438 \u0433\u0440\u0443\u043f\u0438"
- },
- "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0442\u0430 \u043d\u0430 \u0442\u0438\u043f\u043e\u0432\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 deCONZ"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ CLIP \u0441\u0435\u043d\u0437\u043e\u0440\u0438",
diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json
index e690d597dce..ee386bece55 100644
--- a/homeassistant/components/deconz/.translations/ca.json
+++ b/homeassistant/components/deconz/.translations/ca.json
@@ -14,10 +14,6 @@
"flow_title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals",
- "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ"
- },
"description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb la passarel\u00b7la deCONZ proporcionada pel complement de Hass.io: {addon}?",
"title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee (complement de Hass.io)"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ -> Passarel\u00b7la -> Avan\u00e7at\n2. Prem el bot\u00f3 \"Autenticar applicaci\u00f3\"",
"title": "Vincular amb deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals",
- "allow_deconz_groups": "Permetre la importaci\u00f3 de grups deCONZ"
- },
- "title": "Opcions de configuraci\u00f3 addicionals de deCONZ"
}
},
"title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Permet sensors deCONZ CLIP",
- "allow_deconz_groups": "Permet grups de llums deCONZ"
- },
- "description": "Configura la visibilitat dels tipus dels dispositius deCONZ"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Permet sensors deCONZ CLIP",
diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json
index 954d1c8eb6e..544ab0ff2ed 100644
--- a/homeassistant/components/deconz/.translations/cs.json
+++ b/homeassistant/components/deconz/.translations/cs.json
@@ -24,13 +24,6 @@
"link": {
"description": "Odemkn\u011bte br\u00e1nu deCONZ, pro registraci v Home Assistant. \n\n 1. P\u0159ejd\u011bte do nastaven\u00ed syst\u00e9mu deCONZ \n 2. Stiskn\u011bte tla\u010d\u00edtko \"Unlock Gateway\"",
"title": "Propojit s deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel",
- "allow_deconz_groups": "Povolit import skupin deCONZ"
- },
- "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ"
}
},
"title": "Br\u00e1na deCONZ Zigbee"
diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json
index d1af7e1f4ba..91dd0ea9a54 100644
--- a/homeassistant/components/deconz/.translations/da.json
+++ b/homeassistant/components/deconz/.translations/da.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee gateway ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Tillad import af virtuelle sensorer",
- "allow_deconz_groups": "Tillad import af deCONZ-grupper"
- },
"description": "Vil du konfigurere Home Assistant til at oprette forbindelse til deCONZ-gateway'en leveret af Hass.io-tilf\u00f8jelsen {addon}?",
"title": "deCONZ Zigbee-gateway via Hass.io-tilf\u00f8jelse"
},
@@ -31,13 +27,6 @@
"link": {
"description": "L\u00e5s din deCONZ-gateway op for at registrere dig med Home Assistant. \n\n 1. G\u00e5 til deCONZ settings -> Gateway -> Advanced\n 2. Tryk p\u00e5 knappen \"Authenticate app\"",
"title": "Forbind med deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Tillad import af virtuelle sensorer",
- "allow_deconz_groups": "Tillad import af deCONZ-grupper"
- },
- "title": "Ekstra konfigurationsindstillinger for deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Tillad deCONZ CLIP-sensorer",
- "allow_deconz_groups": "Tillad deCONZ-lysgrupper"
- },
- "description": "Konfigurer synligheden af deCONZ-enhedstyper"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Tillad deCONZ CLIP-sensorer",
diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json
index c3ad3cd24c8..1b2daecbc4e 100644
--- a/homeassistant/components/deconz/.translations/de.json
+++ b/homeassistant/components/deconz/.translations/de.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee Gateway",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Import virtueller Sensoren zulassen",
- "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen"
- },
"description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem deCONZ Gateway herstellt, der vom Add-on hass.io {addon} bereitgestellt wird?",
"title": "deCONZ Zigbee Gateway \u00fcber das Hass.io Add-on"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Entsperre dein deCONZ-Gateway, um es bei Home Assistant zu registrieren. \n\n 1. Gehe in die deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"",
"title": "Mit deCONZ verbinden"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Import virtueller Sensoren zulassen",
- "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen"
- },
- "title": "Weitere Konfigurationsoptionen f\u00fcr deCONZ"
}
},
"title": "deCONZ Zigbee Gateway"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen",
- "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen"
- },
- "description": "Konfigurieren der Sichtbarkeit von deCONZ-Ger\u00e4tetypen"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen",
diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json
index 756636ad90a..2c9562359f5 100644
--- a/homeassistant/components/deconz/.translations/en.json
+++ b/homeassistant/components/deconz/.translations/en.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee gateway ({host})",
"step": {
"hassio_confirm": {
- "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}?",
"title": "deCONZ Zigbee gateway via Hass.io add-on"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button",
"title": "Link with deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Allow importing virtual sensors",
- "allow_deconz_groups": "Allow importing deCONZ groups"
- },
- "title": "Extra configuration options for deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Allow deCONZ CLIP sensors",
- "allow_deconz_groups": "Allow deCONZ light groups"
- },
- "description": "Configure visibility of deCONZ device types"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Allow deCONZ CLIP sensors",
diff --git a/homeassistant/components/deconz/.translations/es-419.json b/homeassistant/components/deconz/.translations/es-419.json
index 448b654c86e..ea65ffbab33 100644
--- a/homeassistant/components/deconz/.translations/es-419.json
+++ b/homeassistant/components/deconz/.translations/es-419.json
@@ -13,10 +13,6 @@
},
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales",
- "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ"
- },
"description": "\u00bfDesea configurar Home Assistant para conectarse a la puerta de enlace deCONZ proporcionada por el complemento hass.io {addon}?",
"title": "deCONZ Zigbee gateway a trav\u00e9s del complemento Hass.io"
},
@@ -30,13 +26,6 @@
"link": {
"description": "Desbloquee su puerta de enlace deCONZ para registrarse con Home Assistant. \n\n 1. Vaya a Configuraci\u00f3n deCONZ - > Gateway - > Avanzado \n 2. Presione el bot\u00f3n \"Autenticar aplicaci\u00f3n\"",
"title": "Enlazar con deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales",
- "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ"
- },
- "title": "Opciones de configuraci\u00f3n adicionales para deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
@@ -59,16 +48,5 @@
"remote_button_rotated": "Bot\u00f3n girado \"{subtype}\"",
"remote_gyro_activated": "Dispositivo agitado"
}
- },
- "options": {
- "step": {
- "async_step_deconz_devices": {
- "data": {
- "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"
- }
- }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json
index 047be1c7933..517170fe225 100644
--- a/homeassistant/components/deconz/.translations/es.json
+++ b/homeassistant/components/deconz/.translations/es.json
@@ -14,10 +14,6 @@
"flow_title": "pasarela deCONZ Zigbee ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Permitir importar sensores virtuales",
- "allow_deconz_groups": "Permite importar grupos de deCONZ"
- },
"description": "\u00bfQuieres configurar Home Assistant para que se conecte al gateway de deCONZ proporcionado por el add-on {addon} de hass.io?",
"title": "Add-on deCONZ Zigbee v\u00eda Hass.io"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Desbloquea tu gateway de deCONZ para registrarte con Home Assistant.\n\n1. Dir\u00edgete a deCONZ Settings -> Gateway -> Advanced\n2. Pulsa el bot\u00f3n \"Authenticate app\"",
"title": "Enlazar con deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Permitir importar sensores virtuales",
- "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ"
- },
- "title": "Opciones de configuraci\u00f3n adicionales para deCONZ"
}
},
"title": "Pasarela Zigbee deCONZ"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "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"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Permitir sensores deCONZ CLIP",
diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json
index 214c887cc34..0c2ecf9edb8 100644
--- a/homeassistant/components/deconz/.translations/fr.json
+++ b/homeassistant/components/deconz/.translations/fr.json
@@ -14,10 +14,6 @@
"flow_title": "Passerelle deCONZ Zigbee ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels",
- "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ"
- },
"description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 la passerelle deCONZ fournie par l'add-on hass.io {addon} ?",
"title": "Passerelle deCONZ Zigbee via l'add-on Hass.io"
},
@@ -31,13 +27,6 @@
"link": {
"description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer avec Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres avanc\u00e9s du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"",
"title": "Lien vers deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels",
- "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ"
- },
- "title": "Options de configuration suppl\u00e9mentaires pour deCONZ"
}
},
"title": "Passerelle deCONZ Zigbee"
@@ -62,20 +51,20 @@
"side_5": "Face 5",
"side_6": "Face 6",
"turn_off": "\u00c9teint",
- "turn_on": "Allum\u00e9"
+ "turn_on": "Allumer"
},
"trigger_type": {
"remote_awakened": "Appareil r\u00e9veill\u00e9",
- "remote_button_double_press": "Bouton \"{subtype}\" double cliqu\u00e9",
- "remote_button_long_press": "Bouton \"{subtype}\" appuy\u00e9 continuellement",
+ "remote_button_double_press": "Double clic sur le bouton \" {subtype} \"",
+ "remote_button_long_press": "Appuyer en continu sur le bouton \" {subtype} \"",
"remote_button_long_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9 apr\u00e8s appui long",
- "remote_button_quadruple_press": "Bouton \"{subtype}\" quadruple cliqu\u00e9",
- "remote_button_quintuple_press": "Bouton \"{subtype}\" quintuple cliqu\u00e9",
+ "remote_button_quadruple_press": "Quadruple clic sur le bouton \" {subtype} \"",
+ "remote_button_quintuple_press": "Quintuple clic sur le bouton \" {subtype} \"",
"remote_button_rotated": "Bouton \"{subtype}\" tourn\u00e9",
"remote_button_rotation_stopped": "La rotation du bouton \" {subtype} \" s'est arr\u00eat\u00e9e",
"remote_button_short_press": "Bouton \"{subtype}\" appuy\u00e9",
"remote_button_short_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9",
- "remote_button_triple_press": "Bouton \"{subtype}\" triple cliqu\u00e9",
+ "remote_button_triple_press": "Triple clic sur le bouton \" {subtype} \"",
"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",
@@ -96,19 +85,13 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP",
- "allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ"
- },
- "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP",
"allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ"
},
- "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ"
+ "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ",
+ "title": "Options deCONZ"
}
}
}
diff --git a/homeassistant/components/deconz/.translations/he.json b/homeassistant/components/deconz/.translations/he.json
index 89a2d69950e..da7878e94af 100644
--- a/homeassistant/components/deconz/.translations/he.json
+++ b/homeassistant/components/deconz/.translations/he.json
@@ -19,13 +19,6 @@
"link": {
"description": "\u05d1\u05d8\u05dc \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea \u05d4\u05de\u05e9\u05e8 deCONZ \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd Home Assistant.\n\n 1. \u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea deCONZ \n .2 \u05dc\u05d7\u05e5 \u05e2\u05dc \"Unlock Gateway\"",
"title": "\u05e7\u05e9\u05e8 \u05e2\u05dd deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d5\u05d9\u05e8\u05d8\u05d5\u05d0\u05dc\u05d9\u05d9\u05dd",
- "allow_deconz_groups": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05e7\u05d1\u05d5\u05e6\u05d5\u05ea deCONZ"
- },
- "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e0\u05d5\u05e1\u05e4\u05d5\u05ea \u05e2\u05d1\u05d5\u05e8 deCONZ"
}
},
"title": "\u05de\u05d2\u05e9\u05e8 deCONZ Zigbee"
diff --git a/homeassistant/components/deconz/.translations/hr.json b/homeassistant/components/deconz/.translations/hr.json
index 2f2eb6df214..1700ec050bf 100644
--- a/homeassistant/components/deconz/.translations/hr.json
+++ b/homeassistant/components/deconz/.translations/hr.json
@@ -6,11 +6,6 @@
"host": "Host",
"port": "Port"
}
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Dopusti uvoz virtualnih senzora"
- }
}
}
}
diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json
index c5bf9718127..31148c80e30 100644
--- a/homeassistant/components/deconz/.translations/hu.json
+++ b/homeassistant/components/deconz/.translations/hu.json
@@ -14,9 +14,6 @@
"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": {
@@ -29,13 +26,6 @@
"link": {
"description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot",
"title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Virtu\u00e1lis szenzorok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se",
- "allow_deconz_groups": "deCONZ csoportok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se"
- },
- "title": "Extra be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gek a deCONZhoz"
}
},
"title": "deCONZ Zigbee gateway"
@@ -94,13 +84,6 @@
},
"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",
diff --git a/homeassistant/components/deconz/.translations/id.json b/homeassistant/components/deconz/.translations/id.json
index 7d0b3163a40..72aaa84e70d 100644
--- a/homeassistant/components/deconz/.translations/id.json
+++ b/homeassistant/components/deconz/.translations/id.json
@@ -19,13 +19,6 @@
"link": {
"description": "Buka gerbang deCONZ Anda untuk mendaftar dengan Home Assistant. \n\n 1. Pergi ke pengaturan sistem deCONZ \n 2. Tekan tombol \"Buka Kunci Gateway\"",
"title": "Tautan dengan deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Izinkan mengimpor sensor virtual",
- "allow_deconz_groups": "Izinkan mengimpor grup deCONZ"
- },
- "title": "Opsi konfigurasi tambahan untuk deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json
index f6223cec6c1..e12668f082c 100644
--- a/homeassistant/components/deconz/.translations/it.json
+++ b/homeassistant/components/deconz/.translations/it.json
@@ -14,10 +14,6 @@
"flow_title": "Gateway Zigbee deCONZ ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Consenti l'importazione di sensori virtuali",
- "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ"
- },
"description": "Vuoi configurare Home Assistant per connettersi al gateway deCONZ fornito dal componente aggiuntivo di Hass.io: {addon}?",
"title": "Gateway Pigmee deCONZ tramite il componente aggiuntivo di Hass.io"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Sblocca il tuo gateway deCONZ per registrarti con Home Assistant.\n\n1. Vai a Impostazioni deCONZ -> Gateway -> Avanzate\n2. Premere il pulsante \"Autentica app\"",
"title": "Collega con deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Consenti l'importazione di sensori virtuali",
- "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ"
- },
- "title": "Opzioni di configurazione extra per deCONZ"
}
},
"title": "Gateway Zigbee deCONZ"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Consentire sensori CLIP deCONZ",
- "allow_deconz_groups": "Consentire gruppi luce deCONZ"
- },
- "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Consentire sensori CLIP deCONZ",
diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json
index 1b72545bc09..00b9c1f437a 100644
--- a/homeassistant/components/deconz/.translations/ko.json
+++ b/homeassistant/components/deconz/.translations/ko.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774 ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9",
- "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9"
- },
"description": "Hass.io {addon} \uc560\ub4dc\uc628\uc5d0\uc11c \uc81c\uacf5\ub41c deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
"title": "Hass.io \uc560\ub4dc\uc628\uc758 deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774"
},
@@ -31,13 +27,6 @@
"link": {
"description": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \uc5b8\ub77d\ud558\uc5ec Home Assistant \uc5d0 \uc5f0\uacb0\ud558\uae30.\n\n1. deCONZ \uc2dc\uc2a4\ud15c \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc138\uc694\n2. \"Authenticate app\" \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694",
"title": "deCONZ\uc640 \uc5f0\uacb0"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9",
- "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9"
- },
- "title": "deCONZ \ucd94\uac00 \uad6c\uc131 \uc635\uc158"
}
},
"title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "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"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9",
diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json
index 42fd840524f..61479cb78e2 100644
--- a/homeassistant/components/deconz/.translations/lb.json
+++ b/homeassistant/components/deconz/.translations/lb.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee gateway ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren",
- "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen"
- },
"description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mat der deCONZ gateway ze verbannen d\u00e9i vum hass.io add-on {addon} bereet gestallt g\u00ebtt?",
"title": "deCONZ Zigbee gateway via Hass.io add-on"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen",
"title": "Link mat deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren",
- "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen"
- },
- "title": "Extra Konfiguratiouns Optiounen fir deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "deCONZ Clip Sensoren erlaben",
- "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben"
- },
- "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "deCONZ Clip Sensoren erlaben",
diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json
index 585c09c5339..611d38ba950 100644
--- a/homeassistant/components/deconz/.translations/nl.json
+++ b/homeassistant/components/deconz/.translations/nl.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee gateway ( {host} )",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe",
- "allow_deconz_groups": "Sta de import van deCONZ-groepen toe"
- },
"description": "Wilt u de Home Assistant configureren om verbinding te maken met de deCONZ gateway van de hass.io add-on {addon}?",
"title": "deCONZ Zigbee Gateway via Hass.io add-on"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen (Instellingen -> Gateway -> Geavanceerd)\n2. Druk op de knop \"Gateway ontgrendelen\"",
"title": "Koppel met deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe",
- "allow_deconz_groups": "Sta de import van deCONZ-groepen toe"
- },
- "title": "Extra configuratieopties voor deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "DeCONZ CLIP sensoren toestaan",
- "allow_deconz_groups": "DeCONZ-lichtgroepen toestaan"
- },
- "description": "De zichtbaarheid van deCONZ-apparaattypen configureren"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "DeCONZ CLIP sensoren toestaan",
diff --git a/homeassistant/components/deconz/.translations/nn.json b/homeassistant/components/deconz/.translations/nn.json
index 46933ced427..986795e11c9 100644
--- a/homeassistant/components/deconz/.translations/nn.json
+++ b/homeassistant/components/deconz/.translations/nn.json
@@ -19,13 +19,6 @@
"link": {
"description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere den med Home Assistant.\n\n1. G\u00e5 til systeminnstillingane til deCONZ\n2. Trykk p\u00e5 \"L\u00e5s opp gateway\"-knappen",
"title": "Link med deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Tillat importering av virtuelle sensorar",
- "allow_deconz_groups": "Tillat \u00e5 importera deCONZ-grupper"
- },
- "title": "Ekstra konfigurasjonsalternativ for deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json
index 3387c993ae0..a10ae01e25f 100644
--- a/homeassistant/components/deconz/.translations/no.json
+++ b/homeassistant/components/deconz/.translations/no.json
@@ -14,30 +14,19 @@
"flow_title": "deCONZ Zigbee gateway ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Tillat import av virtuelle sensorer",
- "allow_deconz_groups": "Tillat import av deCONZ grupper"
- },
"description": "Vil du konfigurere Home Assistant til \u00e5 koble seg til deCONZ-gateway levert av Hass.io-tillegget {addon} ?",
"title": "deCONZ Zigbee gateway via Hass.io tillegg"
},
"init": {
"data": {
"host": "Vert",
- "port": "Port"
+ "port": ""
},
"title": "Definer deCONZ-gatewayen"
},
"link": {
"description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger -> Gateway -> Avansert \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen",
"title": "Koble til deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Tillat import av virtuelle sensorer",
- "allow_deconz_groups": "Tillat import av deCONZ grupper"
- },
- "title": "Ekstra konfigurasjonsalternativer for deCONZ"
}
},
"title": "deCONZ Zigbee gateway"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer",
- "allow_deconz_groups": "Tillat deCONZ lys grupper"
- },
- "description": "Konfigurere synlighet av deCONZ enhetstyper"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Tillat deCONZ CLIP-sensorer",
diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json
index d12e633bf23..ace1f4182a4 100644
--- a/homeassistant/components/deconz/.translations/pl.json
+++ b/homeassistant/components/deconz/.translations/pl.json
@@ -14,10 +14,6 @@
"flow_title": "Bramka deCONZ Zigbee ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w",
- "allow_deconz_groups": "Zezwalaj na importowanie grup deCONZ"
- },
"description": "Czy chcesz skonfigurowa\u0107 Home Assistant, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek Hass.io {addon}?",
"title": "Bramka deCONZ Zigbee przez dodatek Hass.io"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawienia deCONZ > bramka > Zaawansowane\n 2. Naci\u015bnij przycisk \"Uwierzytelnij aplikacj\u0119\"",
"title": "Po\u0142\u0105cz z deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w",
- "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ"
- },
- "title": "Dodatkowe opcje konfiguracji dla deCONZ"
}
},
"title": "Brama deCONZ Zigbee"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP",
- "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ"
- },
- "description": "Skonfiguruj widoczno\u015b\u0107 urz\u0105dze\u0144 deCONZ"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP",
diff --git a/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant/components/deconz/.translations/pt-BR.json
index 8d54c470846..6d800bb0269 100644
--- a/homeassistant/components/deconz/.translations/pt-BR.json
+++ b/homeassistant/components/deconz/.translations/pt-BR.json
@@ -13,10 +13,6 @@
},
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais",
- "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ"
- },
"description": "Deseja configurar o Home Assistant para conectar-se ao gateway deCONZ fornecido pelo add-on hass.io {addon} ?",
"title": "Gateway deCONZ Zigbee via add-on Hass.io"
},
@@ -30,26 +26,8 @@
"link": {
"description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"",
"title": "Linkar com deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais",
- "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ"
- },
- "title": "Op\u00e7\u00f5es extras de configura\u00e7\u00e3o para deCONZ"
}
},
"title": "Gateway deCONZ Zigbee"
- },
- "options": {
- "step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Permitir sensores deCONZ CLIP",
- "allow_deconz_groups": "Permitir grupos de luz deCONZ"
- },
- "description": "Configurar visibilidade dos tipos de dispositivos deCONZ"
- }
- }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/pt.json b/homeassistant/components/deconz/.translations/pt.json
index f24d7692a55..f0ea9e57ca0 100644
--- a/homeassistant/components/deconz/.translations/pt.json
+++ b/homeassistant/components/deconz/.translations/pt.json
@@ -19,13 +19,6 @@
"link": {
"description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"",
"title": "Liga\u00e7\u00e3o com deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais",
- "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ"
- },
- "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o extra para deCONZ"
}
},
"title": "Gateway Zigbee deCONZ"
diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json
index 054c85f595a..4d89f5ff8e0 100644
--- a/homeassistant/components/deconz/.translations/ru.json
+++ b/homeassistant/components/deconz/.translations/ru.json
@@ -14,10 +14,6 @@
"flow_title": "\u0428\u043b\u044e\u0437 Zigbee deCONZ ({host})",
"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 \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}\")?",
"title": "Zigbee \u0448\u043b\u044e\u0437 deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)"
},
@@ -31,13 +27,6 @@
"link": {
"description": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0439\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432 Home Assistant:\n\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u044b deCONZ -> Gateway -> Advanced.\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00abAuthenticate app\u00bb.",
"title": "\u0421\u0432\u044f\u0437\u044c \u0441 deCONZ"
- },
- "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 \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"
}
},
"title": "deCONZ"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "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 \u0441\u0435\u043d\u0441\u043e\u0440\u044b deCONZ CLIP",
diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json
index 385de6f0f01..15927059d32 100644
--- a/homeassistant/components/deconz/.translations/sl.json
+++ b/homeassistant/components/deconz/.translations/sl.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee prehod ({host})",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev",
- "allow_deconz_groups": "Dovoli uvoz deCONZ skupin"
- },
"description": "Ali \u017eelite konfigurirati Home Assistant za povezavo s prehodom deCONZ, ki ga ponuja dodatek Hass.io {addon} ?",
"title": "deCONZ Zigbee prehod preko dodatka Hass.io"
},
@@ -31,13 +27,6 @@
"link": {
"description": "Odklenite va\u0161 deCONZ gateway za registracijo s Home Assistant-om. \n1. Pojdite v deCONZ sistemske nastavitve\n2. Pritisnite tipko \"odkleni prehod\"",
"title": "Povezava z deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev",
- "allow_deconz_groups": "Dovoli uvoz deCONZ skupin"
- },
- "title": "Dodatne mo\u017enosti konfiguracije za deCONZ"
}
},
"title": "deCONZ Zigbee prehod"
@@ -96,19 +85,13 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "allow_clip_sensor": "Dovoli deCONZ CLIP senzorje",
- "allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di"
- },
- "description": "Konfiguracija vidnosti tipov naprav deCONZ"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "Dovoli deCONZ CLIP senzorje",
"allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di"
},
- "description": "Konfiguracija vidnosti tipov naprav deCONZ"
+ "description": "Konfiguracija vidnosti tipov naprav deCONZ",
+ "title": "mo\u017enosti deCONZ"
}
}
}
diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json
index 3d74d6cb944..11a8aac485a 100644
--- a/homeassistant/components/deconz/.translations/sv.json
+++ b/homeassistant/components/deconz/.translations/sv.json
@@ -14,10 +14,6 @@
"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 att ansluta till den deCONZ-gateway som tillhandah\u00e5lls av Hass.io-till\u00e4gget {addon}?",
"title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg"
},
@@ -31,13 +27,6 @@
"link": {
"description": "L\u00e5s upp din deCONZ-gateway f\u00f6r att registrera dig med Home Assistant. \n\n 1. G\u00e5 till deCONZ-systeminst\u00e4llningarna \n 2. Tryck p\u00e5 \"L\u00e5s upp gateway\"-knappen",
"title": "L\u00e4nka med deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer",
- "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper"
- },
- "title": "Extra konfigurationsalternativ f\u00f6r deCONZ"
}
},
"title": "deCONZ Zigbee Gateway"
@@ -96,13 +85,6 @@
},
"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",
diff --git a/homeassistant/components/deconz/.translations/vi.json b/homeassistant/components/deconz/.translations/vi.json
index 00f1d9be57f..75d8969495b 100644
--- a/homeassistant/components/deconz/.translations/vi.json
+++ b/homeassistant/components/deconz/.translations/vi.json
@@ -13,13 +13,6 @@
"data": {
"port": "C\u1ed5ng (gi\u00e1 tr\u1ecb m\u1eb7c \u0111\u1ecbnh: '80')"
}
- },
- "options": {
- "data": {
- "allow_clip_sensor": "Cho ph\u00e9p nh\u1eadp c\u1ea3m bi\u1ebfn \u1ea3o",
- "allow_deconz_groups": "Cho ph\u00e9p nh\u1eadp c\u00e1c nh\u00f3m deCONZ"
- },
- "title": "T\u00f9y ch\u1ecdn c\u1ea5u h\u00ecnh b\u1ed5 sung cho deCONZ"
}
}
}
diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json
index ce51a54ac77..ada31494619 100644
--- a/homeassistant/components/deconz/.translations/zh-Hans.json
+++ b/homeassistant/components/deconz/.translations/zh-Hans.json
@@ -19,13 +19,6 @@
"link": {
"description": "\u89e3\u9501\u60a8\u7684 deCONZ \u7f51\u5173\u4ee5\u6ce8\u518c\u5230 Home Assistant\u3002 \n\n 1. \u524d\u5f80 deCONZ \u7cfb\u7edf\u8bbe\u7f6e\n 2. \u70b9\u51fb\u201c\u89e3\u9501\u7f51\u5173\u201d\u6309\u94ae",
"title": "\u8fde\u63a5 deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "\u5141\u8bb8\u5bfc\u5165\u865a\u62df\u4f20\u611f\u5668",
- "allow_deconz_groups": "\u5141\u8bb8\u5bfc\u5165 deCONZ \u7fa4\u7ec4"
- },
- "title": "deCONZ \u7684\u9644\u52a0\u914d\u7f6e\u9879"
}
},
"title": "deCONZ"
diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json
index 073ebd784c6..07b7c6e997b 100644
--- a/homeassistant/components/deconz/.translations/zh-Hant.json
+++ b/homeassistant/components/deconz/.translations/zh-Hant.json
@@ -14,10 +14,6 @@
"flow_title": "deCONZ Zigbee \u9598\u9053\u5668\uff08{host}\uff09",
"step": {
"hassio_confirm": {
- "data": {
- "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668",
- "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44"
- },
"description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u9023\u7dda\u81f3 Hass.io \u9644\u52a0\u6574\u5408 {addon} \u4e4b deCONZ \u9598\u9053\u5668\uff1f",
"title": "\u900f\u904e Hass.io \u9644\u52a0\u7d44\u4ef6 deCONZ Zigbee \u9598\u9053\u5668"
},
@@ -31,13 +27,6 @@
"link": {
"description": "\u89e3\u9664 deCONZ \u9598\u9053\u5668\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a -> \u9598\u9053\u5668 -> \u9032\u968e\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u8a8d\u8b49\u7a0b\u5f0f\uff08Authenticate app\uff09\u300d\u6309\u9215",
"title": "\u9023\u7d50\u81f3 deCONZ"
- },
- "options": {
- "data": {
- "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668",
- "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44"
- },
- "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805"
}
},
"title": "deCONZ Zigbee \u9598\u9053\u5668"
@@ -96,13 +85,6 @@
},
"options": {
"step": {
- "async_step_deconz_devices": {
- "data": {
- "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"
- },
"deconz_devices": {
"data": {
"allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668",
diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py
index 6e5e616fbb8..7db3477c3bb 100644
--- a/homeassistant/components/deconz/cover.py
+++ b/homeassistant/components/deconz/cover.py
@@ -61,7 +61,7 @@ class DeconzCover(DeconzDevice, CoverDevice):
@property
def current_cover_position(self):
"""Return the current position of the cover."""
- return 100 - int(self._device.brightness / 255 * 100)
+ return 100 - int(self._device.brightness / 254 * 100)
@property
def is_closed(self):
@@ -88,7 +88,7 @@ class DeconzCover(DeconzDevice, CoverDevice):
if position < 100:
data["on"] = True
- data["bri"] = 255 - int(position / 100 * 255)
+ data["bri"] = 254 - int(position / 100 * 254)
await self._device.async_set_state(data)
diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py
index b3dedf6cf00..0724f9f9b45 100644
--- a/homeassistant/components/deconz/deconz_device.py
+++ b/homeassistant/components/deconz/deconz_device.py
@@ -86,9 +86,10 @@ class DeconzDevice(DeconzBase, Entity):
async def async_will_remove_from_hass(self) -> None:
"""Disconnect device object when removed."""
self._device.remove_callback(self.async_update_callback)
- del self.gateway.deconz_ids[self.entity_id]
- for unsub_dispatcher in self.listeners:
- unsub_dispatcher()
+ if self.entity_id in self.gateway.deconz_ids:
+ del self.gateway.deconz_ids[self.entity_id]
+ for unsub_dispatcher in self.listeners:
+ unsub_dispatcher()
async def async_remove_self(self, deconz_ids: list) -> None:
"""Schedule removal of this entity.
diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py
index 8ae0394f935..654bcfd43db 100644
--- a/homeassistant/components/deconz/device_trigger.py
+++ b/homeassistant/components/deconz/device_trigger.py
@@ -56,6 +56,8 @@ CONF_RIGHT = "right"
CONF_OPEN = "open"
CONF_CLOSE = "close"
CONF_BOTH_BUTTONS = "both_buttons"
+CONF_TOP_BUTTONS = "top_buttons"
+CONF_BOTTOM_BUTTONS = "bottom_buttons"
CONF_BUTTON_1 = "button_1"
CONF_BUTTON_2 = "button_2"
CONF_BUTTON_3 = "button_3"
@@ -97,6 +99,34 @@ HUE_TAP_REMOTE = {
(CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18},
}
+SENIC_FRIENDS_OF_HUE_MODEL = "FOHSWITCH"
+SENIC_FRIENDS_OF_HUE = {
+ (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1000},
+ (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002},
+ (CONF_LONG_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1001},
+ (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1003},
+ (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 2000},
+ (CONF_SHORT_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2002},
+ (CONF_LONG_PRESS, CONF_BUTTON_2): {CONF_EVENT: 2001},
+ (CONF_LONG_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2003},
+ (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 3000},
+ (CONF_SHORT_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 3002},
+ (CONF_LONG_PRESS, CONF_BUTTON_3): {CONF_EVENT: 3001},
+ (CONF_LONG_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 3003},
+ (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 4000},
+ (CONF_SHORT_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 4002},
+ (CONF_LONG_PRESS, CONF_BUTTON_4): {CONF_EVENT: 4001},
+ (CONF_LONG_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 4003},
+ (CONF_SHORT_PRESS, CONF_TOP_BUTTONS): {CONF_EVENT: 5000},
+ (CONF_SHORT_RELEASE, CONF_TOP_BUTTONS): {CONF_EVENT: 5002},
+ (CONF_LONG_PRESS, CONF_TOP_BUTTONS): {CONF_EVENT: 5001},
+ (CONF_LONG_RELEASE, CONF_TOP_BUTTONS): {CONF_EVENT: 5003},
+ (CONF_SHORT_PRESS, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6000},
+ (CONF_SHORT_RELEASE, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6002},
+ (CONF_LONG_PRESS, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6001},
+ (CONF_LONG_RELEASE, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6003},
+}
+
SYMFONISK_SOUND_CONTROLLER_MODEL = "SYMFONISK Sound Controller"
SYMFONISK_SOUND_CONTROLLER = {
(CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002},
@@ -274,6 +304,7 @@ REMOTES = {
HUE_DIMMER_REMOTE_MODEL_GEN1: HUE_DIMMER_REMOTE,
HUE_DIMMER_REMOTE_MODEL_GEN2: HUE_DIMMER_REMOTE,
HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE,
+ SENIC_FRIENDS_OF_HUE_MODEL: SENIC_FRIENDS_OF_HUE,
SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER,
TRADFRI_ON_OFF_SWITCH_MODEL: TRADFRI_ON_OFF_SWITCH,
TRADFRI_OPEN_CLOSE_REMOTE_MODEL: TRADFRI_OPEN_CLOSE_REMOTE,
diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py
index b59c80a0dc8..eb83f5c15c5 100644
--- a/homeassistant/components/deconz/gateway.py
+++ b/homeassistant/components/deconz/gateway.py
@@ -31,7 +31,7 @@ from .errors import AuthenticationRequired, CannotConnect
@callback
def get_gateway_from_config_entry(hass, config_entry):
"""Return gateway with a matching bridge id."""
- return hass.data[DOMAIN][config_entry.unique_id]
+ return hass.data[DOMAIN].get(config_entry.unique_id)
class DeconzGateway:
@@ -126,6 +126,8 @@ class DeconzGateway:
Causes for this is either discovery updating host address or config entry options changing.
"""
gateway = get_gateway_from_config_entry(hass, entry)
+ if not gateway:
+ return
if gateway.api.host != entry.data[CONF_HOST]:
gateway.api.close()
gateway.api.host = entry.data[CONF_HOST]
diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py
index 2cd238d0787..538e071e194 100644
--- a/homeassistant/components/delijn/sensor.py
+++ b/homeassistant/components/delijn/sensor.py
@@ -66,6 +66,7 @@ class DeLijnPublicTransportSensor(Entity):
self._attributes = {ATTR_ATTRIBUTION: ATTRIBUTION}
self._name = name
self._state = None
+ self._available = False
async def async_update(self):
"""Get the latest data from the De Lijn API."""
@@ -88,8 +89,15 @@ class DeLijnPublicTransportSensor(Entity):
self._attributes["due_at_schedule"] = first["due_at_schedule"]
self._attributes["due_at_realtime"] = first["due_at_realtime"]
self._attributes["next_passages"] = self.line.passages
+ self._available = True
except (KeyError, IndexError) as error:
_LOGGER.debug("Error getting data from De Lijn: %s", error)
+ self._available = False
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
@property
def device_class(self):
diff --git a/homeassistant/components/demo/.translations/de.json b/homeassistant/components/demo/.translations/de.json
index a600790d2fc..658a39246d9 100644
--- a/homeassistant/components/demo/.translations/de.json
+++ b/homeassistant/components/demo/.translations/de.json
@@ -4,6 +4,12 @@
},
"options": {
"step": {
+ "init": {
+ "data": {
+ "one": "eins",
+ "other": "andere"
+ }
+ },
"options_1": {
"data": {
"bool": "Optionaler Boolescher Wert",
diff --git a/homeassistant/components/demo/.translations/es.json b/homeassistant/components/demo/.translations/es.json
index 73ed9809d65..9fd9b61dda1 100644
--- a/homeassistant/components/demo/.translations/es.json
+++ b/homeassistant/components/demo/.translations/es.json
@@ -4,6 +4,12 @@
},
"options": {
"step": {
+ "init": {
+ "data": {
+ "one": "Vacio",
+ "other": "Vacio"
+ }
+ },
"options_1": {
"data": {
"bool": "Booleano opcional",
diff --git a/homeassistant/components/demo/.translations/fr.json b/homeassistant/components/demo/.translations/fr.json
index bc093330c26..3621cd1c404 100644
--- a/homeassistant/components/demo/.translations/fr.json
+++ b/homeassistant/components/demo/.translations/fr.json
@@ -1,5 +1,22 @@
{
"config": {
"title": "D\u00e9mo"
+ },
+ "options": {
+ "step": {
+ "options_1": {
+ "data": {
+ "bool": "Bool\u00e9en facultatif",
+ "int": "Entr\u00e9e num\u00e9rique"
+ }
+ },
+ "options_2": {
+ "data": {
+ "multi": "S\u00e9lection multiple",
+ "select": "S\u00e9lectionnez une option",
+ "string": "Valeur de cha\u00eene"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/demo/.translations/it.json b/homeassistant/components/demo/.translations/it.json
index 7b299913c8e..1173cc48e04 100644
--- a/homeassistant/components/demo/.translations/it.json
+++ b/homeassistant/components/demo/.translations/it.json
@@ -7,7 +7,7 @@
"init": {
"data": {
"one": "uno",
- "other": "altro"
+ "other": "altri"
}
},
"options_1": {
diff --git a/homeassistant/components/demo/.translations/lb.json b/homeassistant/components/demo/.translations/lb.json
index d968b43af8b..05b4ba93427 100644
--- a/homeassistant/components/demo/.translations/lb.json
+++ b/homeassistant/components/demo/.translations/lb.json
@@ -4,9 +4,23 @@
},
"options": {
"step": {
+ "init": {
+ "data": {
+ "one": "Een",
+ "other": "Aner"
+ }
+ },
+ "options_1": {
+ "data": {
+ "bool": "Optionelle Boolean",
+ "int": "Numeresch Agab"
+ }
+ },
"options_2": {
"data": {
- "select": "Eng Optioun auswielen"
+ "multi": "Multiple Auswiel",
+ "select": "Eng Optioun auswielen",
+ "string": "String W\u00e4ert"
}
}
}
diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json
index d13ae1d7701..7e06e781563 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.8.0"],
+ "requirements": ["denonavr==0.8.1"],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@scarface-4711", "@starkillerOG"]
}
diff --git a/homeassistant/components/directv/.translations/ca.json b/homeassistant/components/directv/.translations/ca.json
new file mode 100644
index 00000000000..4bdc104e7de
--- /dev/null
+++ b/homeassistant/components/directv/.translations/ca.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El receptor DirecTV ja est\u00e0 configurat",
+ "unknown": "Error inesperat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar"
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Vols configurar {name}?",
+ "title": "Connexi\u00f3 amb el receptor DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3 o adre\u00e7a IP"
+ },
+ "title": "Connexi\u00f3 amb el receptor DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/de.json b/homeassistant/components/directv/.translations/de.json
new file mode 100644
index 00000000000..98a9e81f661
--- /dev/null
+++ b/homeassistant/components/directv/.translations/de.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Der DirecTV-Empf\u00e4nger ist bereits konfiguriert",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut"
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "one": "eins",
+ "other": "andere"
+ },
+ "description": "M\u00f6chten Sie {name} einrichten?",
+ "title": "Stellen Sie eine Verbindung zum DirecTV-Empf\u00e4nger her"
+ },
+ "user": {
+ "data": {
+ "host": "Host oder IP-Adresse"
+ },
+ "title": "Schlie\u00dfen Sie den DirecTV-Empf\u00e4nger an"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/en.json b/homeassistant/components/directv/.translations/en.json
index 667d5168f8d..774ce1f2035 100644
--- a/homeassistant/components/directv/.translations/en.json
+++ b/homeassistant/components/directv/.translations/en.json
@@ -10,7 +10,6 @@
"flow_title": "DirecTV: {name}",
"step": {
"ssdp_confirm": {
- "data": {},
"description": "Do you want to set up {name}?",
"title": "Connect to the DirecTV receiver"
},
@@ -23,4 +22,4 @@
},
"title": "DirecTV"
}
-}
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/es.json b/homeassistant/components/directv/.translations/es.json
new file mode 100644
index 00000000000..f23f83481e5
--- /dev/null
+++ b/homeassistant/components/directv/.translations/es.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El receptor DirecTV ya est\u00e1 configurado",
+ "unknown": "Error inesperado"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo."
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "\u00bfQuieres configurar {name}?",
+ "title": "Conectar con el receptor DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "Host o direcci\u00f3n IP"
+ },
+ "title": "Conectar con el receptor DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/fr.json b/homeassistant/components/directv/.translations/fr.json
new file mode 100644
index 00000000000..d7262f50eaf
--- /dev/null
+++ b/homeassistant/components/directv/.translations/fr.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le r\u00e9cepteur DirecTV est d\u00e9j\u00e0 configur\u00e9",
+ "unknown": "Erreur inattendue"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer"
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Voulez-vous configurer {name} ?",
+ "title": "Connectez-vous au r\u00e9cepteur DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te ou adresse IP"
+ },
+ "title": "Connectez-vous au r\u00e9cepteur DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/it.json b/homeassistant/components/directv/.translations/it.json
new file mode 100644
index 00000000000..777b66d5c91
--- /dev/null
+++ b/homeassistant/components/directv/.translations/it.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il ricevitore DirecTV \u00e8 gi\u00e0 configurato",
+ "unknown": "Errore imprevisto"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare"
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "one": "uno",
+ "other": "altri"
+ },
+ "description": "Vuoi impostare {name} ?",
+ "title": "Connettersi al ricevitore DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "Host o indirizzo IP"
+ },
+ "title": "Collegamento al ricevitore DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/ko.json b/homeassistant/components/directv/.translations/ko.json
new file mode 100644
index 00000000000..5099b264085
--- /dev/null
+++ b/homeassistant/components/directv/.translations/ko.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "DirecTV \ub9ac\uc2dc\ubc84\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "{name} \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "DirecTV \ub9ac\uc2dc\ubc84\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ },
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c"
+ },
+ "title": "DirecTV \ub9ac\uc2dc\ubc84\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/lb.json b/homeassistant/components/directv/.translations/lb.json
new file mode 100644
index 00000000000..4e2a09c6bef
--- /dev/null
+++ b/homeassistant/components/directv/.translations/lb.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "DirecTV ass scho konfigur\u00e9iert",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol."
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "one": "Een",
+ "other": "Aner"
+ },
+ "description": "Soll {name} konfigur\u00e9iert ginn?",
+ "title": "Mam DirecTV Receiver verbannen"
+ },
+ "user": {
+ "data": {
+ "host": "Numm oder IP Adresse"
+ },
+ "title": "Mam DirecTV Receiver verbannen"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/no.json b/homeassistant/components/directv/.translations/no.json
new file mode 100644
index 00000000000..be2500b38b3
--- /dev/null
+++ b/homeassistant/components/directv/.translations/no.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "DirecTV-mottaker er allerede konfigurert",
+ "unknown": "Uventet feil"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen"
+ },
+ "flow_title": "",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Vil du sette opp {name} ?",
+ "title": "Koble til DirecTV-mottakeren"
+ },
+ "user": {
+ "data": {
+ "host": "Vert eller IP-adresse"
+ },
+ "title": "Koble til DirecTV-mottakeren"
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/pl.json b/homeassistant/components/directv/.translations/pl.json
new file mode 100644
index 00000000000..d9de1368ec5
--- /dev/null
+++ b/homeassistant/components/directv/.translations/pl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Odbiornik DirecTV jest ju\u017c skonfigurowany.",
+ "unknown": "Niespodziewany b\u0142\u0105d."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie."
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "few": "kilka",
+ "many": "wiele",
+ "one": "jeden",
+ "other": "inne"
+ },
+ "description": "Czy chcesz skonfigurowa\u0107 {name}?",
+ "title": "Po\u0142\u0105czenie z odbiornikiem DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "Nazwa hosta lub adres IP"
+ },
+ "title": "Po\u0142\u0105czenie z odbiornikiem DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/ru.json b/homeassistant/components/directv/.translations/ru.json
new file mode 100644
index 00000000000..08e18b89bf1
--- /dev/null
+++ b/homeassistant/components/directv/.translations/ru.json
@@ -0,0 +1,25 @@
+{
+ "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.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437."
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "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 {name}?",
+ "title": "DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441"
+ },
+ "title": "DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/sl.json b/homeassistant/components/directv/.translations/sl.json
new file mode 100644
index 00000000000..ab20a6ec424
--- /dev/null
+++ b/homeassistant/components/directv/.translations/sl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Sprejemnik DirecTV je \u017ee konfiguriran",
+ "unknown": "Nepri\u010dakovana napaka"
+ },
+ "error": {
+ "cannot_connect": "Povezava ni uspela, poskusite znova"
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "few": "nekaj",
+ "one": "ena",
+ "other": "drugo",
+ "two": "dva"
+ },
+ "description": "Ali \u017eelite nastaviti {name} ?",
+ "title": "Pove\u017eite se s sprejemnikom DirecTV"
+ },
+ "user": {
+ "data": {
+ "host": "Gostitelj ali IP naslov"
+ },
+ "title": "Pove\u017eite se s sprejemnikom DirecTV"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/.translations/zh-Hant.json b/homeassistant/components/directv/.translations/zh-Hant.json
new file mode 100644
index 00000000000..b7a1bb41f53
--- /dev/null
+++ b/homeassistant/components/directv/.translations/zh-Hant.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "DirectTV \u63a5\u6536\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21"
+ },
+ "flow_title": "DirecTV\uff1a{name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f",
+ "title": "\u9023\u7dda\u81f3 DirecTV \u63a5\u6536\u5668"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740"
+ },
+ "title": "\u9023\u7dda\u81f3 DirecTV \u63a5\u6536\u5668"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py
index fc7bb78989a..0be5957a29a 100644
--- a/homeassistant/components/directv/__init__.py
+++ b/homeassistant/components/directv/__init__.py
@@ -1,19 +1,27 @@
"""The DirecTV integration."""
import asyncio
from datetime import timedelta
-from typing import Dict
+from typing import Any, Dict
-from DirectPy import DIRECTV
-from requests.exceptions import RequestException
+from directv import DIRECTV, DIRECTVError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_HOST
+from homeassistant.const import ATTR_NAME, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity import Entity
-from .const import DATA_CLIENT, DATA_LOCATIONS, DATA_VERSION_INFO, DEFAULT_PORT, DOMAIN
+from .const import (
+ ATTR_IDENTIFIERS,
+ ATTR_MANUFACTURER,
+ ATTR_MODEL,
+ ATTR_SOFTWARE_VERSION,
+ ATTR_VIA_DEVICE,
+ DOMAIN,
+)
CONFIG_SCHEMA = vol.Schema(
{
@@ -28,21 +36,6 @@ PLATFORMS = ["media_player"]
SCAN_INTERVAL = timedelta(seconds=30)
-def get_dtv_data(
- hass: HomeAssistant, host: str, port: int = DEFAULT_PORT, client_addr: str = "0"
-) -> dict:
- """Retrieve a DIRECTV instance, locations list, and version info for the receiver device."""
- dtv = DIRECTV(host, port, client_addr, determine_state=False)
- locations = dtv.get_locations()
- version_info = dtv.get_version()
-
- return {
- DATA_CLIENT: dtv,
- DATA_LOCATIONS: locations,
- DATA_VERSION_INFO: version_info,
- }
-
-
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
"""Set up the DirecTV component."""
hass.data.setdefault(DOMAIN, {})
@@ -60,14 +53,14 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up DirecTV from a config entry."""
+ dtv = DIRECTV(entry.data[CONF_HOST], session=async_get_clientsession(hass))
+
try:
- dtv_data = await hass.async_add_executor_job(
- get_dtv_data, hass, entry.data[CONF_HOST]
- )
- except RequestException:
+ await dtv.update()
+ except DIRECTVError:
raise ConfigEntryNotReady
- hass.data[DOMAIN][entry.entry_id] = dtv_data
+ hass.data[DOMAIN][entry.entry_id] = dtv
for component in PLATFORMS:
hass.async_create_task(
@@ -92,3 +85,32 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+
+
+class DIRECTVEntity(Entity):
+ """Defines a base DirecTV entity."""
+
+ def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None:
+ """Initialize the DirecTV entity."""
+ self._address = address
+ self._device_id = address if address != "0" else dtv.device.info.receiver_id
+ self._is_client = address != "0"
+ self._name = name
+ self.dtv = dtv
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this DirecTV receiver."""
+ return {
+ ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)},
+ ATTR_NAME: self.name,
+ ATTR_MANUFACTURER: self.dtv.device.info.brand,
+ ATTR_MODEL: None,
+ ATTR_SOFTWARE_VERSION: self.dtv.device.info.version,
+ ATTR_VIA_DEVICE: (DOMAIN, self.dtv.device.info.receiver_id),
+ }
diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py
index d1b3a6cbe62..406f2628ee4 100644
--- a/homeassistant/components/directv/config_flow.py
+++ b/homeassistant/components/directv/config_flow.py
@@ -3,17 +3,20 @@ import logging
from typing import Any, Dict, Optional
from urllib.parse import urlparse
-from DirectPy import DIRECTV
-from requests.exceptions import RequestException
+from directv import DIRECTV, DIRECTVError
import voluptuous as vol
from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
from homeassistant.const import CONF_HOST, CONF_NAME
-from homeassistant.core import callback
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import (
+ ConfigType,
+ DiscoveryInfoType,
+ HomeAssistantType,
+)
-from .const import DEFAULT_PORT
+from .const import CONF_RECEIVER_ID
from .const import DOMAIN # pylint: disable=unused-import
_LOGGER = logging.getLogger(__name__)
@@ -21,22 +24,17 @@ _LOGGER = logging.getLogger(__name__)
ERROR_CANNOT_CONNECT = "cannot_connect"
ERROR_UNKNOWN = "unknown"
-DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
-
-def validate_input(data: Dict) -> Dict:
+async def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
- dtv = DIRECTV(data["host"], DEFAULT_PORT, determine_state=False)
- version_info = dtv.get_version()
+ session = async_get_clientsession(hass)
+ directv = DIRECTV(data[CONF_HOST], session=session)
+ device = await directv.update()
- return {
- "title": data["host"],
- "host": data["host"],
- "receiver_id": "".join(version_info["receiverId"].split()),
- }
+ return {CONF_RECEIVER_ID: device.info.receiver_id}
class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -45,84 +43,91 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
- @callback
- def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
- """Show the form to the user."""
- return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors or {},
- )
+ def __init__(self):
+ """Set up the instance."""
+ self.discovery_info = {}
async def async_step_import(
- self, user_input: Optional[Dict] = None
+ self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
- """Handle a flow initialized by yaml file."""
+ """Handle a flow initiated by configuration file."""
return await self.async_step_user(user_input)
async def async_step_user(
- self, user_input: Optional[Dict] = None
+ self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
- """Handle a flow initialized by user."""
- if not user_input:
- return self._show_form()
-
- errors = {}
+ """Handle a flow initiated by the user."""
+ if user_input is None:
+ return self._show_setup_form()
try:
- info = await self.hass.async_add_executor_job(validate_input, user_input)
- user_input[CONF_HOST] = info[CONF_HOST]
- except RequestException:
- errors["base"] = ERROR_CANNOT_CONNECT
- return self._show_form(errors)
+ info = await validate_input(self.hass, user_input)
+ except DIRECTVError:
+ return self._show_setup_form({"base": ERROR_CANNOT_CONNECT})
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason=ERROR_UNKNOWN)
- await self.async_set_unique_id(info["receiver_id"])
- self._abort_if_unique_id_configured()
+ user_input[CONF_RECEIVER_ID] = info[CONF_RECEIVER_ID]
- return self.async_create_entry(title=info["title"], data=user_input)
+ await self.async_set_unique_id(user_input[CONF_RECEIVER_ID])
+ self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
+
+ return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
async def async_step_ssdp(
- self, discovery_info: Optional[Dict] = None
+ self, discovery_info: DiscoveryInfoType
) -> Dict[str, Any]:
- """Handle a flow initialized by discovery."""
+ """Handle SSDP discovery."""
host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname
- receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID-
+ receiver_id = None
- await self.async_set_unique_id(receiver_id)
- self._abort_if_unique_id_configured(updates={CONF_HOST: host})
+ if discovery_info.get(ATTR_UPNP_SERIAL):
+ receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID-
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
- self.context.update(
- {CONF_HOST: host, CONF_NAME: host, "title_placeholders": {"name": host}}
+ self.context.update({"title_placeholders": {"name": host}})
+
+ self.discovery_info.update(
+ {CONF_HOST: host, CONF_NAME: host, CONF_RECEIVER_ID: receiver_id}
+ )
+
+ try:
+ info = await validate_input(self.hass, self.discovery_info)
+ except DIRECTVError:
+ return self.async_abort(reason=ERROR_CANNOT_CONNECT)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ return self.async_abort(reason=ERROR_UNKNOWN)
+
+ self.discovery_info[CONF_RECEIVER_ID] = info[CONF_RECEIVER_ID]
+
+ await self.async_set_unique_id(self.discovery_info[CONF_RECEIVER_ID])
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: self.discovery_info[CONF_HOST]}
)
return await self.async_step_ssdp_confirm()
async def async_step_ssdp_confirm(
- self, user_input: Optional[Dict] = None
+ self, user_input: ConfigType = None
) -> Dict[str, Any]:
- """Handle user-confirmation of discovered device."""
- # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
- name = self.context.get(CONF_NAME)
+ """Handle a confirmation flow initiated by SSDP."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="ssdp_confirm",
+ description_placeholders={"name": self.discovery_info[CONF_NAME]},
+ errors={},
+ )
- if user_input is not None:
- # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
- user_input[CONF_HOST] = self.context.get(CONF_HOST)
-
- try:
- await self.hass.async_add_executor_job(validate_input, user_input)
- return self.async_create_entry(title=name, data=user_input)
- except (OSError, RequestException):
- return self.async_abort(reason=ERROR_CANNOT_CONNECT)
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception("Unexpected exception")
- return self.async_abort(reason=ERROR_UNKNOWN)
-
- return self.async_show_form(
- step_id="ssdp_confirm", description_placeholders={"name": name},
+ return self.async_create_entry(
+ title=self.discovery_info[CONF_NAME], data=self.discovery_info,
)
-
-class CannotConnect(HomeAssistantError):
- """Error to indicate we cannot connect."""
+ def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
+ """Show the setup form to the user."""
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
+ errors=errors or {},
+ )
diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py
index e5b04ce34f6..9ad01a0179b 100644
--- a/homeassistant/components/directv/const.py
+++ b/homeassistant/components/directv/const.py
@@ -2,19 +2,19 @@
DOMAIN = "directv"
+# Attributes
+ATTR_IDENTIFIERS = "identifiers"
+ATTR_MANUFACTURER = "manufacturer"
ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording"
ATTR_MEDIA_RATING = "media_rating"
ATTR_MEDIA_RECORDED = "media_recorded"
ATTR_MEDIA_START_TIME = "media_start_time"
+ATTR_MODEL = "model"
+ATTR_SOFTWARE_VERSION = "sw_version"
+ATTR_VIA_DEVICE = "via_device"
-DATA_CLIENT = "client"
-DATA_LOCATIONS = "locations"
-DATA_VERSION_INFO = "version_info"
+CONF_RECEIVER_ID = "receiver_id"
DEFAULT_DEVICE = "0"
-DEFAULT_MANUFACTURER = "DirecTV"
DEFAULT_NAME = "DirecTV Receiver"
DEFAULT_PORT = 8080
-
-MODEL_HOST = "DirecTV Host"
-MODEL_CLIENT = "DirecTV Client"
diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json
index cb8ed68b304..4a712ba053e 100644
--- a/homeassistant/components/directv/manifest.json
+++ b/homeassistant/components/directv/manifest.json
@@ -2,9 +2,10 @@
"domain": "directv",
"name": "DirecTV",
"documentation": "https://www.home-assistant.io/integrations/directv",
- "requirements": ["directpy==0.7"],
+ "requirements": ["directv==0.2.0"],
"dependencies": [],
"codeowners": ["@ctalkington"],
+ "quality_scale": "gold",
"config_flow": true,
"ssdp": [
{
diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py
index f487e72f694..b93577a03d6 100644
--- a/homeassistant/components/directv/media_player.py
+++ b/homeassistant/components/directv/media_player.py
@@ -1,12 +1,10 @@
"""Support for the DirecTV receivers."""
import logging
-from typing import Callable, Dict, List, Optional
+from typing import Callable, List
-from DirectPy import DIRECTV
-from requests.exceptions import RequestException
-import voluptuous as vol
+from directv import DIRECTV
-from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
+from homeassistant.components.media_player import MediaPlayerDevice
from homeassistant.components.media_player.const import (
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_MOVIE,
@@ -21,34 +19,17 @@ from homeassistant.components.media_player.const import (
SUPPORT_TURN_ON,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_DEVICE,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
- STATE_OFF,
- STATE_PAUSED,
- STATE_PLAYING,
-)
-from homeassistant.helpers import config_validation as cv
+from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import dt as dt_util
+from . import DIRECTVEntity
from .const import (
ATTR_MEDIA_CURRENTLY_RECORDING,
ATTR_MEDIA_RATING,
ATTR_MEDIA_RECORDED,
ATTR_MEDIA_START_TIME,
- DATA_CLIENT,
- DATA_LOCATIONS,
- DATA_VERSION_INFO,
- DEFAULT_DEVICE,
- DEFAULT_MANUFACTURER,
- DEFAULT_NAME,
- DEFAULT_PORT,
DOMAIN,
- MODEL_CLIENT,
- MODEL_HOST,
)
_LOGGER = logging.getLogger(__name__)
@@ -73,15 +54,6 @@ SUPPORT_DTV_CLIENT = (
| SUPPORT_PLAY
)
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string,
- }
-)
-
async def async_setup_entry(
hass: HomeAssistantType,
@@ -89,139 +61,57 @@ async def async_setup_entry(
async_add_entities: Callable[[List, bool], None],
) -> bool:
"""Set up the DirecTV config entry."""
- locations = hass.data[DOMAIN][entry.entry_id][DATA_LOCATIONS]
- version_info = hass.data[DOMAIN][entry.entry_id][DATA_VERSION_INFO]
+ dtv = hass.data[DOMAIN][entry.entry_id]
entities = []
- for loc in locations["locations"]:
- if "locationName" not in loc or "clientAddr" not in loc:
- continue
-
- if loc["clientAddr"] != "0":
- dtv = DIRECTV(
- entry.data[CONF_HOST],
- DEFAULT_PORT,
- loc["clientAddr"],
- determine_state=False,
- )
- else:
- dtv = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT]
-
+ for location in dtv.device.locations:
entities.append(
- DirecTvDevice(
- str.title(loc["locationName"]), loc["clientAddr"], dtv, version_info,
+ DIRECTVMediaPlayer(
+ dtv=dtv, name=str.title(location.name), address=location.address,
)
)
async_add_entities(entities, True)
-class DirecTvDevice(MediaPlayerDevice):
+class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerDevice):
"""Representation of a DirecTV receiver on the network."""
- def __init__(
- self,
- name: str,
- device: str,
- dtv: DIRECTV,
- version_info: Optional[Dict] = None,
- enabled_default: bool = True,
- ):
- """Initialize the device."""
- self.dtv = dtv
- self._name = name
- self._unique_id = None
- self._is_standby = True
- self._current = None
- self._last_update = None
- self._paused = None
- self._last_position = None
- self._is_recorded = None
- self._is_client = device != "0"
+ def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None:
+ """Initialize DirecTV media player."""
+ super().__init__(
+ dtv=dtv, name=name, address=address,
+ )
+
self._assumed_state = None
self._available = False
- self._enabled_default = enabled_default
- self._first_error_timestamp = None
- self._model = None
- self._receiver_id = None
- self._software_version = None
+ self._is_recorded = None
+ self._is_standby = True
+ self._last_position = None
+ self._last_update = None
+ self._paused = None
+ self._program = None
+ self._state = None
- if self._is_client:
- self._model = MODEL_CLIENT
- self._unique_id = device
-
- if version_info:
- self._receiver_id = "".join(version_info["receiverId"].split())
-
- if not self._is_client:
- self._unique_id = self._receiver_id
- self._model = MODEL_HOST
- self._software_version = version_info["stbSoftwareVersion"]
-
- def update(self):
+ async def async_update(self):
"""Retrieve latest state."""
- _LOGGER.debug("%s: Updating status", self.entity_id)
- try:
- self._available = True
- self._is_standby = self.dtv.get_standby()
- if self._is_standby:
- self._current = None
- self._is_recorded = None
- self._paused = None
- self._assumed_state = False
- self._last_position = None
- self._last_update = None
- else:
- self._current = self.dtv.get_tuned()
- if self._current["status"]["code"] == 200:
- self._first_error_timestamp = None
- self._is_recorded = self._current.get("uniqueId") is not None
- self._paused = self._last_position == self._current["offset"]
- self._assumed_state = self._is_recorded
- self._last_position = self._current["offset"]
- self._last_update = (
- dt_util.utcnow()
- if not self._paused or self._last_update is None
- else self._last_update
- )
- else:
- # If an error is received then only set to unavailable if
- # this started at least 1 minute ago.
- log_message = f"{self.entity_id}: Invalid status {self._current['status']['code']} received"
- if self._check_state_available():
- _LOGGER.debug(log_message)
- else:
- _LOGGER.error(log_message)
+ self._state = await self.dtv.state(self._address)
+ self._available = self._state.available
+ self._is_standby = self._state.standby
+ self._program = self._state.program
- except RequestException as exception:
- _LOGGER.error(
- "%s: Request error trying to update current status: %s",
- self.entity_id,
- exception,
- )
- self._check_state_available()
-
- except Exception as exception:
- _LOGGER.error(
- "%s: Exception trying to update current status: %s",
- self.entity_id,
- exception,
- )
- self._available = False
- if not self._first_error_timestamp:
- self._first_error_timestamp = dt_util.utcnow()
- raise
-
- def _check_state_available(self):
- """Set to unavailable if issue been occurring over 1 minute."""
- if not self._first_error_timestamp:
- self._first_error_timestamp = dt_util.utcnow()
- else:
- tdelta = dt_util.utcnow() - self._first_error_timestamp
- if tdelta.total_seconds() >= 60:
- self._available = False
-
- return self._available
+ if self._is_standby:
+ self._assumed_state = False
+ self._is_recorded = None
+ self._last_position = None
+ self._last_update = None
+ self._paused = None
+ elif self._program is not None:
+ self._paused = self._last_position == self._program.position
+ self._is_recorded = self._program.recorded
+ self._last_position = self._program.position
+ self._last_update = self._state.at
+ self._assumed_state = self._is_recorded
@property
def device_state_attributes(self):
@@ -243,24 +133,10 @@ class DirecTvDevice(MediaPlayerDevice):
@property
def unique_id(self):
"""Return a unique ID to use for this media player."""
- return self._unique_id
+ if self._address == "0":
+ return self.dtv.device.info.receiver_id
- @property
- def device_info(self):
- """Return device specific attributes."""
- return {
- "name": self.name,
- "identifiers": {(DOMAIN, self.unique_id)},
- "manufacturer": DEFAULT_MANUFACTURER,
- "model": self._model,
- "sw_version": self._software_version,
- "via_device": (DOMAIN, self._receiver_id),
- }
-
- @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
+ return self._address
# MediaPlayerDevice properties and methods
@property
@@ -290,29 +166,30 @@ class DirecTvDevice(MediaPlayerDevice):
@property
def media_content_id(self):
"""Return the content ID of current playing media."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current["programId"]
+ return self._program.program_id
@property
def media_content_type(self):
"""Return the content type of current playing media."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- if "episodeTitle" in self._current:
- return MEDIA_TYPE_TVSHOW
+ known_types = [MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW]
+ if self._program.program_type in known_types:
+ return self._program.program_type
return MEDIA_TYPE_MOVIE
@property
def media_duration(self):
"""Return the duration of current playing media in seconds."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current["duration"]
+ return self._program.duration
@property
def media_position(self):
@@ -324,10 +201,7 @@ class DirecTvDevice(MediaPlayerDevice):
@property
def media_position_updated_at(self):
- """When was the position of the current playing media valid.
-
- Returns value from homeassistant.util.dt.utcnow().
- """
+ """When was the position of the current playing media valid."""
if self._is_standby:
return None
@@ -336,34 +210,34 @@ class DirecTvDevice(MediaPlayerDevice):
@property
def media_title(self):
"""Return the title of current playing media."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current["title"]
+ return self._program.title
@property
def media_series_title(self):
"""Return the title of current episode of TV show."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current.get("episodeTitle")
+ return self._program.episode_title
@property
def media_channel(self):
"""Return the channel current playing media."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return f"{self._current['callsign']} ({self._current['major']})"
+ return f"{self._program.channel_name} ({self._program.channel})"
@property
def source(self):
"""Name of the current input source."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current["major"]
+ return self._program.channel
@property
def supported_features(self):
@@ -373,18 +247,18 @@ class DirecTvDevice(MediaPlayerDevice):
@property
def media_currently_recording(self):
"""If the media is currently being recorded or not."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current["isRecording"]
+ return self._program.recording
@property
def media_rating(self):
"""TV Rating of the current playing media."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return self._current["rating"]
+ return self._program.rating
@property
def media_recorded(self):
@@ -397,53 +271,53 @@ class DirecTvDevice(MediaPlayerDevice):
@property
def media_start_time(self):
"""Start time the program aired."""
- if self._is_standby:
+ if self._is_standby or self._program is None:
return None
- return dt_util.as_local(dt_util.utc_from_timestamp(self._current["startTime"]))
+ return dt_util.as_local(self._program.start_time)
- def turn_on(self):
+ async def async_turn_on(self):
"""Turn on the receiver."""
if self._is_client:
raise NotImplementedError()
_LOGGER.debug("Turn on %s", self._name)
- self.dtv.key_press("poweron")
+ await self.dtv.remote("poweron", self._address)
- def turn_off(self):
+ async def async_turn_off(self):
"""Turn off the receiver."""
if self._is_client:
raise NotImplementedError()
_LOGGER.debug("Turn off %s", self._name)
- self.dtv.key_press("poweroff")
+ await self.dtv.remote("poweroff", self._address)
- def media_play(self):
+ async def async_media_play(self):
"""Send play command."""
_LOGGER.debug("Play on %s", self._name)
- self.dtv.key_press("play")
+ await self.dtv.remote("play", self._address)
- def media_pause(self):
+ async def async_media_pause(self):
"""Send pause command."""
_LOGGER.debug("Pause on %s", self._name)
- self.dtv.key_press("pause")
+ await self.dtv.remote("pause", self._address)
- def media_stop(self):
+ async def async_media_stop(self):
"""Send stop command."""
_LOGGER.debug("Stop on %s", self._name)
- self.dtv.key_press("stop")
+ await self.dtv.remote("stop", self._address)
- def media_previous_track(self):
+ async def async_media_previous_track(self):
"""Send rewind command."""
_LOGGER.debug("Rewind on %s", self._name)
- self.dtv.key_press("rew")
+ await self.dtv.remote("rew", self._address)
- def media_next_track(self):
+ async def async_media_next_track(self):
"""Send fast forward command."""
_LOGGER.debug("Fast forward on %s", self._name)
- self.dtv.key_press("ffwd")
+ await self.dtv.remote("ffwd", self._address)
- def play_media(self, media_type, media_id, **kwargs):
+ async def async_play_media(self, media_type, media_id, **kwargs):
"""Select input source."""
if media_type != MEDIA_TYPE_CHANNEL:
_LOGGER.error(
@@ -454,4 +328,4 @@ class DirecTvDevice(MediaPlayerDevice):
return
_LOGGER.debug("Changing channel on %s to %s", self._name, media_id)
- self.dtv.tune_channel(media_id)
+ await self.dtv.tune(media_id, self._address)
diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json
index e496ad0d532..939138bf999 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.3.1"],
+ "requirements": ["discord.py==1.3.2"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py
index d12e9d2c54b..64816acaaf3 100644
--- a/homeassistant/components/discovery/__init__.py
+++ b/homeassistant/components/discovery/__init__.py
@@ -75,7 +75,6 @@ SERVICE_HANDLERS = {
"denonavr": ("media_player", "denonavr"),
"frontier_silicon": ("media_player", "frontier_silicon"),
"openhome": ("media_player", "openhome"),
- "harmony": ("remote", "harmony"),
"bose_soundtouch": ("media_player", "soundtouch"),
"bluesound": ("media_player", "bluesound"),
"songpal": ("media_player", "songpal"),
@@ -93,6 +92,7 @@ MIGRATED_SERVICE_HANDLERS = [
"esphome",
"google_cast",
SERVICE_HEOS,
+ "harmony",
"homekit",
"ikea_tradfri",
"philips_hue",
diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json
index 7f5ff6cfd02..754a9ec5e03 100644
--- a/homeassistant/components/dlink/manifest.json
+++ b/homeassistant/components/dlink/manifest.json
@@ -2,7 +2,7 @@
"domain": "dlink",
"name": "D-Link Wi-Fi Smart Plugs",
"documentation": "https://www.home-assistant.io/integrations/dlink",
- "requirements": ["pyW215==0.6.0"],
+ "requirements": ["pyW215==0.7.0"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/doorbird/.translations/ca.json b/homeassistant/components/doorbird/.translations/ca.json
new file mode 100644
index 00000000000..d26da82ad1e
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/ca.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Aquest dispositiu DoorBird ja est\u00e0 configurat",
+ "link_local_address": "L'enlla\u00e7 amb adreces locals no est\u00e0 perm\u00e8s",
+ "not_doorbird_device": "Aquest dispositiu no \u00e9s DoorBird"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3 (adre\u00e7a IP)",
+ "name": "Nom del dispositiu",
+ "password": "Contrasenya",
+ "username": "Nom d'usuari"
+ },
+ "title": "Connexi\u00f3 amb DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Llista d'esdeveniments separats per comes."
+ },
+ "description": "Afegeix el/s noms del/s esdeveniment/s que vulguis seguir separats per comes. Despr\u00e9s d\u2019introduir-los, utilitzeu l\u2019aplicaci\u00f3 de DoorBird per assignar-los a un esdeveniment espec\u00edfic. Consulta la documentaci\u00f3 a https://www.home-assistant.io/integrations/doorbird/#events.\nExemple: algu_ha_premut_el_boto, moviment_detectat"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/da.json b/homeassistant/components/doorbird/.translations/da.json
new file mode 100644
index 00000000000..3e66091d851
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/da.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/de.json b/homeassistant/components/doorbird/.translations/de.json
new file mode 100644
index 00000000000..2992d066d4a
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/de.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dieser DoorBird ist bereits konfiguriert",
+ "link_local_address": "Lokale Linkadressen werden nicht unterst\u00fctzt",
+ "not_doorbird_device": "Dieses Ger\u00e4t ist kein DoorBird"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "invalid_auth": "Ung\u00fcltige Authentifizierung",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host (IP-Adresse)",
+ "name": "Ger\u00e4tename",
+ "password": "Passwort",
+ "username": "Benutzername"
+ },
+ "title": "Stellen Sie eine Verbindung zu DoorBird her"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Durch Kommas getrennte Liste von Ereignissen."
+ },
+ "description": "F\u00fcgen Sie f\u00fcr jedes Ereignis, das Sie verfolgen m\u00f6chten, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem Sie sie hier eingegeben haben, verwenden Sie die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen finden Sie in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/en.json b/homeassistant/components/doorbird/.translations/en.json
new file mode 100644
index 00000000000..87524cd7dd6
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/en.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This DoorBird is already configured",
+ "link_local_address": "Link local addresses are not supported",
+ "not_doorbird_device": "This device is not a DoorBird"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host (IP Address)",
+ "name": "Device Name",
+ "password": "Password",
+ "username": "Username"
+ },
+ "title": "Connect to the DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Comma separated list of events."
+ },
+ "description": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event. See the documentation at https://www.home-assistant.io/integrations/doorbird/#events. Example: somebody_pressed_the_button, motion"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/es.json b/homeassistant/components/doorbird/.translations/es.json
new file mode 100644
index 00000000000..4e2aa0414dc
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/es.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "DoorBird ya est\u00e1 configurado",
+ "link_local_address": "No se admiten direcciones locales",
+ "not_doorbird_device": "Este dispositivo no es un DoorBird"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar, por favor int\u00e9ntalo de nuevo",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host (Direcci\u00f3n IP)",
+ "name": "Nombre del dispositivo",
+ "password": "Contrase\u00f1a",
+ "username": "Nombre de usuario"
+ },
+ "title": "Conectar con DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Lista de eventos separados por comas."
+ },
+ "description": "A\u00f1ade un nombre de evento separado por comas para cada evento del que deseas realizar un seguimiento. Despu\u00e9s de introducirlos aqu\u00ed, utiliza la aplicaci\u00f3n DoorBird para asignarlos a un evento espec\u00edfico. Consulta la documentaci\u00f3n en https://www.home-assistant.io/integrations/doorbird/#events. Ejemplo: somebody_pressed_the_button, motion"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/fr.json b/homeassistant/components/doorbird/.translations/fr.json
new file mode 100644
index 00000000000..21f67a9471e
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/fr.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ce DoorBird est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification non valide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "H\u00f4te (adresse IP)",
+ "name": "Nom de l'appareil",
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ },
+ "title": "Connectez-vous au DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Liste d'\u00e9v\u00e9nements s\u00e9par\u00e9s par des virgules."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/it.json b/homeassistant/components/doorbird/.translations/it.json
new file mode 100644
index 00000000000..6d1a80424bf
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/it.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Questo DoorBird \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host (indirizzo IP)",
+ "name": "Nome del dispositivo",
+ "password": "Password",
+ "username": "Nome utente"
+ },
+ "title": "Connetti a DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Elenco di eventi separati da virgole."
+ },
+ "description": "Aggiungere un nome di evento separato da virgola per ogni evento che si desidera monitorare. Dopo averli inseriti qui, usa l'applicazione DoorBird per assegnarli a un evento specifico. Consultare la documentazione su https://www.home-assistant.io/integrations/doorbird/#events. Esempio: qualcuno_premuto_il_pulsante, movimento"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/ko.json b/homeassistant/components/doorbird/.translations/ko.json
new file mode 100644
index 00000000000..fff92c32188
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/ko.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc774 DoorBird \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
+ "not_doorbird_device": "\uc774 \uae30\uae30\ub294 DoorBird \uac00 \uc544\ub2d9\ub2c8\ub2e4"
+ },
+ "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"
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8 (IP \uc8fc\uc18c)",
+ "name": "\uae30\uae30 \uc774\ub984",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "title": "DoorBird \uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "\uc27c\ud45c\ub85c \uad6c\ubd84\ub41c \uc774\ubca4\ud2b8 \ubaa9\ub85d."
+ },
+ "description": "\ucd94\uc801\ud558\ub824\ub294 \uac01 \uc774\ubca4\ud2b8\uc5d0 \ub300\ud574 \uc27c\ud45c\ub85c \uad6c\ubd84\ub41c \uc774\ubca4\ud2b8 \uc774\ub984\uc744 \ucd94\uac00\ud574\uc8fc\uc138\uc694. \uc5ec\uae30\uc5d0 \uc785\ub825\ud55c \ud6c4 DoorBird \uc571\uc744 \uc0ac\uc6a9\ud558\uc5ec \ud2b9\uc815 \uc774\ubca4\ud2b8\uc5d0 \ud560\ub2f9\ud574\uc8fc\uc138\uc694. \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 https://www.home-assistant.io/integrations/doorbird/#event \uc124\uba85\uc11c\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694. \uc608: someone_pressed_the_button, motion"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/lb.json b/homeassistant/components/doorbird/.translations/lb.json
new file mode 100644
index 00000000000..ba29b19df8a
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/lb.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "D\u00ebse DoorBird ass scho konfigur\u00e9iert",
+ "link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt",
+ "not_doorbird_device": "D\u00ebsen Apparat ass kee DoorBird"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "flow_title": "DoorBird {name{ ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "Numm (IP Adresse)",
+ "name": "Numm vum Apparat",
+ "password": "Passwuert",
+ "username": "Benotzernumm"
+ },
+ "title": "Mat DoorBird verbannen"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Komma getrennte L\u00ebscht vun Evenementer"
+ },
+ "description": "Setzt ee mat Komma getrennten Evenement Numm fir all Evenement dob\u00e4i d\u00e9i sollt suiv\u00e9iert ginn. Wann's du se hei aginn hues, benotz d'DoorBird App fir se zu engem spezifeschen Evenement dob\u00e4i ze setzen. Kuckt d'Dokumentatioun op https://www.home-assistant.io/integrations/doorbird/#events. Beispill: somebody_pressed_the_button, motion"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/no.json b/homeassistant/components/doorbird/.translations/no.json
new file mode 100644
index 00000000000..29fb34672c8
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/no.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Denne DoorBird er allerede konfigurert",
+ "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke",
+ "not_doorbird_device": "Denne enheten er ikke en DoorBird"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Vert (IP-adresse)",
+ "name": "Enhetsnavn",
+ "password": "Passord",
+ "username": "Brukernavn"
+ },
+ "title": "Koble til DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "Kommaseparert liste over hendelser."
+ },
+ "description": "Legg til et kommaseparert hendelsesnavn for hvert arrangement du \u00f8nsker \u00e5 spore. Etter \u00e5 ha skrevet dem inn her, bruker du DoorBird-appen til \u00e5 tilordne dem til en bestemt hendelse. Se dokumentasjonen p\u00e5 https://www.home-assistant.io/integrations/doorbird/#events. Eksempel: noen_trykket_knappen, bevegelse"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/ru.json b/homeassistant/components/doorbird/.translations/ru.json
new file mode 100644
index 00000000000..1c034d6d68b
--- /dev/null
+++ b/homeassistant/components/doorbird/.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.",
+ "link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.",
+ "not_doorbird_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 DoorBird."
+ },
+ "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."
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "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 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "\u0421\u043f\u0438\u0441\u043e\u043a \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e."
+ },
+ "description": "\u0414\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0441\u043e\u0431\u044b\u0442\u0438\u0439, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c. \u041f\u043e\u0441\u043b\u0435 \u044d\u0442\u043e\u0433\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 DoorBird, \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u0438\u0445 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u043c\u0443 \u0441\u043e\u0431\u044b\u0442\u0438\u044e. \u041f\u0440\u0438\u043c\u0435\u0440: somebody_pressed_the_button, motion. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438: https://www.home-assistant.io/integrations/doorbird/#events."
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/.translations/zh-Hant.json b/homeassistant/components/doorbird/.translations/zh-Hant.json
new file mode 100644
index 00000000000..bb8b291f86b
--- /dev/null
+++ b/homeassistant/components/doorbird/.translations/zh-Hant.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64 DoorBird \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740",
+ "not_doorbird_device": "\u6b64\u8a2d\u5099\u4e26\u975e DoorBird"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "flow_title": "DoorBird {name} ({host})",
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\uff08IP \u4f4d\u5740\uff09",
+ "name": "\u8a2d\u5099\u540d\u7a31",
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "title": "\u9023\u7dda\u81f3 DoorBird"
+ }
+ },
+ "title": "DoorBird"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "events": "\u4ee5\u9017\u865f\u5206\u5225\u4e8b\u4ef6\u5217\u8868\u3002"
+ },
+ "description": "\u4ee5\u9017\u865f\u5206\u5225\u6240\u8981\u8ffd\u8e64\u7684\u4e8b\u4ef6\u540d\u7a31\u3002\u65bc\u6b64\u8f38\u5165\u5f8c\uff0c\u4f7f\u7528 DoorBird App \u6307\u5b9a\u81f3\u7279\u5b9a\u4e8b\u4ef6\u3002\u8acb\u53c3\u95b1\u6587\u4ef6\uff1ahttps://www.home-assistant.io/integrations/doorbird/#events\u3002\u4f8b\u5982\uff1asomebody_pressed_the_button, motion"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py
index 049681a4aa6..f762a722f2f 100644
--- a/homeassistant/components/doorbird/__init__.py
+++ b/homeassistant/components/doorbird/__init__.py
@@ -1,5 +1,7 @@
"""Support for DoorBird devices."""
+import asyncio
import logging
+import urllib
from urllib.error import HTTPError
from doorbirdpy import DoorBird
@@ -7,6 +9,7 @@ import voluptuous as vol
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.logbook import log_entry
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_DEVICES,
CONF_HOST,
@@ -15,17 +18,19 @@ from homeassistant.const import (
CONF_TOKEN,
CONF_USERNAME,
)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.util import dt as dt_util, slugify
-_LOGGER = logging.getLogger(__name__)
+from .const import CONF_EVENTS, DOMAIN, DOOR_STATION, DOOR_STATION_INFO, PLATFORMS
+from .util import get_doorstation_by_token
-DOMAIN = "doorbird"
+_LOGGER = logging.getLogger(__name__)
API_URL = f"/api/{DOMAIN}"
CONF_CUSTOM_URL = "hass_url_override"
-CONF_EVENTS = "events"
RESET_DEVICE_FAVORITES = "doorbird_reset_favorites"
@@ -51,72 +56,24 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup(hass, config):
+async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the DoorBird component."""
+ hass.data.setdefault(DOMAIN, {})
+
# Provide an endpoint for the doorstations to call to trigger events
hass.http.register_view(DoorBirdRequestView)
- doorstations = []
+ if DOMAIN in config and CONF_DEVICES in config[DOMAIN]:
+ for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]):
+ if CONF_NAME not in doorstation_config:
+ doorstation_config[CONF_NAME] = f"DoorBird {index + 1}"
- for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]):
- device_ip = doorstation_config.get(CONF_HOST)
- username = doorstation_config.get(CONF_USERNAME)
- password = doorstation_config.get(CONF_PASSWORD)
- 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 f"DoorBird {index + 1}"
-
- try:
- device = DoorBird(device_ip, username, password)
- status = device.ready()
- except OSError as oserr:
- _LOGGER.error(
- "Failed to setup doorbird at %s: %s; not retrying", device_ip, oserr
- )
- continue
-
- if status[0]:
- doorstation = ConfiguredDoorBird(device, name, events, custom_url, token)
- doorstations.append(doorstation)
- _LOGGER.info(
- 'Connected to DoorBird "%s" as %s@%s',
- doorstation.name,
- username,
- device_ip,
- )
- elif status[1] == 401:
- _LOGGER.error(
- "Authorization rejected by DoorBird for %s@%s", username, device_ip
- )
- return False
- else:
- _LOGGER.error(
- "Could not connect to DoorBird as %s@%s: Error %s",
- username,
- device_ip,
- str(status[1]),
- )
- return False
-
- # Subscribe to doorbell or motion events
- if events:
- try:
- doorstation.register_events(hass)
- except HTTPError:
- hass.components.persistent_notification.create(
- "Doorbird configuration failed. Please verify that API "
- "Operator permission is enabled for the Doorbird user. "
- "A restart will be required once permissions have been "
- "verified.",
- title="Doorbird Configuration Failure",
- notification_id="doorbird_schedule_error",
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=doorstation_config,
)
-
- return False
-
- hass.data[DOMAIN] = doorstations
+ )
def _reset_device_favorites_handler(event):
"""Handle clearing favorites on device."""
@@ -129,6 +86,7 @@ def setup(hass, config):
if doorstation is None:
_LOGGER.error("Device not found for provided token.")
+ return
# Clear webhooks
favorites = doorstation.device.favorites()
@@ -137,16 +95,126 @@ def setup(hass, config):
for favorite_id in favorites[favorite_type]:
doorstation.device.delete_favorite(favorite_type, favorite_id)
- hass.bus.listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler)
+ hass.bus.async_listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler)
return True
-def get_doorstation_by_token(hass, token):
- """Get doorstation by slug."""
- for doorstation in hass.data[DOMAIN]:
- if token == doorstation.token:
- return doorstation
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up DoorBird from a config entry."""
+
+ _async_import_options_from_data_if_missing(hass, entry)
+
+ doorstation_config = entry.data
+ doorstation_options = entry.options
+ config_entry_id = entry.entry_id
+
+ device_ip = doorstation_config[CONF_HOST]
+ username = doorstation_config[CONF_USERNAME]
+ password = doorstation_config[CONF_PASSWORD]
+
+ device = DoorBird(device_ip, username, password)
+ try:
+ status = await hass.async_add_executor_job(device.ready)
+ info = await hass.async_add_executor_job(device.info)
+ except urllib.error.HTTPError as err:
+ if err.code == 401:
+ _LOGGER.error(
+ "Authorization rejected by DoorBird for %s@%s", username, device_ip
+ )
+ return False
+ raise ConfigEntryNotReady
+ except OSError as oserr:
+ _LOGGER.error("Failed to setup doorbird at %s: %s", device_ip, oserr)
+ raise ConfigEntryNotReady
+
+ if not status[0]:
+ _LOGGER.error(
+ "Could not connect to DoorBird as %s@%s: Error %s",
+ username,
+ device_ip,
+ str(status[1]),
+ )
+ raise ConfigEntryNotReady
+
+ token = doorstation_config.get(CONF_TOKEN, config_entry_id)
+ custom_url = doorstation_config.get(CONF_CUSTOM_URL)
+ name = doorstation_config.get(CONF_NAME)
+ events = doorstation_options.get(CONF_EVENTS, [])
+ doorstation = ConfiguredDoorBird(device, name, events, custom_url, token)
+ # Subscribe to doorbell or motion events
+ if not await _async_register_events(hass, doorstation):
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][config_entry_id] = {
+ DOOR_STATION: doorstation,
+ DOOR_STATION_INFO: info,
+ }
+
+ entry.add_update_listener(_update_listener)
+
+ 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
+
+
+async def _async_register_events(hass, doorstation):
+ try:
+ await hass.async_add_executor_job(doorstation.register_events, hass)
+ except HTTPError:
+ hass.components.persistent_notification.create(
+ "Doorbird configuration failed. Please verify that API "
+ "Operator permission is enabled for the Doorbird user. "
+ "A restart will be required once permissions have been "
+ "verified.",
+ title="Doorbird Configuration Failure",
+ notification_id="doorbird_schedule_error",
+ )
+ return False
+
+ return True
+
+
+async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
+ """Handle options update."""
+ config_entry_id = entry.entry_id
+ doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
+
+ doorstation.events = entry.options[CONF_EVENTS]
+ # Subscribe to doorbell or motion events
+ await _async_register_events(hass, doorstation)
+
+
+@callback
+def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
+ options = dict(entry.options)
+ modified = False
+ for importable_option in [CONF_EVENTS]:
+ if importable_option not in entry.options and importable_option in entry.data:
+ options[importable_option] = entry.data[importable_option]
+ modified = True
+
+ if modified:
+ hass.config_entries.async_update_entry(entry, options=options)
class ConfiguredDoorBird:
@@ -157,7 +225,7 @@ class ConfiguredDoorBird:
self._name = name
self._device = device
self._custom_url = custom_url
- self._events = events
+ self.events = events
self._token = token
@property
@@ -189,7 +257,7 @@ class ConfiguredDoorBird:
if self.custom_url is not None:
hass_url = self.custom_url
- for event in self._events:
+ for event in self.events:
event = self._get_event_name(event)
self._register_event(hass_url, event)
diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py
index 4bf3a6e060f..bf999489589 100644
--- a/homeassistant/components/doorbird/camera.py
+++ b/homeassistant/components/doorbird/camera.py
@@ -10,46 +10,69 @@ from homeassistant.components.camera import SUPPORT_STREAM, Camera
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.util.dt as dt_util
-from . import DOMAIN as DOORBIRD_DOMAIN
+from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO
+from .entity import DoorBirdEntity
-_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1)
-_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1)
-_LIVE_INTERVAL = datetime.timedelta(seconds=1)
+_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=2)
+_LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30)
+_LIVE_INTERVAL = datetime.timedelta(seconds=45)
_LOGGER = logging.getLogger(__name__)
-_TIMEOUT = 10 # seconds
+_TIMEOUT = 15 # seconds
-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 DoorBird camera platform."""
- for doorstation in hass.data[DOORBIRD_DOMAIN]:
- device = doorstation.device
- async_add_entities(
- [
- DoorBirdCamera(
- device.live_image_url,
- f"{doorstation.name} Live",
- _LIVE_INTERVAL,
- device.rtsp_live_video_url,
- ),
- DoorBirdCamera(
- device.history_image_url(1, "doorbell"),
- f"{doorstation.name} Last Ring",
- _LAST_VISITOR_INTERVAL,
- ),
- DoorBirdCamera(
- device.history_image_url(1, "motionsensor"),
- f"{doorstation.name} Last Motion",
- _LAST_MOTION_INTERVAL,
- ),
- ]
- )
+ config_entry_id = config_entry.entry_id
+ doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
+ doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO]
+ device = doorstation.device
+
+ async_add_entities(
+ [
+ DoorBirdCamera(
+ doorstation,
+ doorstation_info,
+ device.live_image_url,
+ "live",
+ f"{doorstation.name} Live",
+ _LIVE_INTERVAL,
+ device.rtsp_live_video_url,
+ ),
+ DoorBirdCamera(
+ doorstation,
+ doorstation_info,
+ device.history_image_url(1, "doorbell"),
+ "last_ring",
+ f"{doorstation.name} Last Ring",
+ _LAST_VISITOR_INTERVAL,
+ ),
+ DoorBirdCamera(
+ doorstation,
+ doorstation_info,
+ device.history_image_url(1, "motionsensor"),
+ "last_motion",
+ f"{doorstation.name} Last Motion",
+ _LAST_MOTION_INTERVAL,
+ ),
+ ]
+ )
-class DoorBirdCamera(Camera):
+class DoorBirdCamera(DoorBirdEntity, Camera):
"""The camera on a DoorBird device."""
- def __init__(self, url, name, interval=None, stream_url=None):
+ def __init__(
+ self,
+ doorstation,
+ doorstation_info,
+ url,
+ camera_id,
+ name,
+ interval=None,
+ stream_url=None,
+ ):
"""Initialize the camera on a DoorBird device."""
+ super().__init__(doorstation, doorstation_info)
self._url = url
self._stream_url = stream_url
self._name = name
@@ -57,12 +80,17 @@ class DoorBirdCamera(Camera):
self._supported_features = SUPPORT_STREAM if self._stream_url else 0
self._interval = interval or datetime.timedelta
self._last_update = datetime.datetime.min
- super().__init__()
+ self._unique_id = f"{self._mac_addr}_{camera_id}"
async def stream_source(self):
"""Return the stream source."""
return self._stream_url
+ @property
+ def unique_id(self):
+ """Camera Unique id."""
+ return self._unique_id
+
@property
def supported_features(self):
"""Return supported features."""
@@ -89,8 +117,10 @@ class DoorBirdCamera(Camera):
self._last_update = now
return self._last_image
except asyncio.TimeoutError:
- _LOGGER.error("Camera image timed out")
+ _LOGGER.error("DoorBird %s: Camera image timed out", self._name)
return self._last_image
except aiohttp.ClientError as error:
- _LOGGER.error("Error getting camera image: %s", error)
+ _LOGGER.error(
+ "DoorBird %s: Error getting camera image: %s", self._name, error
+ )
return self._last_image
diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py
new file mode 100644
index 00000000000..aa712a63ed0
--- /dev/null
+++ b/homeassistant/components/doorbird/config_flow.py
@@ -0,0 +1,160 @@
+"""Config flow for DoorBird integration."""
+from ipaddress import ip_address
+import logging
+import urllib
+
+from doorbirdpy import DoorBird
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
+from homeassistant.util.network import is_link_local
+
+from .const import CONF_EVENTS, DOORBIRD_OUI
+from .const import DOMAIN # pylint:disable=unused-import
+from .util import get_mac_address_from_doorstation_info
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _schema_with_defaults(host=None, name=None):
+ return vol.Schema(
+ {
+ vol.Required(CONF_HOST, default=host): str,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_NAME, default=name): str,
+ }
+ )
+
+
+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.
+ """
+ device = DoorBird(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD])
+ try:
+ status = await hass.async_add_executor_job(device.ready)
+ info = await hass.async_add_executor_job(device.info)
+ except urllib.error.HTTPError as err:
+ if err.code == 401:
+ raise InvalidAuth
+ raise CannotConnect
+ except OSError:
+ raise CannotConnect
+
+ if not status[0]:
+ raise CannotConnect
+
+ mac_addr = get_mac_address_from_doorstation_info(info)
+
+ # Return info that you want to store in the config entry.
+ return {"title": data[CONF_HOST], "mac_addr": mac_addr}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for DoorBird."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize the DoorBird config flow."""
+ self.discovery_schema = {}
+
+ 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 CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(info["mac_addr"])
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=info["title"], data=user_input)
+
+ data = self.discovery_schema or _schema_with_defaults()
+ return self.async_show_form(step_id="user", data_schema=data, errors=errors)
+
+ async def async_step_zeroconf(self, discovery_info):
+ """Prepare configuration for a discovered doorbird device."""
+ macaddress = discovery_info["properties"]["macaddress"]
+
+ if macaddress[:6] != DOORBIRD_OUI:
+ return self.async_abort(reason="not_doorbird_device")
+ if is_link_local(ip_address(discovery_info[CONF_HOST])):
+ return self.async_abort(reason="link_local_address")
+
+ await self.async_set_unique_id(macaddress)
+
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: discovery_info[CONF_HOST]}
+ )
+
+ chop_ending = "._axis-video._tcp.local."
+ friendly_hostname = discovery_info["name"]
+ if friendly_hostname.endswith(chop_ending):
+ friendly_hostname = friendly_hostname[: -len(chop_ending)]
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ self.context["title_placeholders"] = {
+ CONF_NAME: friendly_hostname,
+ CONF_HOST: discovery_info[CONF_HOST],
+ }
+ self.discovery_schema = _schema_with_defaults(
+ host=discovery_info[CONF_HOST], name=friendly_hostname
+ )
+
+ return await self.async_step_user()
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ return await self.async_step_user(user_input)
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler(config_entry)
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a option flow for doorbird."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry):
+ """Initialize options flow."""
+ self.config_entry = config_entry
+
+ async def async_step_init(self, user_input=None):
+ """Handle options flow."""
+ if user_input is not None:
+ events = [event.strip() for event in user_input[CONF_EVENTS].split(",")]
+
+ return self.async_create_entry(title="", data={CONF_EVENTS: events})
+
+ current_events = self.config_entry.options.get(CONF_EVENTS, [])
+
+ # We convert to a comma separated list for the UI
+ # since there really isn't anything better
+ options_schema = vol.Schema(
+ {vol.Optional(CONF_EVENTS, default=", ".join(current_events)): str}
+ )
+ return self.async_show_form(step_id="init", data_schema=options_schema)
+
+
+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/doorbird/const.py b/homeassistant/components/doorbird/const.py
new file mode 100644
index 00000000000..3b639fc8dca
--- /dev/null
+++ b/homeassistant/components/doorbird/const.py
@@ -0,0 +1,17 @@
+"""The DoorBird integration constants."""
+
+
+DOMAIN = "doorbird"
+PLATFORMS = ["switch", "camera"]
+DOOR_STATION = "door_station"
+DOOR_STATION_INFO = "door_station_info"
+CONF_EVENTS = "events"
+MANUFACTURER = "Bird Home Automation Group"
+DOORBIRD_OUI = "1CCAE3"
+
+DOORBIRD_INFO_KEY_FIRMWARE = "FIRMWARE"
+DOORBIRD_INFO_KEY_BUILD_NUMBER = "BUILD_NUMBER"
+DOORBIRD_INFO_KEY_DEVICE_TYPE = "DEVICE-TYPE"
+DOORBIRD_INFO_KEY_RELAYS = "RELAYS"
+DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR"
+DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR"
diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py
new file mode 100644
index 00000000000..44cbb1f42de
--- /dev/null
+++ b/homeassistant/components/doorbird/entity.py
@@ -0,0 +1,36 @@
+"""The DoorBird integration base entity."""
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ DOORBIRD_INFO_KEY_BUILD_NUMBER,
+ DOORBIRD_INFO_KEY_DEVICE_TYPE,
+ DOORBIRD_INFO_KEY_FIRMWARE,
+ MANUFACTURER,
+)
+from .util import get_mac_address_from_doorstation_info
+
+
+class DoorBirdEntity(Entity):
+ """Base class for doorbird entities."""
+
+ def __init__(self, doorstation, doorstation_info):
+ """Initialize the entity."""
+ super().__init__()
+ self._doorstation_info = doorstation_info
+ self._doorstation = doorstation
+ self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info)
+
+ @property
+ def device_info(self):
+ """Doorbird device info."""
+ firmware = self._doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE]
+ firmware_build = self._doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER]
+ return {
+ "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_addr)},
+ "name": self._doorstation.name,
+ "manufacturer": MANUFACTURER,
+ "sw_version": f"{firmware} {firmware_build}",
+ "model": self._doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE],
+ }
diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json
index 1703557cc9e..e0aef80ab61 100644
--- a/homeassistant/components/doorbird/manifest.json
+++ b/homeassistant/components/doorbird/manifest.json
@@ -2,7 +2,16 @@
"domain": "doorbird",
"name": "DoorBird",
"documentation": "https://www.home-assistant.io/integrations/doorbird",
- "requirements": ["doorbirdpy==2.0.8"],
- "dependencies": ["http", "logbook"],
- "codeowners": ["@oblogic7"]
+ "requirements": [
+ "doorbirdpy==2.0.8"
+ ],
+ "dependencies": [
+ "http",
+ "logbook"
+ ],
+ "zeroconf": ["_axis-video._tcp.local."],
+ "codeowners": [
+ "@oblogic7", "@bdraco"
+ ],
+ "config_flow": true
}
diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json
new file mode 100644
index 00000000000..e4fb72db91b
--- /dev/null
+++ b/homeassistant/components/doorbird/strings.json
@@ -0,0 +1,37 @@
+{
+ "options" : {
+ "step" : {
+ "init" : {
+ "data" : {
+ "events" : "Comma separated list of events."
+ },
+ "description" : "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event. See the documentation at https://www.home-assistant.io/integrations/doorbird/#events. Example: somebody_pressed_the_button, motion"
+ }
+ }
+ },
+ "config" : {
+ "step" : {
+ "user" : {
+ "title" : "Connect to the DoorBird",
+ "data" : {
+ "password" : "Password",
+ "host" : "Host (IP Address)",
+ "name" : "Device Name",
+ "username" : "Username"
+ }
+ }
+ },
+ "abort" : {
+ "already_configured" : "This DoorBird is already configured",
+ "link_local_address": "Link local addresses are not supported",
+ "not_doorbird_device": "This device is not a DoorBird"
+ },
+ "title" : "DoorBird",
+ "flow_title" : "DoorBird {name} ({host})",
+ "error" : {
+ "invalid_auth" : "Invalid authentication",
+ "unknown" : "Unexpected error",
+ "cannot_connect" : "Failed to connect, please try again"
+ }
+ }
+}
diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py
index 7a0dfa82e76..9f292803b8b 100644
--- a/homeassistant/components/doorbird/switch.py
+++ b/homeassistant/components/doorbird/switch.py
@@ -5,33 +5,38 @@ import logging
from homeassistant.components.switch import SwitchDevice
import homeassistant.util.dt as dt_util
-from . import DOMAIN as DOORBIRD_DOMAIN
+from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO
+from .entity import DoorBirdEntity
_LOGGER = logging.getLogger(__name__)
IR_RELAY = "__ir_light__"
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the DoorBird switch platform."""
- switches = []
+ entities = []
+ config_entry_id = config_entry.entry_id
- for doorstation in hass.data[DOORBIRD_DOMAIN]:
- relays = doorstation.device.info()["RELAYS"]
- relays.append(IR_RELAY)
+ doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
+ doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO]
- for relay in relays:
- switch = DoorBirdSwitch(doorstation, relay)
- switches.append(switch)
+ relays = doorstation_info["RELAYS"]
+ relays.append(IR_RELAY)
- add_entities(switches)
+ for relay in relays:
+ switch = DoorBirdSwitch(doorstation, doorstation_info, relay)
+ entities.append(switch)
+
+ async_add_entities(entities)
-class DoorBirdSwitch(SwitchDevice):
+class DoorBirdSwitch(DoorBirdEntity, SwitchDevice):
"""A relay in a DoorBird device."""
- def __init__(self, doorstation, relay):
+ def __init__(self, doorstation, doorstation_info, relay):
"""Initialize a relay in a DoorBird device."""
+ super().__init__(doorstation, doorstation_info)
self._doorstation = doorstation
self._relay = relay
self._state = False
@@ -41,6 +46,12 @@ class DoorBirdSwitch(SwitchDevice):
self._time = datetime.timedelta(minutes=5)
else:
self._time = datetime.timedelta(seconds=5)
+ self._unique_id = f"{self._mac_addr}_{self._relay}"
+
+ @property
+ def unique_id(self):
+ """Switch unique id."""
+ return self._unique_id
@property
def name(self):
diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py
new file mode 100644
index 00000000000..7db9063580d
--- /dev/null
+++ b/homeassistant/components/doorbird/util.py
@@ -0,0 +1,19 @@
+"""DoorBird integration utils."""
+
+from .const import DOMAIN, DOOR_STATION
+
+
+def get_mac_address_from_doorstation_info(doorstation_info):
+ """Get the mac address depending on the device type."""
+ if "PRIMARY_MAC_ADDR" in doorstation_info:
+ return doorstation_info["PRIMARY_MAC_ADDR"]
+ return doorstation_info["WIFI_MAC_ADDR"]
+
+
+def get_doorstation_by_token(hass, token):
+ """Get doorstation by slug."""
+ for config_entry_id in hass.data[DOMAIN]:
+ doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
+
+ if token == doorstation.token:
+ return doorstation
diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py
index e27bdfbb142..973d09a384f 100755
--- a/homeassistant/components/dynalite/__init__.py
+++ b/homeassistant/components/dynalite/__init__.py
@@ -141,7 +141,7 @@ async def async_entry_changed(hass, entry):
"""Reload entry since the data has changed."""
LOGGER.debug("Reconfiguring entry %s", entry.data)
bridge = hass.data[DOMAIN][entry.entry_id]
- await bridge.reload_config(entry.data)
+ bridge.reload_config(entry.data)
LOGGER.debug("Reconfiguring entry finished %s", entry.data)
diff --git a/homeassistant/components/ecobee/.translations/no.json b/homeassistant/components/ecobee/.translations/no.json
index efaa566c424..6658c3ac2e9 100644
--- a/homeassistant/components/ecobee/.translations/no.json
+++ b/homeassistant/components/ecobee/.translations/no.json
@@ -20,6 +20,6 @@
"title": "ecobee API-n\u00f8kkel"
}
},
- "title": "ecobee"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json
index 9f6b861c8fb..d6bc3b1eaa1 100644
--- a/homeassistant/components/ecobee/manifest.json
+++ b/homeassistant/components/ecobee/manifest.json
@@ -4,6 +4,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ecobee",
"dependencies": [],
- "requirements": ["python-ecobee-api==0.2.2"],
+ "requirements": ["python-ecobee-api==0.2.5"],
"codeowners": ["@marthoc"]
}
diff --git a/homeassistant/components/elgato/.translations/zh-Hant.json b/homeassistant/components/elgato/.translations/zh-Hant.json
index b187abc5ccd..c0c638851a1 100644
--- a/homeassistant/components/elgato/.translations/zh-Hant.json
+++ b/homeassistant/components/elgato/.translations/zh-Hant.json
@@ -19,7 +19,7 @@
},
"zeroconf_confirm": {
"description": "\u662f\u5426\u8981\u5c07 Elgato Key \u7167\u660e\u5e8f\u865f `{serial_number}` \u65b0\u589e\u81f3 Home Assistant\uff1f",
- "title": "\u767c\u73fe\u5230 Elgato Key \u7167\u660e\u8a2d\u5099"
+ "title": "\u81ea\u52d5\u63a2\u7d22\u5230 Elgato Key \u7167\u660e\u8a2d\u5099"
}
},
"title": "Elgato Key \u7167\u660e"
diff --git a/homeassistant/components/elkm1/.translations/ca.json b/homeassistant/components/elkm1/.translations/ca.json
new file mode 100644
index 00000000000..a426b7a3433
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/ca.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "Ja hi ha un Elk-M1 configurat amb aquesta adre\u00e7a",
+ "already_configured": "Ja hi ha un Elk-M1 configurat amb aquest prefix"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "Adre\u00e7a IP, domini o port s\u00e8rie (si es est\u00e0 connectat amb una connexi\u00f3 s\u00e8rie).",
+ "password": "Contrasenya (nom\u00e9s segur).",
+ "prefix": "Prefix \u00fanic (deixa-ho en blanc si nom\u00e9s tens un \u00fanic controlador Elk-M1).",
+ "protocol": "Protocol",
+ "temperature_unit": "Unitats de temperatura que utilitza l'Elk-M1.",
+ "username": "Nom d'usuari (nom\u00e9s segur)."
+ },
+ "description": "La cadena de car\u00e0cters (string) de l'adre\u00e7a ha de tenir el format: 'adre\u00e7a[:port]' tant per al mode 'segur' com el 'no segur'. Exemple: '192.168.1.1'. El port \u00e9s opcional, per defecte \u00e9s el 2101 pel mode 'no segur' i el 2601 pel 'segur'. Per al protocol s\u00e8rie, l'adre\u00e7a ha de tenir el format 'tty[:baud]'. Exemple: '/dev/ttyS1'. La velocitat en bauds \u00e9s opcional (115200 per defecte).",
+ "title": "Connexi\u00f3 amb el controlador Elk-M1"
+ }
+ },
+ "title": "Controlador Elk-M1"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/de.json b/homeassistant/components/elkm1/.translations/de.json
new file mode 100644
index 00000000000..40e6cff4460
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/de.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "Ein ElkM1 mit dieser Adresse ist bereits konfiguriert",
+ "already_configured": "Ein ElkM1 mit diesem Pr\u00e4fix ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "invalid_auth": "Ung\u00fcltige Authentifizierung",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "Die IP-Adresse, die Domain oder der serielle Port bei einer seriellen Verbindung.",
+ "password": "Passwort (Nur sicher).",
+ "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn Sie nur einen ElkM1 haben).",
+ "protocol": "Protokoll",
+ "temperature_unit": "Die von ElkM1 verwendete Temperatureinheit.",
+ "username": "Benutzername (Nur sicher)."
+ },
+ "description": "Die Adresszeichenfolge muss in der Form 'adresse[:port]' f\u00fcr 'sicher' und 'nicht sicher' vorliegen. Beispiel: '192.168.1.1'. Der Port ist optional und standardm\u00e4\u00dfig 2101 f\u00fcr \"nicht sicher\" und 2601 f\u00fcr \"sicher\". F\u00fcr das serielle Protokoll muss die Adresse die Form 'tty[:baud]' haben. Beispiel: '/dev/ttyS1'. Der Baudrate ist optional und standardm\u00e4\u00dfig 115200.",
+ "title": "Stellen Sie eine Verbindung zur Elk-M1-Steuerung her"
+ }
+ },
+ "title": "Elk-M1-Steuerung"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/en.json b/homeassistant/components/elkm1/.translations/en.json
new file mode 100644
index 00000000000..7671e250bf3
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/en.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "An ElkM1 with this address is already configured",
+ "already_configured": "An ElkM1 with this prefix is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "The IP address or domain or serial port if connecting via serial.",
+ "password": "Password (secure only).",
+ "prefix": "A unique prefix (leave blank if you only have one ElkM1).",
+ "protocol": "Protocol",
+ "temperature_unit": "The temperature unit ElkM1 uses.",
+ "username": "Username (secure only)."
+ },
+ "description": "The address string must be in the form 'address[:port]' for 'secure' and 'non-secure'. Example: '192.168.1.1'. The port is optional and defaults to 2101 for 'non-secure' and 2601 for 'secure'. For the serial protocol, the address must be in the form 'tty[:baud]'. Example: '/dev/ttyS1'. The baud is optional and defaults to 115200.",
+ "title": "Connect to Elk-M1 Control"
+ }
+ },
+ "title": "Elk-M1 Control"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/es.json b/homeassistant/components/elkm1/.translations/es.json
new file mode 100644
index 00000000000..8fdce004c41
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/es.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "Ya est\u00e1 configurado un Elk-M1 con esta direcci\u00f3n",
+ "already_configured": "Ya est\u00e1 configurado un Elk-M1 con este prefijo"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "La direcci\u00f3n IP o dominio o puerto serie si se conecta a trav\u00e9s de serie.",
+ "password": "Contrase\u00f1a (s\u00f3lo seguro)",
+ "prefix": "Un prefijo \u00fanico (d\u00e9jalo en blanco si s\u00f3lo tienes un Elk-M1).",
+ "protocol": "Protocolo",
+ "temperature_unit": "La temperatura que usa la unidad Elk-M1",
+ "username": "Usuario (s\u00f3lo seguro)"
+ },
+ "description": "La cadena de direcci\u00f3n debe estar en el formato 'direcci\u00f3n[:puerto]' para 'seguro' y 'no-seguro'. Ejemplo: '192.168.1.1'. El puerto es opcional y el valor predeterminado es 2101 para 'no-seguro' y 2601 para 'seguro'. Para el protocolo serie, la direcci\u00f3n debe tener la forma 'tty[:baudios]'. Ejemplo: '/dev/ttyS1'. Los baudios son opcionales y el valor predeterminado es 115200.",
+ "title": "Conectar con Control Elk-M1"
+ }
+ },
+ "title": "Control Elk-M1"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/fr.json b/homeassistant/components/elkm1/.translations/fr.json
new file mode 100644
index 00000000000..20ad7b8b007
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/fr.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification non valide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "L'adresse IP ou le domaine ou le port s\u00e9rie si vous vous connectez via s\u00e9rie.",
+ "password": "Mot de passe (s\u00e9curis\u00e9 uniquement).",
+ "protocol": "Protocole",
+ "username": "Nom d'utilisateur (s\u00e9curis\u00e9 uniquement)."
+ },
+ "title": "Se connecter a Elk-M1 Control"
+ }
+ },
+ "title": "Elk-M1 Control"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/it.json b/homeassistant/components/elkm1/.translations/it.json
new file mode 100644
index 00000000000..c3f1941d8b5
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/it.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "Un ElkM1 con questo indirizzo \u00e8 gi\u00e0 configurato",
+ "already_configured": "Un ElkM1 con questo prefisso \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "L'indirizzo IP o il dominio o la porta seriale se ci si connette tramite seriale.",
+ "password": "Password (solo sicura).",
+ "prefix": "Un prefisso univoco (lasciare vuoto se si dispone di un solo ElkM1).",
+ "protocol": "Protocollo",
+ "temperature_unit": "L'unit\u00e0 di temperatura utilizzata da ElkM1.",
+ "username": "Nome utente (solo sicuro)."
+ },
+ "description": "La stringa di indirizzi deve essere nella forma \"address[:port]\" per \"secure\" e \"non secure\". Esempio: '192.168.1.1.1'. La porta \u00e8 facoltativa e il valore predefinito \u00e8 2101 per 'non sicuro' e 2601 per 'sicuro'. Per il protocollo seriale, l'indirizzo deve essere nella forma 'tty[:baud]'. Esempio: '/dev/ttyS1'. Il baud \u00e8 opzionale e il valore predefinito \u00e8 115200.",
+ "title": "Collegamento al controllo Elk-M1"
+ }
+ },
+ "title": "Controllo Elk-M1"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/ko.json b/homeassistant/components/elkm1/.translations/ko.json
new file mode 100644
index 00000000000..4ef1e528c22
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/ko.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "\uc774 \uc8fc\uc18c\ub85c ElkM1 \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "already_configured": "\uc774 \uc811\ub450\uc0ac\ub85c ElkM1 \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "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": {
+ "address": "\uc2dc\ub9ac\uc5bc\uc744 \ud1b5\ud574 \uc5f0\uacb0\ud558\ub294 \uacbd\uc6b0\uc758 IP \uc8fc\uc18c \ub098 \ub3c4\uba54\uc778 \ub610\ub294 \uc2dc\ub9ac\uc5bc \ud3ec\ud2b8",
+ "password": "\ube44\ubc00\ubc88\ud638 (\ubcf4\uc548 \uc804\uc6a9).",
+ "prefix": "\uace0\uc720\ud55c \uc811\ub450\uc0ac (ElkM1 \uc774 \ud558\ub098\ub9cc \uc788\uc73c\uba74 \ube44\uc6cc\ub450\uc138\uc694).",
+ "protocol": "\ud504\ub85c\ud1a0\ucf5c",
+ "temperature_unit": "ElkM1 \uc774 \uc0ac\uc6a9\ud558\ub294 \uc628\ub3c4 \ub2e8\uc704",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984 (\ubcf4\uc548 \uc804\uc6a9)."
+ },
+ "description": "\uc8fc\uc18c \ubb38\uc790\uc5f4\uc740 '\ubcf4\uc548' \ubc0f '\ube44\ubcf4\uc548' \uc758 \uacbd\uc6b0 'address[:port]' \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4. \uc608: '192.168.1.1'. \ud3ec\ud2b8\ub294 \uc120\ud0dd \uc0ac\ud56d\uc774\uba70 \uae30\ubcf8\uac12\uc740 '\ube44\ubcf4\uc548' \uc758 \uacbd\uc6b0 2101 \uc774\uace0 '\ubcf4\uc548' \uc758 \uacbd\uc6b0 2601 \uc785\ub2c8\ub2e4. \uc2dc\ub9ac\uc5bc \ud504\ub85c\ud1a0\ucf5c\uc758 \uacbd\uc6b0 \uc8fc\uc18c\ub294 'tty[:baud]' \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4. \uc608: '/dev/ttyS1'. \ud1b5\uc2e0\uc18d\ub3c4 \ubc14\uc6b0\ub4dc\ub294 \uc120\ud0dd \uc0ac\ud56d\uc774\uba70 \uae30\ubcf8\uac12\uc740 115200 \uc785\ub2c8\ub2e4.",
+ "title": "Elk-M1 \uc81c\uc5b4\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "Elk-M1 \uc81c\uc5b4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/lb.json b/homeassistant/components/elkm1/.translations/lb.json
new file mode 100644
index 00000000000..bb56b4c8154
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/lb.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "Een ElkM1 mat d\u00ebser Adress ass scho konfigur\u00e9iert",
+ "already_configured": "Een ElkM1 mat d\u00ebsem Prefix ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "IP Adress oder Domain oder Serielle Port falls d'Verbindung seriell ass.",
+ "password": "Passwuert (n\u00ebmmen ges\u00e9chert)",
+ "prefix": "Een eenzegaartege Pr\u00e4fix (eidel lossen wann et n\u00ebmmen 1 ElkM1 g\u00ebtt)",
+ "protocol": "Protokoll",
+ "temperature_unit": "Temperatur Eenheet d\u00e9i den ElkM1 benotzt.",
+ "username": "Benotzernumm (n\u00ebmmen ges\u00e9chert)"
+ },
+ "description": "D'Adress muss an der Form 'adress[:port]' fir 'ges\u00e9chert' an 'onges\u00e9chert' sinn. Beispill: '192.168.1.1'. De Port os optionell an ass standardm\u00e9isseg op 2101 fir 'onges\u00e9chert' an op 2601 fir 'ges\u00e9chert' d\u00e9fin\u00e9iert. Fir de serielle Protokoll, muss d'Adress an der Form 'tty[:baud]' sinn. Beispill: '/dev/ttyS1'. Baud Rate ass optionell an ass standardmlisseg op 115200 d\u00e9fin\u00e9iert.",
+ "title": "Mat Elk-M1 Control verbannen"
+ }
+ },
+ "title": "Elk-M1 Control"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/no.json b/homeassistant/components/elkm1/.translations/no.json
new file mode 100644
index 00000000000..86a4e67801b
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/no.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "En ElkM1 med denne adressen er allerede konfigurert",
+ "already_configured": "En ElkM1 med dette prefikset er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "IP-adressen eller domenet eller seriell port hvis du kobler til via seriell.",
+ "password": "Passord (bare sikkert).",
+ "prefix": "Et unikt prefiks (la v\u00e6re tomt hvis du bare har en ElkM1).",
+ "protocol": "protokoll",
+ "temperature_unit": "Temperaturenheten ElkM1 bruker.",
+ "username": "Brukernavn (bare sikkert)."
+ },
+ "description": "Adressestrengen m\u00e5 v\u00e6re i formen 'adresse [: port]' for 'sikker' og 'ikke-sikker'. Eksempel: '192.168.1.1'. Porten er valgfri og er standard til 2101 for 'ikke-sikker' og 2601 for 'sikker'. For den serielle protokollen m\u00e5 adressen v\u00e6re i formen 'tty [: baud]'. Eksempel: '/ dev / ttyS1'. Baud er valgfri og er standard til 115200.",
+ "title": "Koble til Elk-M1-kontroll"
+ }
+ },
+ "title": "Elk-M1 kontroll"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/ru.json b/homeassistant/components/elkm1/.translations/ru.json
new file mode 100644
index 00000000000..11e04ad816c
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/ru.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u044d\u0442\u0438\u043c \u0430\u0434\u0440\u0435\u0441\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u044d\u0442\u0438\u043c \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043e\u043c \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\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": {
+ "address": "IP-\u0430\u0434\u0440\u0435\u0441, \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442.",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c (\u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u043e\u043f\u0446\u0438\u0438 'secure')",
+ "prefix": "\u0423\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439 \u043f\u0440\u0435\u0444\u0438\u043a\u0441 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0435\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d ElkM1).",
+ "protocol": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b",
+ "temperature_unit": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b",
+ "username": "\u041b\u043e\u0433\u0438\u043d (\u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u043e\u043f\u0446\u0438\u0438 'secure')"
+ },
+ "description": "\u0421\u0442\u0440\u043e\u043a\u0430 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043e\u043b\u0436\u043d\u0430 \u0431\u044b\u0442\u044c \u0432 \u0444\u043e\u0440\u043c\u0435 'addres[:port]' \u0434\u043b\u044f \u043e\u043f\u0446\u0438\u0439 'secure' \u0438 'non-secure'. \u041f\u0440\u0438\u043c\u0435\u0440: '192.168.1.1'. \u041f\u043e\u0440\u0442 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u043c \u0438 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e 2101 \u0434\u043b\u044f \u043e\u043f\u0446\u0438\u0438 'non-secure'\u00bb \u0438 2601 \u0434\u043b\u044f \u043e\u043f\u0446\u0438\u0438 'secure'\u00bb. \u0414\u043b\u044f \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 \u0430\u0434\u0440\u0435\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0432 \u0444\u043e\u0440\u043c\u0435 'tty[:baud]'. \u041f\u0440\u0438\u043c\u0435\u0440: '/dev/ttyS1'. Baud \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u043c \u0438 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0440\u0430\u0432\u0435\u043d 115200.",
+ "title": "Elk-M1 Control"
+ }
+ },
+ "title": "Elk-M1 Control"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/.translations/zh-Hant.json b/homeassistant/components/elkm1/.translations/zh-Hant.json
new file mode 100644
index 00000000000..d40d927ae8f
--- /dev/null
+++ b/homeassistant/components/elkm1/.translations/zh-Hant.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "address_already_configured": "\u4f7f\u7528\u6b64\u4f4d\u5740\u7684\u4e00\u7d44 ElkM1 \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "already_configured": "\u4f7f\u7528\u6b64 Prefix \u7684\u4e00\u7d44 ElkM1 \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "address": "IP \u6216\u7db2\u57df\u540d\u7a31\u3001\u5e8f\u5217\u57e0\uff08\u5047\u5982\u900f\u904e\u5e8f\u5217\u9023\u7dda\uff09\u3002",
+ "password": "\u5bc6\u78bc\uff08\u50c5\u52a0\u5bc6\uff09\u3002",
+ "prefix": "\u7368\u4e00\u7684 Prefix\uff08\u5047\u5982\u50c5\u6709\u4e00\u7d44 ElkM1 \u5247\u4fdd\u7559\u7a7a\u767d\uff09\u3002",
+ "protocol": "\u901a\u8a0a\u5354\u5b9a",
+ "temperature_unit": "ElkM1 \u4f7f\u7528\u6eab\u5ea6\u55ae\u4f4d\u3002",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31\uff08\u50c5\u52a0\u5bc6\uff09\u3002"
+ },
+ "description": "\u52a0\u5bc6\u8207\u975e\u52a0\u5bc6\u4e4b\u4f4d\u5740\u5b57\u4e32\u683c\u5f0f\u5fc5\u9808\u70ba 'address[:port]'\u3002\u4f8b\u5982\uff1a'192.168.1.1'\u3002\u901a\u8a0a\u57e0\u70ba\u9078\u9805\u8f38\u5165\uff0c\u975e\u52a0\u5bc6\u9810\u8a2d\u503c\u70ba 2101\u3001\u52a0\u5bc6\u5247\u70ba 2601\u3002\u5e8f\u5217\u901a\u8a0a\u5354\u5b9a\u3001\u4f4d\u5740\u683c\u5f0f\u5fc5\u9808\u70ba 'tty[:baud]'\u3002\u4f8b\u5982\uff1a'/dev/ttyS1'\u3002\u50b3\u8f38\u7387\u70ba\u9078\u9805\u8f38\u5165\uff0c\u9810\u8a2d\u503c\u70ba 115200\u3002",
+ "title": "\u9023\u7dda\u81f3 Elk-M1 Control"
+ }
+ },
+ "title": "Elk-M1 Control"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py
index 2acb8030cf1..183897d306e 100644
--- a/homeassistant/components/elkm1/__init__.py
+++ b/homeassistant/components/elkm1/__init__.py
@@ -1,11 +1,13 @@
"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
+import asyncio
import logging
import re
+import async_timeout
import elkm1_lib as elkm1
-from elkm1_lib.const import Max
import voluptuous as vol
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_EXCLUDE,
CONF_HOST,
@@ -15,23 +17,29 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
-DOMAIN = "elkm1"
+from .const import (
+ CONF_AREA,
+ CONF_AUTO_CONFIGURE,
+ CONF_COUNTER,
+ CONF_ENABLED,
+ CONF_KEYPAD,
+ CONF_OUTPUT,
+ CONF_PLC,
+ CONF_PREFIX,
+ CONF_SETTING,
+ CONF_TASK,
+ CONF_THERMOSTAT,
+ CONF_ZONE,
+ DOMAIN,
+ ELK_ELEMENTS,
+)
-CONF_AREA = "area"
-CONF_COUNTER = "counter"
-CONF_ENABLED = "enabled"
-CONF_KEYPAD = "keypad"
-CONF_OUTPUT = "output"
-CONF_PLC = "plc"
-CONF_SETTING = "setting"
-CONF_TASK = "task"
-CONF_THERMOSTAT = "thermostat"
-CONF_ZONE = "zone"
-CONF_PREFIX = "prefix"
+SYNC_TIMEOUT = 55
_LOGGER = logging.getLogger(__name__)
@@ -110,6 +118,7 @@ DEVICE_SCHEMA = vol.Schema(
vol.Optional(CONF_PREFIX, default=""): vol.All(cv.string, vol.Lower),
vol.Optional(CONF_USERNAME, default=""): cv.string,
vol.Optional(CONF_PASSWORD, default=""): cv.string,
+ vol.Optional(CONF_AUTO_CONFIGURE, default=False): cv.boolean,
vol.Optional(CONF_TEMPERATURE_UNIT, default="F"): cv.temperature_unit,
vol.Optional(CONF_AREA, default={}): DEVICE_SCHEMA_SUBDOMAIN,
vol.Optional(CONF_COUNTER, default={}): DEVICE_SCHEMA_SUBDOMAIN,
@@ -132,34 +141,58 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up the Elk M1 platform."""
- devices = {}
- elk_datas = {}
+ hass.data.setdefault(DOMAIN, {})
+ _create_elk_services(hass)
- configs = {
- CONF_AREA: Max.AREAS.value,
- CONF_COUNTER: Max.COUNTERS.value,
- CONF_KEYPAD: Max.KEYPADS.value,
- CONF_OUTPUT: Max.OUTPUTS.value,
- CONF_PLC: Max.LIGHTS.value,
- CONF_SETTING: Max.SETTINGS.value,
- CONF_TASK: Max.TASKS.value,
- CONF_THERMOSTAT: Max.THERMOSTATS.value,
- CONF_ZONE: Max.ZONES.value,
- }
-
- def _included(ranges, set_to, values):
- for rng in ranges:
- if not rng[0] <= rng[1] <= len(values):
- raise vol.Invalid(f"Invalid range {rng}")
- values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
+ if DOMAIN not in hass_config:
+ return True
for index, conf in enumerate(hass_config[DOMAIN]):
- _LOGGER.debug("Setting up elkm1 #%d - %s", index, conf["host"])
+ _LOGGER.debug("Importing elkm1 #%d - %s", index, conf[CONF_HOST])
- config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]}
+ # The update of the config entry is done in async_setup
+ # to ensure the entry if updated before async_setup_entry
+ # is called to avoid a situation where the user has to restart
+ # twice for the changes to take effect
+ current_config_entry = _async_find_matching_config_entry(
+ hass, conf[CONF_PREFIX]
+ )
+ if current_config_entry:
+ # If they alter the yaml config we import the changes
+ # since there currently is no practical way to do an options flow
+ # with the large amount of include/exclude/enabled options that elkm1 has.
+ hass.config_entries.async_update_entry(current_config_entry, data=conf)
+ continue
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf,
+ )
+ )
+
+ return True
+
+
+@callback
+def _async_find_matching_config_entry(hass, prefix):
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ if entry.unique_id == prefix:
+ return entry
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Elk-M1 Control from a config entry."""
+
+ conf = entry.data
+
+ _LOGGER.debug("Setting up elkm1 %s", conf["host"])
+
+ config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]}
+
+ if not conf[CONF_AUTO_CONFIGURE]:
+ # With elkm1-lib==0.7.16 and later auto configure is available
config["panel"] = {"enabled": True, "included": [True]}
-
- for item, max_ in configs.items():
+ for item, max_ in ELK_ELEMENTS.items():
config[item] = {
"enabled": conf[item][CONF_ENABLED],
"included": [not conf[item]["include"]] * max_,
@@ -171,39 +204,92 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
_LOGGER.error("Config item: %s; %s", item, err)
return False
- prefix = conf[CONF_PREFIX]
- elk = elkm1.Elk(
- {
- "url": conf[CONF_HOST],
- "userid": conf[CONF_USERNAME],
- "password": conf[CONF_PASSWORD],
- }
- )
- elk.connect()
-
- devices[prefix] = elk
- elk_datas[prefix] = {
- "elk": elk,
- "prefix": prefix,
- "config": config,
- "keypads": {},
+ elk = elkm1.Elk(
+ {
+ "url": conf[CONF_HOST],
+ "userid": conf[CONF_USERNAME],
+ "password": conf[CONF_PASSWORD],
}
+ )
+ elk.connect()
- _create_elk_services(hass, devices)
+ if not await async_wait_for_elk_to_sync(elk, SYNC_TIMEOUT):
+ _LOGGER.error(
+ "Timed out after %d seconds while trying to sync with ElkM1", SYNC_TIMEOUT,
+ )
+ elk.disconnect()
+ raise ConfigEntryNotReady
+
+ if elk.invalid_auth:
+ _LOGGER.error("Authentication failed for ElkM1")
+ return False
+
+ hass.data[DOMAIN][entry.entry_id] = {
+ "elk": elk,
+ "prefix": conf[CONF_PREFIX],
+ "auto_configure": conf[CONF_AUTO_CONFIGURE],
+ "config": config,
+ "keypads": {},
+ }
- hass.data[DOMAIN] = elk_datas
for component in SUPPORTED_DOMAINS:
hass.async_create_task(
- discovery.async_load_platform(hass, component, DOMAIN, {}, hass_config)
+ hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
-def _create_elk_services(hass, elks):
+def _included(ranges, set_to, values):
+ for rng in ranges:
+ if not rng[0] <= rng[1] <= len(values):
+ raise vol.Invalid(f"Invalid range {rng}")
+ values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
+
+
+def _find_elk_by_prefix(hass, prefix):
+ """Search all config entries for a given prefix."""
+ for entry_id in hass.data[DOMAIN]:
+ if hass.data[DOMAIN][entry_id]["prefix"] == prefix:
+ return hass.data[DOMAIN][entry_id]["elk"]
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in SUPPORTED_DOMAINS
+ ]
+ )
+ )
+
+ # disconnect cleanly
+ hass.data[DOMAIN][entry.entry_id]["elk"].disconnect()
+
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
+
+
+async def async_wait_for_elk_to_sync(elk, timeout):
+ """Wait until the elk system has finished sync."""
+ try:
+ with async_timeout.timeout(timeout):
+ await elk.sync_complete()
+ return True
+ except asyncio.TimeoutError:
+ elk.disconnect()
+
+ return False
+
+
+def _create_elk_services(hass):
def _speak_word_service(service):
prefix = service.data["prefix"]
- elk = elks.get(prefix)
+ elk = _find_elk_by_prefix(hass, prefix)
if elk is None:
_LOGGER.error("No elk m1 with prefix for speak_word: '%s'", prefix)
return
@@ -211,7 +297,7 @@ def _create_elk_services(hass, elks):
def _speak_phrase_service(service):
prefix = service.data["prefix"]
- elk = elks.get(prefix)
+ elk = _find_elk_by_prefix(hass, prefix)
if elk is None:
_LOGGER.error("No elk m1 with prefix for speak_phrase: '%s'", prefix)
return
@@ -227,12 +313,23 @@ def _create_elk_services(hass, elks):
def create_elk_entities(elk_data, elk_elements, element_type, class_, entities):
"""Create the ElkM1 devices of a particular class."""
- if elk_data["config"][element_type]["enabled"]:
- elk = elk_data["elk"]
- _LOGGER.debug("Creating elk entities for %s", elk)
- for element in elk_elements:
- if elk_data["config"][element_type]["included"][element.index]:
- entities.append(class_(element, elk, elk_data))
+ auto_configure = elk_data["auto_configure"]
+
+ if not auto_configure and not elk_data["config"][element_type]["enabled"]:
+ return
+
+ elk = elk_data["elk"]
+ _LOGGER.debug("Creating elk entities for %s", elk)
+
+ for element in elk_elements:
+ if auto_configure:
+ if not element.configured:
+ continue
+ # Only check the included list if auto configure is not
+ elif not elk_data["config"][element_type]["included"][element.index]:
+ continue
+
+ entities.append(class_(element, elk, elk_data))
return entities
@@ -297,9 +394,34 @@ class ElkEntity(Entity):
def _element_callback(self, element, changeset):
"""Handle callback from an Elk element that has changed."""
self._element_changed(element, changeset)
- self.async_schedule_update_ha_state(True)
+ self.async_write_ha_state()
async def async_added_to_hass(self):
"""Register callback for ElkM1 changes and update entity state."""
self._element.add_callback(self._element_callback)
self._element_callback(self._element, {})
+
+ @property
+ def device_info(self):
+ """Device info connecting via the ElkM1 system."""
+ return {
+ "via_device": (DOMAIN, f"{self._prefix}_system"),
+ }
+
+
+class ElkAttachedEntity(ElkEntity):
+ """An elk entity that is attached to the elk system."""
+
+ @property
+ def device_info(self):
+ """Device info for the underlying ElkM1 system."""
+ device_name = "ElkM1"
+ if self._prefix:
+ device_name += f" {self._prefix}"
+ return {
+ "name": device_name,
+ "identifiers": {(DOMAIN, f"{self._prefix}_system")},
+ "sw_version": self._elk.panel.elkm1_version,
+ "manufacturer": "ELK Products, Inc.",
+ "model": "M1",
+ }
diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py
index de1cb62234c..d7cd5cf2ad0 100644
--- a/homeassistant/components/elkm1/alarm_control_panel.py
+++ b/homeassistant/components/elkm1/alarm_control_panel.py
@@ -1,4 +1,6 @@
"""Each ElkM1 area will be created as a separate alarm_control_panel."""
+import logging
+
from elkm1_lib.const import AlarmState, ArmedStatus, ArmLevel, ArmUpState
import voluptuous as vol
@@ -22,24 +24,18 @@ from homeassistant.const import (
STATE_ALARM_PENDING,
STATE_ALARM_TRIGGERED,
)
+from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.dispatcher import (
- async_dispatcher_connect,
- async_dispatcher_send,
-)
from . import (
- DOMAIN,
SERVICE_ALARM_ARM_HOME_INSTANT,
SERVICE_ALARM_ARM_NIGHT_INSTANT,
SERVICE_ALARM_ARM_VACATION,
SERVICE_ALARM_DISPLAY_MESSAGE,
- ElkEntity,
+ ElkAttachedEntity,
create_elk_entities,
)
-
-SIGNAL_ARM_ENTITY = "elkm1_arm"
-SIGNAL_DISPLAY_MESSAGE = "elkm1_display_message"
+from .const import DOMAIN
ELK_ALARM_SERVICE_SCHEMA = vol.Schema(
{
@@ -61,69 +57,57 @@ DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema(
}
)
+_LOGGER = logging.getLogger(__name__)
-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 ElkM1 alarm platform."""
- if discovery_info is None:
- return
-
- elk_datas = hass.data[DOMAIN]
+ elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
- for elk_data in elk_datas.values():
- elk = elk_data["elk"]
- entities = create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities)
+
+ elk = elk_data["elk"]
+ areas_with_keypad = set()
+ for keypad in elk.keypads:
+ areas_with_keypad.add(keypad.area)
+
+ areas = []
+ for area in elk.areas:
+ if area.index in areas_with_keypad or elk_data["auto_configure"] is False:
+ areas.append(area)
+ create_elk_entities(elk_data, areas, "area", ElkArea, entities)
async_add_entities(entities, True)
- def _dispatch(signal, entity_ids, *args):
- for entity_id in entity_ids:
- async_dispatcher_send(hass, f"{signal}_{entity_id}", *args)
+ platform = entity_platform.current_platform.get()
- def _arm_service(service):
- entity_ids = service.data.get(ATTR_ENTITY_ID, [])
- arm_level = _arm_services().get(service.service)
- args = (arm_level, service.data.get(ATTR_CODE))
- _dispatch(SIGNAL_ARM_ENTITY, entity_ids, *args)
-
- for service in _arm_services():
- hass.services.async_register(
- DOMAIN, service, _arm_service, ELK_ALARM_SERVICE_SCHEMA
- )
-
- def _display_message_service(service):
- entity_ids = service.data.get(ATTR_ENTITY_ID, [])
- data = service.data
- args = (
- data["clear"],
- data["beep"],
- data["timeout"],
- data["line1"],
- data["line2"],
- )
- _dispatch(SIGNAL_DISPLAY_MESSAGE, entity_ids, *args)
-
- hass.services.async_register(
- DOMAIN,
+ platform.async_register_entity_service(
+ SERVICE_ALARM_ARM_VACATION,
+ ELK_ALARM_SERVICE_SCHEMA,
+ "async_alarm_arm_vacation",
+ )
+ platform.async_register_entity_service(
+ SERVICE_ALARM_ARM_HOME_INSTANT,
+ ELK_ALARM_SERVICE_SCHEMA,
+ "async_alarm_arm_home_instant",
+ )
+ platform.async_register_entity_service(
+ SERVICE_ALARM_ARM_NIGHT_INSTANT,
+ ELK_ALARM_SERVICE_SCHEMA,
+ "async_alarm_arm_night_instant",
+ )
+ platform.async_register_entity_service(
SERVICE_ALARM_DISPLAY_MESSAGE,
- _display_message_service,
DISPLAY_MESSAGE_SERVICE_SCHEMA,
+ "async_display_message",
)
-def _arm_services():
- return {
- SERVICE_ALARM_ARM_VACATION: ArmLevel.ARMED_VACATION.value,
- SERVICE_ALARM_ARM_HOME_INSTANT: ArmLevel.ARMED_STAY_INSTANT.value,
- SERVICE_ALARM_ARM_NIGHT_INSTANT: ArmLevel.ARMED_NIGHT_INSTANT.value,
- }
-
-
-class ElkArea(ElkEntity, AlarmControlPanel):
+class ElkArea(ElkAttachedEntity, AlarmControlPanel):
"""Representation of an Area / Partition within the ElkM1 alarm panel."""
def __init__(self, element, elk, elk_data):
"""Initialize Area as Alarm Control Panel."""
super().__init__(element, elk, elk_data)
- self._changed_by_entity_id = ""
+ self._changed_by_keypad = None
self._state = None
async def async_added_to_hass(self):
@@ -131,23 +115,13 @@ class ElkArea(ElkEntity, AlarmControlPanel):
await super().async_added_to_hass()
for keypad in self._elk.keypads:
keypad.add_callback(self._watch_keypad)
- async_dispatcher_connect(
- self.hass, f"{SIGNAL_ARM_ENTITY}_{self.entity_id}", self._arm_service
- )
- async_dispatcher_connect(
- self.hass,
- f"{SIGNAL_DISPLAY_MESSAGE}_{self.entity_id}",
- self._display_message,
- )
def _watch_keypad(self, keypad, changeset):
if keypad.area != self._element.index:
return
if changeset.get("last_user") is not None:
- self._changed_by_entity_id = self.hass.data[DOMAIN][self._prefix][
- "keypads"
- ].get(keypad.index, "")
- self.async_schedule_update_ha_state(True)
+ self._changed_by_keypad = keypad.name
+ self.async_write_ha_state()
@property
def code_format(self):
@@ -178,7 +152,7 @@ class ElkArea(ElkEntity, AlarmControlPanel):
attrs["arm_up_state"] = ArmUpState(elmt.arm_up_state).name.lower()
if elmt.alarm_state is not None:
attrs["alarm_state"] = AlarmState(elmt.alarm_state).name.lower()
- attrs["changed_by_entity_id"] = self._changed_by_entity_id
+ attrs["changed_by_keypad"] = self._changed_by_keypad
return attrs
def _element_changed(self, element, changeset):
@@ -225,9 +199,18 @@ class ElkArea(ElkEntity, AlarmControlPanel):
"""Send arm night command."""
self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code))
- async def _arm_service(self, arm_level, code):
- self._element.arm(arm_level, code)
+ async def async_alarm_arm_home_instant(self, code=None):
+ """Send arm stay instant command."""
+ self._element.arm(ArmLevel.ARMED_STAY_INSTANT.value, int(code))
- async def _display_message(self, clear, beep, timeout, line1, line2):
+ async def async_alarm_arm_night_instant(self, code=None):
+ """Send arm night instant command."""
+ self._element.arm(ArmLevel.ARMED_NIGHT_INSTANT.value, int(code))
+
+ async def async_alarm_arm_vacation(self, code=None):
+ """Send arm vacation command."""
+ self._element.arm(ArmLevel.ARMED_VACATION.value, int(code))
+
+ async def async_display_message(self, clear, beep, timeout, line1, line2):
"""Display a message on all keypads for the area."""
self._element.display_message(clear, beep, timeout, line1, line2)
diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py
index abc9dc0933c..3c5c70b2bd0 100644
--- a/homeassistant/components/elkm1/climate.py
+++ b/homeassistant/components/elkm1/climate.py
@@ -14,9 +14,10 @@ from homeassistant.components.climate.const import (
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
-from homeassistant.const import PRECISION_WHOLE, STATE_ON
+from homeassistant.const import PRECISION_WHOLE, STATE_ON, TEMP_CELSIUS, TEMP_FAHRENHEIT
-from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+from . import ElkEntity, create_elk_entities
+from .const import DOMAIN
SUPPORT_HVAC = [
HVAC_MODE_OFF,
@@ -27,18 +28,14 @@ SUPPORT_HVAC = [
]
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 thermostat platform."""
- if discovery_info is None:
- return
-
- elk_datas = hass.data[ELK_DOMAIN]
+ elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
- for elk_data in elk_datas.values():
- elk = elk_data["elk"]
- entities = create_elk_entities(
- elk_data, elk.thermostats, "thermostat", ElkThermostat, entities
- )
+ elk = elk_data["elk"]
+ create_elk_entities(
+ elk_data, elk.thermostats, "thermostat", ElkThermostat, entities
+ )
async_add_entities(entities, True)
@@ -58,7 +55,7 @@ class ElkThermostat(ElkEntity, ClimateDevice):
@property
def temperature_unit(self):
"""Return the temperature unit."""
- return self._temperature_unit
+ return TEMP_FAHRENHEIT if self._temperature_unit == "F" else TEMP_CELSIUS
@property
def current_temperature(self):
diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py
new file mode 100644
index 00000000000..cad3ecac42a
--- /dev/null
+++ b/homeassistant/components/elkm1/config_flow.py
@@ -0,0 +1,164 @@
+"""Config flow for Elk-M1 Control integration."""
+import logging
+from urllib.parse import urlparse
+
+import elkm1_lib as elkm1
+import voluptuous as vol
+
+from homeassistant import config_entries, exceptions
+from homeassistant.const import (
+ CONF_ADDRESS,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PROTOCOL,
+ CONF_TEMPERATURE_UNIT,
+ CONF_USERNAME,
+)
+from homeassistant.util import slugify
+
+from . import async_wait_for_elk_to_sync
+from .const import CONF_AUTO_CONFIGURE, CONF_PREFIX
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+PROTOCOL_MAP = {"secure": "elks://", "non-secure": "elk://", "serial": "serial://"}
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PROTOCOL, default="secure"): vol.In(
+ ["secure", "non-secure", "serial"]
+ ),
+ vol.Required(CONF_ADDRESS): str,
+ vol.Optional(CONF_USERNAME, default=""): str,
+ vol.Optional(CONF_PASSWORD, default=""): str,
+ vol.Optional(CONF_PREFIX, default=""): str,
+ vol.Optional(CONF_TEMPERATURE_UNIT, default="F"): vol.In(["F", "C"]),
+ }
+)
+
+VALIDATE_TIMEOUT = 35
+
+
+async def validate_input(data):
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+
+ userid = data.get(CONF_USERNAME)
+ password = data.get(CONF_PASSWORD)
+
+ prefix = data[CONF_PREFIX]
+ url = _make_url_from_data(data)
+ requires_password = url.startswith("elks://")
+
+ if requires_password and (not userid or not password):
+ raise InvalidAuth
+
+ elk = elkm1.Elk(
+ {"url": url, "userid": userid, "password": password, "element_list": ["panel"]}
+ )
+ elk.connect()
+
+ timed_out = False
+ if not await async_wait_for_elk_to_sync(elk, VALIDATE_TIMEOUT):
+ _LOGGER.error(
+ "Timed out after %d seconds while trying to sync with elkm1",
+ VALIDATE_TIMEOUT,
+ )
+ timed_out = True
+
+ elk.disconnect()
+
+ if timed_out:
+ raise CannotConnect
+ if elk.invalid_auth:
+ raise InvalidAuth
+
+ device_name = data[CONF_PREFIX] if data[CONF_PREFIX] else "ElkM1"
+ # Return info that you want to store in the config entry.
+ return {"title": device_name, CONF_HOST: url, CONF_PREFIX: slugify(prefix)}
+
+
+def _make_url_from_data(data):
+ host = data.get(CONF_HOST)
+ if host:
+ return host
+
+ protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]]
+ address = data[CONF_ADDRESS]
+ return f"{protocol}{address}"
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Elk-M1 Control."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize the elkm1 config flow."""
+ self.importing = False
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ if self._url_already_configured(_make_url_from_data(user_input)):
+ return self.async_abort(reason="address_already_configured")
+
+ try:
+ info = await validate_input(user_input)
+
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(user_input[CONF_PREFIX])
+ self._abort_if_unique_id_configured()
+
+ if self.importing:
+ return self.async_create_entry(title=info["title"], data=user_input)
+
+ return self.async_create_entry(
+ title=info["title"],
+ data={
+ CONF_HOST: info[CONF_HOST],
+ CONF_USERNAME: user_input[CONF_USERNAME],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ CONF_AUTO_CONFIGURE: True,
+ CONF_TEMPERATURE_UNIT: user_input[CONF_TEMPERATURE_UNIT],
+ CONF_PREFIX: info[CONF_PREFIX],
+ },
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ self.importing = True
+ return await self.async_step_user(user_input)
+
+ def _url_already_configured(self, url):
+ """See if we already have a elkm1 matching user input configured."""
+ existing_hosts = {
+ urlparse(entry.data[CONF_HOST]).hostname
+ for entry in self._async_current_entries()
+ }
+ return urlparse(url).hostname in existing_hosts
+
+
+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/elkm1/const.py b/homeassistant/components/elkm1/const.py
new file mode 100644
index 00000000000..bad6d7fbcf1
--- /dev/null
+++ b/homeassistant/components/elkm1/const.py
@@ -0,0 +1,31 @@
+"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
+
+from elkm1_lib.const import Max
+
+DOMAIN = "elkm1"
+
+CONF_AUTO_CONFIGURE = "auto_configure"
+CONF_AREA = "area"
+CONF_COUNTER = "counter"
+CONF_ENABLED = "enabled"
+CONF_KEYPAD = "keypad"
+CONF_OUTPUT = "output"
+CONF_PLC = "plc"
+CONF_SETTING = "setting"
+CONF_TASK = "task"
+CONF_THERMOSTAT = "thermostat"
+CONF_ZONE = "zone"
+CONF_PREFIX = "prefix"
+
+
+ELK_ELEMENTS = {
+ CONF_AREA: Max.AREAS.value,
+ CONF_COUNTER: Max.COUNTERS.value,
+ CONF_KEYPAD: Max.KEYPADS.value,
+ CONF_OUTPUT: Max.OUTPUTS.value,
+ CONF_PLC: Max.LIGHTS.value,
+ CONF_SETTING: Max.SETTINGS.value,
+ CONF_TASK: Max.TASKS.value,
+ CONF_THERMOSTAT: Max.THERMOSTATS.value,
+ CONF_ZONE: Max.ZONES.value,
+}
diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py
index 10a9ae1b931..b7cfe20dfd8 100644
--- a/homeassistant/components/elkm1/light.py
+++ b/homeassistant/components/elkm1/light.py
@@ -1,18 +1,17 @@
"""Support for control of ElkM1 lighting (X10, UPB, etc)."""
+
from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light
-from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+from . import ElkEntity, create_elk_entities
+from .const import DOMAIN
-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 Elk light platform."""
- if discovery_info is None:
- return
- elk_datas = hass.data[ELK_DOMAIN]
+ elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
- for elk_data in elk_datas.values():
- elk = elk_data["elk"]
- create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities)
+ elk = elk_data["elk"]
+ create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities)
async_add_entities(entities, True)
diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json
index c75da1ef039..17b016fcb8b 100644
--- a/homeassistant/components/elkm1/manifest.json
+++ b/homeassistant/components/elkm1/manifest.json
@@ -2,7 +2,12 @@
"domain": "elkm1",
"name": "Elk-M1 Control",
"documentation": "https://www.home-assistant.io/integrations/elkm1",
- "requirements": ["elkm1-lib==0.7.15"],
+ "requirements": [
+ "elkm1-lib==0.7.17"
+ ],
"dependencies": [],
- "codeowners": []
+ "codeowners": [
+ "@bdraco"
+ ],
+ "config_flow": true
}
diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py
index dc5ea39d154..1f894cc7681 100644
--- a/homeassistant/components/elkm1/scene.py
+++ b/homeassistant/components/elkm1/scene.py
@@ -1,22 +1,20 @@
"""Support for control of ElkM1 tasks ("macros")."""
from homeassistant.components.scene import Scene
-from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+from . import ElkAttachedEntity, create_elk_entities
+from .const import DOMAIN
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 scene platform."""
- if discovery_info is None:
- return
- elk_datas = hass.data[ELK_DOMAIN]
+ elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
- for elk_data in elk_datas.values():
- elk = elk_data["elk"]
- entities = create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities)
+ elk = elk_data["elk"]
+ create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities)
async_add_entities(entities, True)
-class ElkTask(ElkEntity, Scene):
+class ElkTask(ElkAttachedEntity, Scene):
"""Elk-M1 task as scene."""
async def async_activate(self):
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index df29e1cda7e..79987d806a1 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -7,31 +7,22 @@ from elkm1_lib.const import (
)
from elkm1_lib.util import pretty_const, username
-from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+from . import ElkAttachedEntity, create_elk_entities
+from .const import DOMAIN
+
+UNDEFINED_TEMPATURE = -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):
"""Create the Elk-M1 sensor platform."""
- if discovery_info is None:
- return
-
- elk_datas = hass.data[ELK_DOMAIN]
+ elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
- for elk_data in elk_datas.values():
- elk = elk_data["elk"]
- entities = create_elk_entities(
- elk_data, elk.counters, "counter", ElkCounter, entities
- )
- entities = create_elk_entities(
- elk_data, elk.keypads, "keypad", ElkKeypad, entities
- )
- entities = create_elk_entities(
- elk_data, [elk.panel], "panel", ElkPanel, entities
- )
- entities = create_elk_entities(
- elk_data, elk.settings, "setting", ElkSetting, entities
- )
- entities = create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
+ elk = elk_data["elk"]
+ create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities)
+ create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities)
+ create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities)
+ create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities)
+ create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
async_add_entities(entities, True)
@@ -40,7 +31,7 @@ def temperature_to_state(temperature, undefined_temperature):
return temperature if temperature > undefined_temperature else None
-class ElkSensor(ElkEntity):
+class ElkSensor(ElkAttachedEntity):
"""Base representation of Elk-M1 sensor."""
def __init__(self, element, elk, elk_data):
@@ -89,7 +80,7 @@ class ElkKeypad(ElkSensor):
"""Attributes of the sensor."""
attrs = self.initial_attrs()
attrs["area"] = self._element.area + 1
- attrs["temperature"] = self._element.temperature
+ attrs["temperature"] = self._state
attrs["last_user_time"] = self._element.last_user_time.isoformat()
attrs["last_user"] = self._element.last_user + 1
attrs["code"] = self._element.code
@@ -98,14 +89,9 @@ class ElkKeypad(ElkSensor):
return attrs
def _element_changed(self, element, changeset):
- self._state = temperature_to_state(self._element.temperature, -40)
-
- async def async_added_to_hass(self):
- """Register callback for ElkM1 changes and update entity state."""
- await super().async_added_to_hass()
- elk_datas = self.hass.data[ELK_DOMAIN]
- for elk_data in elk_datas.values():
- elk_data["keypads"][self._element.index] = self.entity_id
+ self._state = temperature_to_state(
+ self._element.temperature, UNDEFINED_TEMPATURE
+ )
class ElkPanel(ElkSensor):
@@ -214,7 +200,9 @@ class ElkZone(ElkSensor):
def _element_changed(self, element, changeset):
if self._element.definition == ZoneType.TEMPERATURE.value:
- self._state = temperature_to_state(self._element.temperature, -60)
+ self._state = temperature_to_state(
+ self._element.temperature, UNDEFINED_TEMPATURE
+ )
elif self._element.definition == ZoneType.ANALOG_ZONE.value:
self._state = self._element.voltage
else:
diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json
new file mode 100644
index 00000000000..a5246a004c3
--- /dev/null
+++ b/homeassistant/components/elkm1/strings.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "title": "Elk-M1 Control",
+ "step": {
+ "user": {
+ "title": "Connect to Elk-M1 Control",
+ "description": "The address string must be in the form 'address[:port]' for 'secure' and 'non-secure'. Example: '192.168.1.1'. The port is optional and defaults to 2101 for 'non-secure' and 2601 for 'secure'. For the serial protocol, the address must be in the form 'tty[:baud]'. Example: '/dev/ttyS1'. The baud is optional and defaults to 115200.",
+ "data": {
+ "protocol": "Protocol",
+ "address": "The IP address or domain or serial port if connecting via serial.",
+ "username": "Username (secure only).",
+ "password": "Password (secure only).",
+ "prefix": "A unique prefix (leave blank if you only have one ElkM1).",
+ "temperature_unit": "The temperature unit ElkM1 uses."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "An ElkM1 with this prefix is already configured",
+ "address_already_configured": "An ElkM1 with this address is already configured"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py
index e6dd82dc0ac..af32e81bc4c 100644
--- a/homeassistant/components/elkm1/switch.py
+++ b/homeassistant/components/elkm1/switch.py
@@ -1,24 +1,20 @@
"""Support for control of ElkM1 outputs (relays)."""
from homeassistant.components.switch import SwitchDevice
-from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+from . import ElkAttachedEntity, create_elk_entities
+from .const import DOMAIN
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 switch platform."""
- if discovery_info is None:
- return
- elk_datas = hass.data[ELK_DOMAIN]
+ elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
- for elk_data in elk_datas.values():
- elk = elk_data["elk"]
- entities = create_elk_entities(
- elk_data, elk.outputs, "output", ElkOutput, entities
- )
+ elk = elk_data["elk"]
+ create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities)
async_add_entities(entities, True)
-class ElkOutput(ElkEntity, SwitchDevice):
+class ElkOutput(ElkAttachedEntity, SwitchDevice):
"""Elk output as switch."""
@property
diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py
index 56d68cee6b5..e063fc49f2f 100644
--- a/homeassistant/components/emby/media_player.py
+++ b/homeassistant/components/emby/media_player.py
@@ -305,7 +305,7 @@ class EmbyDevice(MediaPlayerDevice):
"""Flag media player features that are supported."""
if self.supports_remote_control:
return SUPPORT_EMBY
- return None
+ return 0
async def async_media_play(self):
"""Play media."""
diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py
index 0a358c6e894..6b234a9df7b 100644
--- a/homeassistant/components/emulated_hue/__init__.py
+++ b/homeassistant/components/emulated_hue/__init__.py
@@ -9,7 +9,6 @@ from homeassistant.components.http import real_ip
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.deprecation import get_deprecated
from homeassistant.util.json import load_json, save_json
from .hue_api import (
@@ -91,9 +90,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-ATTR_EMULATED_HUE = "emulated_hue"
ATTR_EMULATED_HUE_NAME = "emulated_hue_name"
-ATTR_EMULATED_HUE_HIDDEN = "emulated_hue_hidden"
async def async_setup(hass, yaml_config):
@@ -220,7 +217,9 @@ class Config:
# Get domains that are exposed by default when expose_by_default is
# True
- self.exposed_domains = conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
+ self.exposed_domains = set(
+ conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
+ )
# Calculated effective advertised IP and port for network isolation
self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr
@@ -229,6 +228,12 @@ class Config:
self.entities = conf.get(CONF_ENTITIES, {})
+ self._entities_with_hidden_attr_in_config = dict()
+ for entity_id in self.entities:
+ hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN, None)
+ if hidden_value is not None:
+ self._entities_with_hidden_attr_in_config[entity_id] = hidden_value
+
def entity_id_to_number(self, entity_id):
"""Get a unique number for the entity id."""
if self.type == TYPE_ALEXA:
@@ -280,35 +285,18 @@ class Config:
# Ignore entities that are views
return False
- domain = entity.domain.lower()
- explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
- explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None)
-
- if (
- entity.entity_id in self.entities
- and CONF_ENTITY_HIDDEN in self.entities[entity.entity_id]
- ):
- explicit_hidden = self.entities[entity.entity_id][CONF_ENTITY_HIDDEN]
-
- if explicit_expose is True or explicit_hidden is False:
- expose = True
- elif explicit_expose is False or explicit_hidden is True:
- expose = False
- else:
- expose = None
- get_deprecated(
- entity.attributes, ATTR_EMULATED_HUE_HIDDEN, ATTR_EMULATED_HUE, None
- )
- domain_exposed_by_default = (
- self.expose_by_default and domain in self.exposed_domains
- )
+ if entity.entity_id in self._entities_with_hidden_attr_in_config:
+ return not self._entities_with_hidden_attr_in_config[entity.entity_id]
+ if not self.expose_by_default:
+ return False
# Expose an entity if the entity's domain is exposed by default and
# the configuration doesn't explicitly exclude it from being
# exposed, or if the entity is explicitly exposed
- is_default_exposed = domain_exposed_by_default and expose is not False
+ if entity.domain in self.exposed_domains:
+ return True
- return is_default_exposed or expose
+ return False
def _load_json(filename):
diff --git a/homeassistant/components/emulated_roku/.translations/no.json b/homeassistant/components/emulated_roku/.translations/no.json
index b41da3ccde3..a0b8efcacd6 100644
--- a/homeassistant/components/emulated_roku/.translations/no.json
+++ b/homeassistant/components/emulated_roku/.translations/no.json
@@ -16,6 +16,6 @@
"title": "Definer serverkonfigurasjon"
}
},
- "title": "EmulatedRoku"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json
index f7dac2a9d56..b4fa669fbff 100644
--- a/homeassistant/components/esphome/.translations/no.json
+++ b/homeassistant/components/esphome/.translations/no.json
@@ -24,12 +24,12 @@
"user": {
"data": {
"host": "Vert",
- "port": "Port"
+ "port": ""
},
"description": "Vennligst skriv inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node.",
- "title": "ESPHome"
+ "title": ""
}
},
- "title": "ESPHome"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/zh-Hant.json b/homeassistant/components/esphome/.translations/zh-Hant.json
index 0386fd8c468..bc229d190a7 100644
--- a/homeassistant/components/esphome/.translations/zh-Hant.json
+++ b/homeassistant/components/esphome/.translations/zh-Hant.json
@@ -19,7 +19,7 @@
},
"discovery_confirm": {
"description": "\u662f\u5426\u8981\u5c07 ESPHome \u7bc0\u9ede `{name}` \u65b0\u589e\u81f3 Home Assistant\uff1f",
- "title": "\u767c\u73fe\u5230 ESPHome \u7bc0\u9ede"
+ "title": "\u81ea\u52d5\u63a2\u7d22\u5230 ESPHome \u7bc0\u9ede"
},
"user": {
"data": {
diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py
index 8720f67f396..fb031359693 100644
--- a/homeassistant/components/flexit/climate.py
+++ b/homeassistant/components/flexit/climate.py
@@ -11,11 +11,7 @@ from homeassistant.components.climate.const import (
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
-from homeassistant.components.modbus import (
- CONF_HUB,
- DEFAULT_HUB,
- DOMAIN as MODBUS_DOMAIN,
-)
+from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_NAME,
diff --git a/homeassistant/components/flunearyou/.translations/ca.json b/homeassistant/components/flunearyou/.translations/ca.json
new file mode 100644
index 00000000000..dddf7dc2c88
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/ca.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Les coordenades ja estan registrades"
+ },
+ "error": {
+ "general_error": "S'ha produ\u00eft un error desconegut."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitud",
+ "longitude": "Longitud"
+ },
+ "description": "Monitoritza informes basats en usuari i CDC per a parells de coordenades.",
+ "title": "Configuraci\u00f3 Flu Near You"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/de.json b/homeassistant/components/flunearyou/.translations/de.json
new file mode 100644
index 00000000000..0ac83023896
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/de.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Diese Koordinaten sind bereits registriert."
+ },
+ "error": {
+ "general_error": "Es gab einen unbekannten Fehler."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Breitengrad",
+ "longitude": "L\u00e4ngengrad"
+ },
+ "title": "Konfigurieren Sie die Grippe in Ihrer N\u00e4he"
+ }
+ },
+ "title": "Grippe in Ihrer N\u00e4he"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/en.json b/homeassistant/components/flunearyou/.translations/en.json
new file mode 100644
index 00000000000..ca868b8ebd9
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/en.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "These coordinates are already registered."
+ },
+ "error": {
+ "general_error": "There was an unknown error."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "description": "Monitor user-based and CDC repots for a pair of coordinates.",
+ "title": "Configure Flu Near You"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/es.json b/homeassistant/components/flunearyou/.translations/es.json
new file mode 100644
index 00000000000..df104c5405e
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/es.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Estas coordenadas ya est\u00e1n registradas."
+ },
+ "error": {
+ "general_error": "Se ha producido un error desconocido."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitud",
+ "longitude": "Longitud"
+ },
+ "description": "Monitorizar reportes de usuarios y del CDC para un par de coordenadas",
+ "title": "Configurar Flu Near You"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/ko.json b/homeassistant/components/flunearyou/.translations/ko.json
new file mode 100644
index 00000000000..c155a7f6111
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/ko.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "general_error": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\uc704\ub3c4",
+ "longitude": "\uacbd\ub3c4"
+ },
+ "description": "\uc0ac\uc6a9\uc790 \uae30\ubc18 \ub370\uc774\ud130 \ubc0f CDC \ubcf4\uace0\uc11c\uc5d0\uc11c \uc88c\ud45c\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4.",
+ "title": "Flu Near You \uad6c\uc131"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/lb.json b/homeassistant/components/flunearyou/.translations/lb.json
new file mode 100644
index 00000000000..03c8d0bce09
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "D\u00ebs Koordinate si scho registr\u00e9iert"
+ },
+ "error": {
+ "general_error": "Onbekannten Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Breedegrad",
+ "longitude": "L\u00e4ngegrad"
+ },
+ "description": "Iwwerwach Benotzer-bas\u00e9iert an CDC Berichter fir Koordinaten.",
+ "title": "Flu Near You konfigur\u00e9ieren"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/ru.json b/homeassistant/components/flunearyou/.translations/ru.json
new file mode 100644
index 00000000000..8e8b050ba7a
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/ru.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b."
+ },
+ "error": {
+ "general_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430"
+ },
+ "description": "\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445 \u0438 CDC \u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0434\u043b\u044f \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f.",
+ "title": "Flu Near You"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/flunearyou/.translations/zh-Hant.json b/homeassistant/components/flunearyou/.translations/zh-Hant.json
new file mode 100644
index 00000000000..50f31707a61
--- /dev/null
+++ b/homeassistant/components/flunearyou/.translations/zh-Hant.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64\u4e9b\u5ea7\u6a19\u5df2\u8a3b\u518a\u3002"
+ },
+ "error": {
+ "general_error": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u7def\u5ea6",
+ "longitude": "\u7d93\u5ea6"
+ },
+ "description": "\u76e3\u6e2c\u4f7f\u7528\u8005\u8207 CDC \u56de\u5831\u5ea7\u6a19\u3002",
+ "title": "\u8a2d\u5b9a Flu Near You"
+ }
+ },
+ "title": "Flu Near You"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json
index dd0fdae9619..4073f1bbb36 100644
--- a/homeassistant/components/fortios/manifest.json
+++ b/homeassistant/components/fortios/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "fortios",
- "name": "Home Assistant Device Tracker to support FortiOS",
+ "name": "FortiOS",
"documentation": "https://www.home-assistant.io/integrations/fortios/",
"requirements": ["fortiosapi==0.10.8"],
"dependencies": [],
diff --git a/homeassistant/components/freebox/.translations/ca.json b/homeassistant/components/freebox/.translations/ca.json
new file mode 100644
index 00000000000..0abfc0ef52b
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/ca.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat"
+ },
+ "error": {
+ "connection_failed": "No s'ha pogut connectar, torna-ho a provar",
+ "register_failed": "No s'ha pogut registrar, torna-ho a provar",
+ "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard"
+ },
+ "step": {
+ "link": {
+ "description": "Prem \"Envia\", a continuaci\u00f3, toca la fletxa dreta del router per registrar Freebox amb Home Assistant.\n\n",
+ "title": "Enlla\u00e7 amb router Freebox"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "port": "Port"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/de.json b/homeassistant/components/freebox/.translations/de.json
new file mode 100644
index 00000000000..72caccf49dc
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/de.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Host bereits konfiguriert"
+ },
+ "error": {
+ "connection_failed": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "register_failed": "Registrieren fehlgeschlagen, bitte versuche es erneut",
+ "unknown": "Unbekannter Fehler: Bitte versuchen Sie es sp\u00e4ter erneut"
+ },
+ "step": {
+ "link": {
+ "description": "Klicken Sie auf \"Senden\" und ber\u00fchren Sie dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router]\n (/static/images/config_freebox.png)",
+ "title": "Link Freebox Router"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/en.json b/homeassistant/components/freebox/.translations/en.json
new file mode 100644
index 00000000000..75d925e2f7a
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/en.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Host already configured"
+ },
+ "error": {
+ "connection_failed": "Failed to connect, please try again",
+ "register_failed": "Failed to register, please try again",
+ "unknown": "Unknown error: please retry later"
+ },
+ "step": {
+ "link": {
+ "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n",
+ "title": "Link Freebox router"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/es.json b/homeassistant/components/freebox/.translations/es.json
new file mode 100644
index 00000000000..2c073e1d044
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/es.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El host ya est\u00e1 configurado."
+ },
+ "error": {
+ "connection_failed": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.",
+ "register_failed": "No se pudo registrar, int\u00e9ntalo de nuevo",
+ "unknown": "Error desconocido: por favor, int\u00e9ntalo de nuevo m\u00e1s"
+ },
+ "step": {
+ "link": {
+ "description": "Pulsa \"Enviar\", despu\u00e9s pulsa en la flecha derecha en el router para registrar Freebox con Home Assistant\n\n",
+ "title": "Enlazar router Freebox"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Puerto"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/fr.json b/homeassistant/components/freebox/.translations/fr.json
new file mode 100644
index 00000000000..6a91abc7076
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/fr.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "connection_failed": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "register_failed": "\u00c9chec de l'inscription, veuillez r\u00e9essayer",
+ "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard"
+ },
+ "step": {
+ "link": {
+ "description": "Cliquez sur \u00abSoumettre\u00bb, puis appuyez sur la fl\u00e8che droite du routeur pour enregistrer Freebox avec Home Assistant. \n\n ! [Emplacement du bouton sur le routeur](/static/images/config_freebox.png)"
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te",
+ "port": "Port"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/it.json b/homeassistant/components/freebox/.translations/it.json
new file mode 100644
index 00000000000..6624167722b
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/it.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Host gi\u00e0 configurato"
+ },
+ "error": {
+ "connection_failed": "Impossibile connettersi, si prega di riprovare",
+ "register_failed": "Errore in fase di registrazione, si prega di riprovare",
+ "unknown": "Errore sconosciuto: riprovare pi\u00f9 tardi"
+ },
+ "step": {
+ "link": {
+ "description": "Fare clic su \"Invia\", quindi toccare la freccia destra sul router per registrare Freebox con Home Assistant.\n\n",
+ "title": "Collega il router Freebox"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Porta"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/ko.json b/homeassistant/components/freebox/.translations/ko.json
new file mode 100644
index 00000000000..56c91ad824c
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/ko.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "connection_failed": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694"
+ },
+ "step": {
+ "link": {
+ "description": "\"Submit\" \uc744 \ud074\ub9ad\ud55c \ub2e4\uc74c \ub77c\uc6b0\ud130\uc758 \uc624\ub978\ucabd \ud654\uc0b4\ud45c\ub97c \ud130\uce58\ud558\uc5ec Home Assistant \uc5d0 Freebox \ub97c \ub4f1\ub85d\ud574\uc8fc\uc138\uc694.\n\n",
+ "title": "Freebox \ub77c\uc6b0\ud130 \uc5f0\uacb0"
+ },
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8",
+ "port": "\ud3ec\ud2b8"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/lb.json b/homeassistant/components/freebox/.translations/lb.json
new file mode 100644
index 00000000000..21567b8f096
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/lb.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "connection_failed": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9ier w.e.g. nach emol",
+ "unknown": "Onbekannte Feeler: prob\u00e9iertsp\u00e9ider nach emol"
+ },
+ "step": {
+ "link": {
+ "description": "Dr\u00e9ck \"Ofsch\u00e9cken\", dann dr\u00e9ck de rietse Feil um Router fir d'Freebox mam Home Assistant ze registr\u00e9ieren.\n\n",
+ "title": "Freebox Router verbannen"
+ },
+ "user": {
+ "data": {
+ "host": "Apparat",
+ "port": "Port"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/no.json b/homeassistant/components/freebox/.translations/no.json
new file mode 100644
index 00000000000..cf8b3e55402
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/no.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Verten er allerede konfigurert"
+ },
+ "error": {
+ "connection_failed": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "register_failed": "Registrering feilet, vennligst pr\u00f8v igjen",
+ "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere"
+ },
+ "step": {
+ "link": {
+ "description": "Klikk p\u00e5 \"Submit\", deretter trykker du p\u00e5 den h\u00f8yre pilen p\u00e5 ruteren for \u00e5 registrere Freebox med Home Assistent.\n\n",
+ "title": "Link Freebox-ruter"
+ },
+ "user": {
+ "data": {
+ "host": "Vert",
+ "port": ""
+ },
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/pl.json b/homeassistant/components/freebox/.translations/pl.json
new file mode 100644
index 00000000000..40fe7f097f1
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/pl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Host jest ju\u017c skonfigurowany."
+ },
+ "error": {
+ "connection_failed": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.",
+ "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107. Spr\u00f3buj ponownie.",
+ "unknown": "Nieznany b\u0142\u0105d, spr\u00f3buj ponownie p\u00f3\u017aniej."
+ },
+ "step": {
+ "link": {
+ "description": "Kliknij \"Prze\u015blij\", a nast\u0119pnie naci\u015bnij przycisk strza\u0142ki w prawo na routerze, aby zarejestrowa\u0107 Freebox w Home Assistan'cie. \n\n ![Lokalizacja przycisku na routerze] (/static/images/config_freebox.png)",
+ "title": "Po\u0142\u0105cz z routerem Freebox"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/ru.json b/homeassistant/components/freebox/.translations/ru.json
new file mode 100644
index 00000000000..4d4fdcc650d
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/ru.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\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."
+ },
+ "error": {
+ "connection_failed": "\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.",
+ "register_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.",
+ "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": {
+ "link": {
+ "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c', \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u0441\u043e \u0441\u0442\u0440\u0435\u043b\u043a\u043e\u0439 \u043d\u0430 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c Freebox \u0432 Home Assistant. \n\n ",
+ "title": "Freebox"
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "port": "\u041f\u043e\u0440\u0442"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/sl.json b/homeassistant/components/freebox/.translations/sl.json
new file mode 100644
index 00000000000..e9865b9bf1e
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/sl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Gostitelj je \u017ee konfiguriran"
+ },
+ "error": {
+ "connection_failed": "Povezava ni uspela, poskusite znova",
+ "register_failed": "Registracija ni uspela, poskusite znova",
+ "unknown": "Neznana napaka: poskusite pozneje"
+ },
+ "step": {
+ "link": {
+ "description": "Kliknite \u00bbPo\u0161lji\u00ab, nato pa se dotaknite desne pu\u0161\u010dice na usmerjevalniku, \u010de \u017eelite registrirati Freebox pri programu Home Assistant. \n\n ! [Lokacija gumba na usmerjevalniku] (/static/images/config_freebox.png)",
+ "title": "Povezava usmerjevalnika Freebox"
+ },
+ "user": {
+ "data": {
+ "host": "Gostitelj",
+ "port": "Vrata"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/.translations/zh-Hant.json b/homeassistant/components/freebox/.translations/zh-Hant.json
new file mode 100644
index 00000000000..38da7b96e03
--- /dev/null
+++ b/homeassistant/components/freebox/.translations/zh-Hant.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "connection_failed": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66",
+ "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66"
+ },
+ "step": {
+ "link": {
+ "description": "\u6309\u4e0b\u50b3\u9001 \"Submit\"\u3001\u63a5\u8457\u6309\u4e0b\u8def\u7531\u5668\u4e0a\u7684\u53f3\u7bad\u982d\u4ee5\u5c07 Freebox \u8a3b\u518a\u81f3 Home Assistant\u3002\n\n",
+ "title": "\u9023\u7d50 Freebox \u8def\u7531\u5668"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "port": "\u901a\u8a0a\u57e0"
+ },
+ "title": "Freebox"
+ }
+ },
+ "title": "Freebox"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py
index 58426334dea..9e303c75e7a 100644
--- a/homeassistant/components/freebox/__init__.py
+++ b/homeassistant/components/freebox/__init__.py
@@ -1,29 +1,26 @@
"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
+import asyncio
import logging
-import socket
-from aiofreepybox import Freepybox
-from aiofreepybox.exceptions import HttpRequestError
import voluptuous as vol
from homeassistant.components.discovery import SERVICE_FREEBOX
+from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.helpers import config_validation as cv, discovery
-from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import DOMAIN, PLATFORMS
+from .router import FreeboxRouter
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "freebox"
-DATA_FREEBOX = DOMAIN
-
-FREEBOX_CONFIG_FILE = "freebox.conf"
+FREEBOX_SCHEMA = vol.Schema(
+ {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port}
+)
CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port}
- )
- },
+ {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [FREEBOX_SCHEMA]))},
extra=vol.ALLOW_EXTRA,
)
@@ -37,54 +34,70 @@ async def async_setup(hass, config):
host = discovery_info.get("properties", {}).get("api_domain")
port = discovery_info.get("properties", {}).get("https_port")
_LOGGER.info("Discovered Freebox server: %s:%s", host, port)
- await async_setup_freebox(hass, config, host, port)
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_DISCOVERY},
+ data={CONF_HOST: host, CONF_PORT: port},
+ )
+ )
discovery.async_listen(hass, SERVICE_FREEBOX, discovery_dispatch)
- if conf is not None:
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
- await async_setup_freebox(hass, config, host, port)
+ if conf is None:
+ return True
+
+ for freebox_conf in conf:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=freebox_conf,
+ )
+ )
return True
-async def async_setup_freebox(hass, config, host, port):
- """Start up the Freebox component platforms."""
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Set up Freebox component."""
+ router = FreeboxRouter(hass, entry)
+ await router.setup()
- app_desc = {
- "app_id": "hass",
- "app_name": "Home Assistant",
- "app_version": "0.65",
- "device_name": socket.gethostname(),
- }
+ hass.data.setdefault(DOMAIN, {})
+ hass.data[DOMAIN][entry.unique_id] = router
- token_file = hass.config.path(FREEBOX_CONFIG_FILE)
- api_version = "v6"
-
- fbx = Freepybox(app_desc=app_desc, token_file=token_file, api_version=api_version)
-
- try:
- await fbx.open(host, port)
- except HttpRequestError:
- _LOGGER.exception("Failed to connect to Freebox")
- else:
- hass.data[DATA_FREEBOX] = fbx
-
- async def async_freebox_reboot(call):
- """Handle reboot service call."""
- await fbx.system.reboot()
-
- hass.services.async_register(DOMAIN, "reboot", async_freebox_reboot)
-
- hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config))
+ for platform in PLATFORMS:
hass.async_create_task(
- async_load_platform(hass, "device_tracker", DOMAIN, {}, config)
+ hass.config_entries.async_forward_entry_setup(entry, platform)
)
- hass.async_create_task(async_load_platform(hass, "switch", DOMAIN, {}, config))
- async def close_fbx(event):
- """Close Freebox connection on HA Stop."""
- await fbx.close()
+ # Services
+ async def async_reboot(call):
+ """Handle reboot service call."""
+ await router.reboot()
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fbx)
+ hass.services.async_register(DOMAIN, "reboot", async_reboot)
+
+ async def async_close_connection(event):
+ """Close Freebox connection on HA Stop."""
+ await router.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, platform)
+ for platform in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ router = hass.data[DOMAIN].pop(entry.unique_id)
+ await router.close()
+
+ return unload_ok
diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py
new file mode 100644
index 00000000000..b2d1a0ab771
--- /dev/null
+++ b/homeassistant/components/freebox/config_flow.py
@@ -0,0 +1,110 @@
+"""Config flow to configure the Freebox integration."""
+import logging
+
+from aiofreepybox.exceptions import AuthorizationError, HttpRequestError
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_HOST, CONF_PORT
+
+from .const import DOMAIN # pylint: disable=unused-import
+from .router import get_api
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Initialize Freebox config flow."""
+ self._host = None
+ self._port = None
+
+ def _show_setup_form(self, user_input=None, errors=None):
+ """Show the setup form to the user."""
+
+ if user_input is None:
+ user_input = {}
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
+ vol.Required(CONF_PORT, default=user_input.get(CONF_PORT, "")): int,
+ }
+ ),
+ 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)
+
+ self._host = user_input[CONF_HOST]
+ self._port = user_input[CONF_PORT]
+
+ # Check if already configured
+ await self.async_set_unique_id(self._host)
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_link()
+
+ async def async_step_link(self, user_input=None):
+ """Attempt to link with the Freebox router.
+
+ Given a configured host, will ask the user to press the button
+ to connect to the router.
+ """
+ if user_input is None:
+ return self.async_show_form(step_id="link")
+
+ errors = {}
+
+ fbx = await get_api(self.hass, self._host)
+ try:
+ # Open connection and check authentication
+ await fbx.open(self._host, self._port)
+
+ # Check permissions
+ await fbx.system.get_config()
+ await fbx.lan.get_hosts_list()
+ await self.hass.async_block_till_done()
+
+ # Close connection
+ await fbx.close()
+
+ return self.async_create_entry(
+ title=self._host, data={CONF_HOST: self._host, CONF_PORT: self._port},
+ )
+
+ except AuthorizationError as error:
+ _LOGGER.error(error)
+ errors["base"] = "register_failed"
+
+ except HttpRequestError:
+ _LOGGER.error("Error connecting to the Freebox router at %s", self._host)
+ errors["base"] = "connection_failed"
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Unknown error connecting with Freebox router at %s", self._host
+ )
+ errors["base"] = "unknown"
+
+ return self.async_show_form(step_id="link", errors=errors)
+
+ async def async_step_import(self, user_input=None):
+ """Import a config entry."""
+ return await self.async_step_user(user_input)
+
+ async def async_step_discovery(self, user_input=None):
+ """Initialize step from discovery."""
+ return await self.async_step_user(user_input)
diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py
new file mode 100644
index 00000000000..0612e4e76f1
--- /dev/null
+++ b/homeassistant/components/freebox/const.py
@@ -0,0 +1,75 @@
+"""Freebox component constants."""
+import socket
+
+from homeassistant.const import (
+ DATA_RATE_KILOBYTES_PER_SECOND,
+ DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS,
+)
+
+DOMAIN = "freebox"
+
+APP_DESC = {
+ "app_id": "hass",
+ "app_name": "Home Assistant",
+ "app_version": "0.106",
+ "device_name": socket.gethostname(),
+}
+API_VERSION = "v6"
+
+PLATFORMS = ["device_tracker", "sensor", "switch"]
+
+DEFAULT_DEVICE_NAME = "Unknown device"
+
+# to store the cookie
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+
+# Sensor
+SENSOR_NAME = "name"
+SENSOR_UNIT = "unit"
+SENSOR_ICON = "icon"
+SENSOR_DEVICE_CLASS = "device_class"
+
+CONNECTION_SENSORS = {
+ "rate_down": {
+ SENSOR_NAME: "Freebox download speed",
+ SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND,
+ SENSOR_ICON: "mdi:download-network",
+ SENSOR_DEVICE_CLASS: None,
+ },
+ "rate_up": {
+ SENSOR_NAME: "Freebox upload speed",
+ SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND,
+ SENSOR_ICON: "mdi:upload-network",
+ SENSOR_DEVICE_CLASS: None,
+ },
+}
+
+TEMPERATURE_SENSOR_TEMPLATE = {
+ SENSOR_NAME: None,
+ SENSOR_UNIT: TEMP_CELSIUS,
+ SENSOR_ICON: "mdi:thermometer",
+ SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
+}
+
+# Icons
+DEVICE_ICONS = {
+ "freebox_delta": "mdi:television-guide",
+ "freebox_hd": "mdi:television-guide",
+ "freebox_mini": "mdi:television-guide",
+ "freebox_player": "mdi:television-guide",
+ "ip_camera": "mdi:cctv",
+ "ip_phone": "mdi:phone-voip",
+ "laptop": "mdi:laptop",
+ "multimedia_device": "mdi:play-network",
+ "nas": "mdi:nas",
+ "networking_device": "mdi:network",
+ "printer": "mdi:printer",
+ "router": "mdi:router-wireless",
+ "smartphone": "mdi:cellphone",
+ "tablet": "mdi:tablet",
+ "television": "mdi:television",
+ "vg_console": "mdi:gamepad-variant",
+ "workstation": "mdi:desktop-tower-monitor",
+}
diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py
index 63cf869990d..ea9919f5742 100644
--- a/homeassistant/components/freebox/device_tracker.py
+++ b/homeassistant/components/freebox/device_tracker.py
@@ -1,65 +1,148 @@
"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
-from collections import namedtuple
+from datetime import datetime
import logging
+from typing import Dict
-from homeassistant.components.device_tracker import DeviceScanner
+from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER
+from homeassistant.components.device_tracker.config_entry import ScannerEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import HomeAssistantType
-from . import DATA_FREEBOX
+from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN
+from .router import FreeboxRouter
_LOGGER = logging.getLogger(__name__)
-async def async_get_scanner(hass, config):
- """Validate the configuration and return a Freebox scanner."""
- scanner = FreeboxDeviceScanner(hass.data[DATA_FREEBOX])
- await scanner.async_connect()
- return scanner if scanner.success_init else None
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
+ """Set up device tracker for Freebox component."""
+ router = hass.data[DOMAIN][entry.unique_id]
+ tracked = set()
+ @callback
+ def update_router():
+ """Update the values of the router."""
+ add_entities(router, async_add_entities, tracked)
-Device = namedtuple("Device", ["id", "name", "ip"])
-
-
-def _build_device(device_dict):
- return Device(
- device_dict["l2ident"]["id"],
- device_dict["primary_name"],
- device_dict["l3connectivities"][0]["addr"],
+ router.listeners.append(
+ async_dispatcher_connect(hass, router.signal_device_new, update_router)
)
+ update_router()
-class FreeboxDeviceScanner(DeviceScanner):
- """Queries the Freebox device."""
- def __init__(self, fbx):
- """Initialize the scanner."""
- self.last_results = {}
- self.success_init = False
- self.connection = fbx
+@callback
+def add_entities(router, async_add_entities, tracked):
+ """Add new tracker entities from the router."""
+ new_tracked = []
- async def async_connect(self):
- """Initialize connection to the router."""
- # Test the router is accessible.
- data = await self.connection.lan.get_hosts_list()
- self.success_init = data is not None
+ for mac, device in router.devices.items():
+ if mac in tracked:
+ continue
- async def async_scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- await self.async_update_info()
- return [device.id for device in self.last_results]
+ new_tracked.append(FreeboxDevice(router, device))
+ tracked.add(mac)
- async def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- name = next(
- (result.name for result in self.last_results if result.id == device), None
+ if new_tracked:
+ async_add_entities(new_tracked, True)
+
+
+class FreeboxDevice(ScannerEntity):
+ """Representation of a Freebox device."""
+
+ def __init__(self, router: FreeboxRouter, device: Dict[str, any]) -> None:
+ """Initialize a Freebox device."""
+ self._router = router
+ self._name = device["primary_name"].strip() or DEFAULT_DEVICE_NAME
+ self._mac = device["l2ident"]["id"]
+ self._manufacturer = device["vendor_name"]
+ self._icon = icon_for_freebox_device(device)
+ self._active = False
+ self._attrs = {}
+
+ self._unsub_dispatcher = None
+
+ def update(self) -> None:
+ """Update the Freebox device."""
+ device = self._router.devices[self._mac]
+ self._active = device["active"]
+ if device.get("attrs") is None:
+ # device
+ self._attrs = {
+ "last_time_reachable": datetime.fromtimestamp(
+ device["last_time_reachable"]
+ ),
+ "last_time_activity": datetime.fromtimestamp(device["last_activity"]),
+ }
+ else:
+ # router
+ self._attrs = device["attrs"]
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._mac
+
+ @property
+ def name(self) -> str:
+ """Return the name."""
+ return self._name
+
+ @property
+ def is_connected(self):
+ """Return true if the device is connected to the network."""
+ return self._active
+
+ @property
+ def source_type(self) -> str:
+ """Return the source type."""
+ return SOURCE_TYPE_ROUTER
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def device_state_attributes(self) -> Dict[str, any]:
+ """Return the attributes."""
+ return self._attrs
+
+ @property
+ def device_info(self) -> Dict[str, any]:
+ """Return the device information."""
+ return {
+ "connections": {(CONNECTION_NETWORK_MAC, self._mac)},
+ "identifiers": {(DOMAIN, self.unique_id)},
+ "name": self.name,
+ "manufacturer": self._manufacturer,
+ }
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ async def async_on_demand_update(self):
+ """Update state."""
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Register state update callback."""
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, self._router.signal_device_update, self.async_on_demand_update
)
- return name
- async def async_update_info(self):
- """Ensure the information from the Freebox router is up to date."""
- _LOGGER.debug("Checking Devices")
+ async def async_will_remove_from_hass(self):
+ """Clean up after entity before removal."""
+ self._unsub_dispatcher()
- hosts = await self.connection.lan.get_hosts_list()
- last_results = [_build_device(device) for device in hosts if device["active"]]
-
- self.last_results = last_results
+def icon_for_freebox_device(device) -> str:
+ """Return a host icon from his type."""
+ return DEVICE_ICONS.get(device["host_type"], "mdi:help-network")
diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json
index 7a66490c90d..1bfb4924a78 100644
--- a/homeassistant/components/freebox/manifest.json
+++ b/homeassistant/components/freebox/manifest.json
@@ -1,9 +1,10 @@
{
"domain": "freebox",
"name": "Freebox",
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/freebox",
"requirements": ["aiofreepybox==0.0.8"],
"dependencies": [],
"after_dependencies": ["discovery"],
- "codeowners": ["@snoof85"]
+ "codeowners": ["@snoof85", "@Quentame"]
}
diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py
new file mode 100644
index 00000000000..7b4784c6ca4
--- /dev/null
+++ b/homeassistant/components/freebox/router.py
@@ -0,0 +1,193 @@
+"""Represent the Freebox router and its devices and sensors."""
+from datetime import datetime, timedelta
+import logging
+from pathlib import Path
+from typing import Dict, Optional
+
+from aiofreepybox import Freepybox
+from aiofreepybox.api.wifi import Wifi
+from aiofreepybox.exceptions import HttpRequestError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import slugify
+
+from .const import (
+ API_VERSION,
+ APP_DESC,
+ CONNECTION_SENSORS,
+ DOMAIN,
+ STORAGE_KEY,
+ STORAGE_VERSION,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+
+class FreeboxRouter:
+ """Representation of a Freebox router."""
+
+ def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None:
+ """Initialize a Freebox router."""
+ self.hass = hass
+ self._entry = entry
+ self._host = entry.data[CONF_HOST]
+ self._port = entry.data[CONF_PORT]
+
+ self._api: Freepybox = None
+ self._name = None
+ self.mac = None
+ self._sw_v = None
+ self._attrs = {}
+
+ self.devices: Dict[str, any] = {}
+ self.sensors_temperature: Dict[str, int] = {}
+ self.sensors_connection: Dict[str, float] = {}
+
+ self.listeners = []
+
+ async def setup(self) -> None:
+ """Set up a Freebox router."""
+ self._api = await get_api(self.hass, self._host)
+
+ try:
+ await self._api.open(self._host, self._port)
+ except HttpRequestError:
+ _LOGGER.exception("Failed to connect to Freebox")
+ return ConfigEntryNotReady
+
+ # System
+ fbx_config = await self._api.system.get_config()
+ self.mac = fbx_config["mac"]
+ self._name = fbx_config["model_info"]["pretty_name"]
+ self._sw_v = fbx_config["firmware_version"]
+
+ # Devices & sensors
+ await self.update_all()
+ async_track_time_interval(self.hass, self.update_all, SCAN_INTERVAL)
+
+ async def update_all(self, now: Optional[datetime] = None) -> None:
+ """Update all Freebox platforms."""
+ await self.update_sensors()
+ await self.update_devices()
+
+ async def update_devices(self) -> None:
+ """Update Freebox devices."""
+ new_device = False
+ fbx_devices: Dict[str, any] = await self._api.lan.get_hosts_list()
+
+ # Adds the Freebox itself
+ fbx_devices.append(
+ {
+ "primary_name": self._name,
+ "l2ident": {"id": self.mac},
+ "vendor_name": "Freebox SAS",
+ "host_type": "router",
+ "active": True,
+ "attrs": self._attrs,
+ }
+ )
+
+ for fbx_device in fbx_devices:
+ device_mac = fbx_device["l2ident"]["id"]
+
+ if self.devices.get(device_mac) is None:
+ new_device = True
+
+ self.devices[device_mac] = fbx_device
+
+ async_dispatcher_send(self.hass, self.signal_device_update)
+
+ if new_device:
+ async_dispatcher_send(self.hass, self.signal_device_new)
+
+ async def update_sensors(self) -> None:
+ """Update Freebox sensors."""
+ # System sensors
+ syst_datas: Dict[str, any] = await self._api.system.get_config()
+
+ # According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree.
+ # Name and id of sensors may vary under Freebox devices.
+ for sensor in syst_datas["sensors"]:
+ self.sensors_temperature[sensor["name"]] = sensor["value"]
+
+ # Connection sensors
+ connection_datas: Dict[str, any] = await self._api.connection.get_status()
+ for sensor_key in CONNECTION_SENSORS:
+ self.sensors_connection[sensor_key] = connection_datas[sensor_key]
+
+ self._attrs = {
+ "IPv4": connection_datas.get("ipv4"),
+ "IPv6": connection_datas.get("ipv6"),
+ "connection_type": connection_datas["media"],
+ "uptime": datetime.fromtimestamp(
+ round(datetime.now().timestamp()) - syst_datas["uptime_val"]
+ ),
+ "firmware_version": self._sw_v,
+ "serial": syst_datas["serial"],
+ }
+
+ async_dispatcher_send(self.hass, self.signal_sensor_update)
+
+ async def reboot(self) -> None:
+ """Reboot the Freebox."""
+ await self._api.system.reboot()
+
+ async def close(self) -> None:
+ """Close the connection."""
+ if self._api is not None:
+ await self._api.close()
+ self._api = None
+
+ @property
+ def device_info(self) -> Dict[str, any]:
+ """Return the device information."""
+ return {
+ "connections": {(CONNECTION_NETWORK_MAC, self.mac)},
+ "identifiers": {(DOMAIN, self.mac)},
+ "name": self._name,
+ "manufacturer": "Freebox SAS",
+ "sw_version": self._sw_v,
+ }
+
+ @property
+ def signal_device_new(self) -> str:
+ """Event specific per Freebox entry to signal new device."""
+ return f"{DOMAIN}-{self._host}-device-new"
+
+ @property
+ def signal_device_update(self) -> str:
+ """Event specific per Freebox entry to signal updates in devices."""
+ return f"{DOMAIN}-{self._host}-device-update"
+
+ @property
+ def signal_sensor_update(self) -> str:
+ """Event specific per Freebox entry to signal updates in sensors."""
+ return f"{DOMAIN}-{self._host}-sensor-update"
+
+ @property
+ def sensors(self) -> Wifi:
+ """Return the wifi."""
+ return {**self.sensors_temperature, **self.sensors_connection}
+
+ @property
+ def wifi(self) -> Wifi:
+ """Return the wifi."""
+ return self._api.wifi
+
+
+async def get_api(hass: HomeAssistantType, host: str) -> Freepybox:
+ """Get the Freebox API."""
+ freebox_path = Path(hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY).path)
+ freebox_path.mkdir(exist_ok=True)
+
+ token_file = Path(f"{freebox_path}/{slugify(host)}.conf")
+
+ return Freepybox(APP_DESC, token_file, API_VERSION)
diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py
index 0653120b49c..a3c5c32901c 100644
--- a/homeassistant/components/freebox/sensor.py
+++ b/homeassistant/components/freebox/sensor.py
@@ -1,81 +1,127 @@
"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
import logging
+from typing import Dict
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import HomeAssistantType
-from . import DATA_FREEBOX
+from .const import (
+ CONNECTION_SENSORS,
+ DOMAIN,
+ SENSOR_DEVICE_CLASS,
+ SENSOR_ICON,
+ SENSOR_NAME,
+ SENSOR_UNIT,
+ TEMPERATURE_SENSOR_TEMPLATE,
+)
+from .router import FreeboxRouter
_LOGGER = logging.getLogger(__name__)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
"""Set up the sensors."""
- fbx = hass.data[DATA_FREEBOX]
- async_add_entities([FbxRXSensor(fbx), FbxTXSensor(fbx)], True)
+ router = hass.data[DOMAIN][entry.unique_id]
+ entities = []
+
+ for sensor_name in router.sensors_temperature:
+ entities.append(
+ FreeboxSensor(
+ router,
+ sensor_name,
+ {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"},
+ )
+ )
+
+ for sensor_key in CONNECTION_SENSORS:
+ entities.append(
+ FreeboxSensor(router, sensor_key, CONNECTION_SENSORS[sensor_key])
+ )
+
+ async_add_entities(entities, True)
-class FbxSensor(Entity):
- """Representation of a freebox sensor."""
+class FreeboxSensor(Entity):
+ """Representation of a Freebox sensor."""
- _name = "generic"
- _unit = None
- _icon = None
-
- def __init__(self, fbx):
- """Initialize the sensor."""
- self._fbx = fbx
+ def __init__(
+ self, router: FreeboxRouter, sensor_type: str, sensor: Dict[str, any]
+ ) -> None:
+ """Initialize a Freebox sensor."""
self._state = None
- self._datas = None
+ self._router = router
+ self._sensor_type = sensor_type
+ self._name = sensor[SENSOR_NAME]
+ self._unit = sensor[SENSOR_UNIT]
+ self._icon = sensor[SENSOR_ICON]
+ self._device_class = sensor[SENSOR_DEVICE_CLASS]
+ self._unique_id = f"{self._router.mac} {self._name}"
+
+ self._unsub_dispatcher = None
+
+ def update(self) -> None:
+ """Update the Freebox sensor."""
+ state = self._router.sensors[self._sensor_type]
+ if self._unit == DATA_RATE_KILOBYTES_PER_SECOND:
+ self._state = round(state / 1000, 2)
+ else:
+ self._state = state
@property
- def name(self):
- """Return the name of the sensor."""
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self) -> str:
+ """Return the name."""
return self._name
@property
- def unit_of_measurement(self):
- """Return the unit of the sensor."""
+ def state(self) -> str:
+ """Return the state."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit."""
return self._unit
@property
- def icon(self):
- """Return the icon of the sensor."""
+ def icon(self) -> str:
+ """Return the icon."""
return self._icon
@property
- def state(self):
- """Return the state of the sensor."""
- return self._state
+ def device_class(self) -> str:
+ """Return the device_class."""
+ return self._device_class
- async def async_update(self):
- """Fetch status from freebox."""
- self._datas = await self._fbx.connection.get_status()
+ @property
+ def device_info(self) -> Dict[str, any]:
+ """Return the device information."""
+ return self._router.device_info
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
-class FbxRXSensor(FbxSensor):
- """Update the Freebox RxSensor."""
+ async def async_on_demand_update(self):
+ """Update state."""
+ self.async_schedule_update_ha_state(True)
- _name = "Freebox download speed"
- _unit = DATA_RATE_KILOBYTES_PER_SECOND
- _icon = "mdi:download-network"
+ async def async_added_to_hass(self):
+ """Register state update callback."""
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, self._router.signal_sensor_update, self.async_on_demand_update
+ )
- async def async_update(self):
- """Get the value from fetched datas."""
- await super().async_update()
- if self._datas is not None:
- self._state = round(self._datas["rate_down"] / 1000, 2)
-
-
-class FbxTXSensor(FbxSensor):
- """Update the Freebox TxSensor."""
-
- _name = "Freebox upload speed"
- _unit = DATA_RATE_KILOBYTES_PER_SECOND
- _icon = "mdi:upload-network"
-
- async def async_update(self):
- """Get the value from fetched datas."""
- await super().async_update()
- if self._datas is not None:
- self._state = round(self._datas["rate_up"] / 1000, 2)
+ async def async_will_remove_from_hass(self):
+ """Clean up after entity before removal."""
+ self._unsub_dispatcher()
diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json
new file mode 100644
index 00000000000..867a497d02f
--- /dev/null
+++ b/homeassistant/components/freebox/strings.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "title": "Freebox",
+ "step": {
+ "user": {
+ "title": "Freebox",
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ }
+ },
+ "link": {
+ "title": "Link Freebox router",
+ "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n"
+ }
+ },
+ "error":{
+ "register_failed": "Failed to register, please try again",
+ "connection_failed": "Failed to connect, please try again",
+ "unknown": "Unknown error: please retry later"
+ },
+ "abort":{
+ "already_configured": "Host already configured"
+ }
+ }
+}
diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py
index 062d6a699fe..9e1011d5d3c 100644
--- a/homeassistant/components/freebox/switch.py
+++ b/homeassistant/components/freebox/switch.py
@@ -1,50 +1,65 @@
"""Support for Freebox Delta, Revolution and Mini 4K."""
import logging
+from typing import Dict
+
+from aiofreepybox.exceptions import InsufficientPermissionsError
from homeassistant.components.switch import SwitchDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
-from . import DATA_FREEBOX
+from .const import DOMAIN
+from .router import FreeboxRouter
_LOGGER = logging.getLogger(__name__)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
"""Set up the switch."""
- fbx = hass.data[DATA_FREEBOX]
- async_add_entities([FbxWifiSwitch(fbx)], True)
+ router = hass.data[DOMAIN][entry.unique_id]
+ async_add_entities([FreeboxWifiSwitch(router)], True)
-class FbxWifiSwitch(SwitchDevice):
+class FreeboxWifiSwitch(SwitchDevice):
"""Representation of a freebox wifi switch."""
- def __init__(self, fbx):
+ def __init__(self, router: FreeboxRouter) -> None:
"""Initialize the Wifi switch."""
self._name = "Freebox WiFi"
self._state = None
- self._fbx = fbx
+ self._router = router
+ self._unique_id = f"{self._router.mac} {self._name}"
@property
- def name(self):
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self) -> str:
"""Return the name of the switch."""
return self._name
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if device is on."""
return self._state
- async def _async_set_state(self, enabled):
- """Turn the switch on or off."""
- from aiofreepybox.exceptions import InsufficientPermissionsError
+ @property
+ def device_info(self) -> Dict[str, any]:
+ """Return the device information."""
+ return self._router.device_info
+ async def _async_set_state(self, enabled: bool):
+ """Turn the switch on or off."""
wifi_config = {"enabled": enabled}
try:
- await self._fbx.wifi.set_global_config(wifi_config)
+ await self._router.wifi.set_global_config(wifi_config)
except InsufficientPermissionsError:
_LOGGER.warning(
- "Home Assistant does not have permissions to"
- " modify the Freebox settings. Please refer"
- " to documentation."
+ "Home Assistant does not have permissions to modify the Freebox settings. Please refer to documentation."
)
async def async_turn_on(self, **kwargs):
@@ -57,6 +72,6 @@ class FbxWifiSwitch(SwitchDevice):
async def async_update(self):
"""Get the state and update it."""
- datas = await self._fbx.wifi.get_global_config()
+ datas = await self._router.wifi.get_global_config()
active = datas["enabled"]
self._state = bool(active)
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index e5f6c3e2a26..efd9f99b18a 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -2,9 +2,7 @@
"domain": "frontend",
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
- "requirements": [
- "home-assistant-frontend==20200318.1"
- ],
+ "requirements": ["home-assistant-frontend==20200407.1"],
"dependencies": [
"api",
"auth",
@@ -16,8 +14,6 @@
"system_log",
"websocket_api"
],
- "codeowners": [
- "@home-assistant/frontend"
- ],
+ "codeowners": ["@home-assistant/frontend"],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/garmin_connect/.translations/ko.json b/homeassistant/components/garmin_connect/.translations/ko.json
index 018a0a8d923..eb354821d3d 100644
--- a/homeassistant/components/garmin_connect/.translations/ko.json
+++ b/homeassistant/components/garmin_connect/.translations/ko.json
@@ -16,9 +16,9 @@
"username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
},
"description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694",
- "title": "Garmin \uc5f0\uacb0"
+ "title": "Garmin Connect"
}
},
- "title": "Garmin \uc5f0\uacb0"
+ "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
index f7bdba27906..bc8669086b1 100644
--- a/homeassistant/components/garmin_connect/.translations/no.json
+++ b/homeassistant/components/garmin_connect/.translations/no.json
@@ -16,9 +16,9 @@
"username": "Brukernavn"
},
"description": "Angi brukeropplysninger.",
- "title": "Garmin Connect"
+ "title": ""
}
},
- "title": "Garmin Connect"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py
index d63d82d1284..1536a875698 100644
--- a/homeassistant/components/garmin_connect/__init__.py
+++ b/homeassistant/components/garmin_connect/__init__.py
@@ -43,13 +43,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
GarminConnectAuthenticationError,
GarminConnectTooManyRequestsError,
) as err:
- _LOGGER.error("Error occurred during Garmin Connect login: %s", err)
+ _LOGGER.error("Error occurred during Garmin Connect login request: %s", err)
return False
except (GarminConnectConnectionError) as err:
- _LOGGER.error("Error occurred during Garmin Connect login: %s", err)
+ _LOGGER.error(
+ "Connection error occurred during Garmin Connect login request: %s", err
+ )
raise ConfigEntryNotReady
except Exception: # pylint: disable=broad-except
- _LOGGER.error("Unknown error occurred during Garmin Connect login")
+ _LOGGER.exception("Unknown error occurred during Garmin Connect login request")
return False
garmin_data = GarminConnectData(hass, garmin_client)
@@ -93,16 +95,18 @@ class GarminConnectData:
today = date.today()
try:
- self.data = self.client.get_stats(today.isoformat())
+ self.data = self.client.get_stats_and_body(today.isoformat())
except (
GarminConnectAuthenticationError,
GarminConnectTooManyRequestsError,
+ GarminConnectConnectionError,
) 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)
+ _LOGGER.error(
+ "Error occurred during Garmin Connect get activity request: %s", err
+ )
return
except Exception: # pylint: disable=broad-except
- _LOGGER.error("Unknown error occurred during Garmin Connect get stats")
+ _LOGGER.exception(
+ "Unknown error occurred during Garmin Connect get activity request"
+ )
return
diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py
index 38245ff5eb8..ebf56b27a7f 100644
--- a/homeassistant/components/garmin_connect/const.py
+++ b/homeassistant/components/garmin_connect/const.py
@@ -309,4 +309,13 @@ GARMIN_ENTITY_LIST = {
DEVICE_CLASS_TIMESTAMP,
False,
],
+ "weight": ["Weight", "kg", "mdi:weight-kilogram", None, False],
+ "bmi": ["BMI", "", "mdi:food", None, False],
+ "bodyFat": ["Body Fat", "%", "mdi:food", None, False],
+ "bodyWater": ["Body Water", "%", "mdi:water-percent", None, False],
+ "bodyMass": ["Body Mass", "kg", "mdi:food", None, False],
+ "muscleMass": ["Muscle Mass", "kg", "mdi:dumbbell", None, False],
+ "physiqueRating": ["Physique Rating", "", "mdi:numeric", None, False],
+ "visceralFat": ["Visceral Fat", "", "mdi:food", None, False],
+ "metabolicAge": ["Metabolic Age", "", "mdi:calendar-heart", None, False],
}
diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json
index b2282831572..ee534354cb3 100644
--- a/homeassistant/components/garmin_connect/manifest.json
+++ b/homeassistant/components/garmin_connect/manifest.json
@@ -3,7 +3,7 @@
"name": "Garmin Connect",
"documentation": "https://www.home-assistant.io/integrations/garmin_connect",
"dependencies": [],
- "requirements": ["garminconnect==0.1.8"],
+ "requirements": ["garminconnect==0.1.10"],
"codeowners": ["@cyberjunky"],
"config_flow": true
}
diff --git a/homeassistant/components/garmin_connect/sensor.py b/homeassistant/components/garmin_connect/sensor.py
index 5edf54d95dc..78bf248c51b 100644
--- a/homeassistant/components/garmin_connect/sensor.py
+++ b/homeassistant/components/garmin_connect/sensor.py
@@ -34,7 +34,7 @@ async def async_setup_entry(
) 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.")
+ _LOGGER.exception("Unknown error occurred during Garmin Connect Client update.")
entities = []
for (
@@ -172,6 +172,12 @@ class GarminConnectSensor(Entity):
if "Duration" in self._type or "Seconds" in self._type:
self._state = data[self._type] // 60
+ elif "Mass" in self._type or self._type == "weight":
+ self._state = round((data[self._type] / 1000), 2)
+ elif (
+ self._type == "bodyFat" or self._type == "bodyWater" or self._type == "bmi"
+ ):
+ self._state = round(data[self._type], 2)
else:
self._state = data[self._type]
diff --git a/homeassistant/components/gdacs/.translations/no.json b/homeassistant/components/gdacs/.translations/no.json
index 54b3ca68451..f344c47365d 100644
--- a/homeassistant/components/gdacs/.translations/no.json
+++ b/homeassistant/components/gdacs/.translations/no.json
@@ -6,7 +6,7 @@
"step": {
"user": {
"data": {
- "radius": "Radius"
+ "radius": ""
},
"title": "Fyll ut filterdetaljene."
}
diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py
index 31c3ba4138c..c45d6e56425 100644
--- a/homeassistant/components/gdacs/geo_location.py
+++ b/homeassistant/components/gdacs/geo_location.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
)
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.unit_system import IMPERIAL_SYSTEM
from .const import DEFAULT_ICON, DOMAIN, FEED
@@ -107,6 +108,10 @@ class GdacsEvent(GeolocationEvent):
"""Call when entity will be removed from hass."""
self._remove_signal_delete()
self._remove_signal_update()
+ # Remove from entity registry.
+ entity_registry = await async_get_registry(self.hass)
+ if self.entity_id in entity_registry.entities:
+ entity_registry.async_remove(self.entity_id)
@callback
def _delete_callback(self):
diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py
index 3abeab32262..768ef108969 100644
--- a/homeassistant/components/generic/camera.py
+++ b/homeassistant/components/generic/camera.py
@@ -132,7 +132,9 @@ class GenericCamera(Camera):
)
return response.content
except requests.exceptions.RequestException as error:
- _LOGGER.error("Error getting camera image: %s", error)
+ _LOGGER.error(
+ "Error getting new camera image from %s: %s", self._name, error
+ )
return self._last_image
self._last_image = await self.hass.async_add_job(fetch)
@@ -146,10 +148,12 @@ class GenericCamera(Camera):
response = await websession.get(url, auth=self._auth)
self._last_image = await response.read()
except asyncio.TimeoutError:
- _LOGGER.error("Timeout getting image from: %s", self._name)
+ _LOGGER.error("Timeout getting camera image from %s", self._name)
return self._last_image
except aiohttp.ClientError as err:
- _LOGGER.error("Error getting new camera image: %s", err)
+ _LOGGER.error(
+ "Error getting new camera image from %s: %s", self._name, err
+ )
return self._last_image
self._last_url = url
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index 8714ddcfbe6..a7ddcc08314 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -30,6 +30,7 @@ from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_ON,
+ STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import DOMAIN as HA_DOMAIN, callback
@@ -197,7 +198,10 @@ class GenericThermostat(ClimateDevice, RestoreEntity):
def _async_startup(event):
"""Init on startup."""
sensor_state = self.hass.states.get(self.sensor_entity_id)
- if sensor_state and sensor_state.state != STATE_UNKNOWN:
+ if sensor_state and sensor_state.state not in (
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ ):
self._async_update_temp(sensor_state)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)
@@ -352,7 +356,7 @@ class GenericThermostat(ClimateDevice, RestoreEntity):
async def _async_sensor_changed(self, entity_id, old_state, new_state):
"""Handle temperature changes."""
- if new_state is None:
+ if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return
self._async_update_temp(new_state)
diff --git a/homeassistant/components/geofency/.translations/no.json b/homeassistant/components/geofency/.translations/no.json
index 1956c453a9f..431c0e16e7d 100644
--- a/homeassistant/components/geofency/.translations/no.json
+++ b/homeassistant/components/geofency/.translations/no.json
@@ -13,6 +13,6 @@
"title": "Sett opp Geofency Webhook"
}
},
- "title": "Geofency Webhook"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/geonetnz_quakes/.translations/bg.json b/homeassistant/components/geonetnz_quakes/.translations/bg.json
index 48d6eacda91..c907a6bafd9 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/bg.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/bg.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u043e"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/ca.json b/homeassistant/components/geonetnz_quakes/.translations/ca.json
index 57ce2b4ee81..c422c1768a7 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/ca.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/ca.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Ubicaci\u00f3 ja registrada"
+ "abort": {
+ "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/da.json b/homeassistant/components/geonetnz_quakes/.translations/da.json
index 0d0e927bc4b..15847cdadc9 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/da.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/da.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Placering allerede registreret"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/de.json b/homeassistant/components/geonetnz_quakes/.translations/de.json
index e5c2acf352c..a9d3c8dca79 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/de.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/de.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Standort bereits registriert"
+ "abort": {
+ "already_configured": "Der Standort ist bereits konfiguriert."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/en.json b/homeassistant/components/geonetnz_quakes/.translations/en.json
index 4143efcdf96..41fafa5763b 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/en.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/en.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Location already registered"
+ "abort": {
+ "already_configured": "Location is already configured."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/es.json b/homeassistant/components/geonetnz_quakes/.translations/es.json
index f6f592675ab..daab68f1111 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/es.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/es.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Ubicaci\u00f3n ya registrada"
+ "abort": {
+ "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/fr.json b/homeassistant/components/geonetnz_quakes/.translations/fr.json
index 74ae5541754..0a6fb793628 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/fr.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/fr.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Emplacement d\u00e9j\u00e0 enregistr\u00e9"
+ "abort": {
+ "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/it.json b/homeassistant/components/geonetnz_quakes/.translations/it.json
index 2a019aa39d9..2b2cac02737 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/it.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/it.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Localit\u00e0 gi\u00e0 registrata"
+ "abort": {
+ "already_configured": "La posizione \u00e8 gi\u00e0 configurata."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/ko.json b/homeassistant/components/geonetnz_quakes/.translations/ko.json
index 66a216149dd..30c534b18e0 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/ko.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/ko.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ "abort": {
+ "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/lb.json b/homeassistant/components/geonetnz_quakes/.translations/lb.json
index 2499befecbb..a4cbecc5818 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/lb.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/lb.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Standuert ass scho registr\u00e9iert"
+ "abort": {
+ "already_configured": "Standuert ass scho konfigu\u00e9iert."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/nl.json b/homeassistant/components/geonetnz_quakes/.translations/nl.json
index d6af28240eb..4495dee078d 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/nl.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/nl.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Locatie al geregistreerd"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/no.json b/homeassistant/components/geonetnz_quakes/.translations/no.json
index 40b695d6f51..82160a4295f 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/no.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/no.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Beliggenhet allerede er registrert"
+ "abort": {
+ "already_configured": "Plasseringen er allerede konfigurert."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/pl.json b/homeassistant/components/geonetnz_quakes/.translations/pl.json
index bdd8f152d39..b9763b61fcc 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 jest ju\u017c zarejestrowana."
+ "abort": {
+ "already_configured": "Lokalizacja jest ju\u017c skonfigurowana."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json b/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json
index 7e3ee3b24da..1dcf264b3f6 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/pt-BR.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Localiza\u00e7\u00e3o j\u00e1 registrada"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/ru.json b/homeassistant/components/geonetnz_quakes/.translations/ru.json
index dddb5c47bb9..0b3d23bfa3b 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/ru.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "\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."
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/sl.json b/homeassistant/components/geonetnz_quakes/.translations/sl.json
index bdd05d33953..03f265f2719 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/sl.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/sl.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Lokacija je \u017ee registrirana"
+ "abort": {
+ "already_configured": "Lokacija je \u017ee nastavljena."
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/sv.json b/homeassistant/components/geonetnz_quakes/.translations/sv.json
index 13058ad3ad2..3e27c340808 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/sv.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/sv.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Plats redan registrerad"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json b/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json
index 487ac9ea8c0..f46e74a35bc 100644
--- a/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json
+++ b/homeassistant/components/geonetnz_quakes/.translations/zh-Hant.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "\u5ea7\u6a19\u5df2\u8a3b\u518a"
+ "abort": {
+ "already_configured": "\u4f4d\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002"
},
"step": {
"user": {
diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py
index 7d29f5ed3ec..ed0b9f9f714 100644
--- a/homeassistant/components/geonetnz_quakes/geo_location.py
+++ b/homeassistant/components/geonetnz_quakes/geo_location.py
@@ -96,7 +96,8 @@ class GeonetnzQuakesEvent(GeolocationEvent):
self._remove_signal_update()
# Remove from entity registry.
entity_registry = await async_get_registry(self.hass)
- entity_registry.async_remove(self.entity_id)
+ if self.entity_id in entity_registry.entities:
+ entity_registry.async_remove(self.entity_id)
@callback
def _delete_callback(self):
diff --git a/homeassistant/components/gios/.translations/no.json b/homeassistant/components/gios/.translations/no.json
index b045c51e563..9842ae67a4b 100644
--- a/homeassistant/components/gios/.translations/no.json
+++ b/homeassistant/components/gios/.translations/no.json
@@ -18,6 +18,6 @@
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)"
}
},
- "title": "GIO\u015a"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py
index 981de6395de..c7e708e3207 100644
--- a/homeassistant/components/gios/__init__.py
+++ b/homeassistant/components/gios/__init__.py
@@ -1,24 +1,22 @@
"""The GIOS component."""
-import asyncio
import logging
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
-from gios import ApiError, Gios, NoStationError
+from gios import ApiError, Gios, InvalidSensorsData, NoStationError
from homeassistant.core import Config, HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.util import Throttle
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_STATION_ID, DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN
+from .const import CONF_STATION_ID, DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured GIOS."""
- hass.data[DOMAIN] = {}
- hass.data[DOMAIN][DATA_CLIENT] = {}
return True
@@ -29,11 +27,14 @@ async def async_setup_entry(hass, config_entry):
websession = async_get_clientsession(hass)
- gios = GiosData(websession, station_id)
+ coordinator = GiosDataUpdateCoordinator(hass, websession, station_id)
+ await coordinator.async_refresh()
- await gios.async_update()
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
- hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = gios
+ hass.data.setdefault(DOMAIN, {})
+ hass.data[DOMAIN][config_entry.entry_id] = coordinator
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, "air_quality")
@@ -43,36 +44,32 @@ async def async_setup_entry(hass, config_entry):
async def async_unload_entry(hass, config_entry):
"""Unload a config entry."""
- hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
+ hass.data[DOMAIN].pop(config_entry.entry_id)
await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality")
return True
-class GiosData:
+class GiosDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold GIOS data."""
- def __init__(self, session, station_id):
- """Initialize."""
- self._gios = Gios(station_id, session)
- self.station_id = station_id
- self.sensors = {}
- self.latitude = None
- self.longitude = None
- self.station_name = None
- self.available = True
+ def __init__(self, hass, session, station_id):
+ """Class to manage fetching GIOS data API."""
+ self.gios = Gios(station_id, session)
- @Throttle(DEFAULT_SCAN_INTERVAL)
- async def async_update(self):
- """Update GIOS data."""
+ super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
+
+ async def _async_update_data(self):
+ """Update data via library."""
try:
with timeout(30):
- await self._gios.update()
- except asyncio.TimeoutError:
- _LOGGER.error("Asyncio Timeout Error")
- except (ApiError, NoStationError, ClientConnectorError) as error:
- _LOGGER.error("GIOS data update failed: %s", error)
- self.available = self._gios.available
- self.latitude = self._gios.latitude
- self.longitude = self._gios.longitude
- self.station_name = self._gios.station_name
- self.sensors = self._gios.data
+ await self.gios.update()
+ except (
+ ApiError,
+ NoStationError,
+ ClientConnectorError,
+ InvalidSensorsData,
+ ) as error:
+ raise UpdateFailed(error)
+ if not self.gios.data:
+ raise UpdateFailed("Invalid sensors data")
+ return self.gios.data
diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py
index f7285c8cc5a..c8cd8be11c7 100644
--- a/homeassistant/components/gios/air_quality.py
+++ b/homeassistant/components/gios/air_quality.py
@@ -10,19 +10,27 @@ from homeassistant.components.air_quality import (
)
from homeassistant.const import CONF_NAME
-from .const import ATTR_STATION, DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, ICONS_MAP
+from .const import ATTR_STATION, DOMAIN, ICONS_MAP
ATTRIBUTION = "Data provided by GIOŚ"
-SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL
+
+SENSOR_MAP = {
+ "CO": ATTR_CO,
+ "NO2": ATTR_NO2,
+ "O3": ATTR_OZONE,
+ "PM10": ATTR_PM_10,
+ "PM2.5": ATTR_PM_2_5,
+ "SO2": ATTR_SO2,
+}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add a GIOS entities from a config_entry."""
name = config_entry.data[CONF_NAME]
- data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
+ coordinator = hass.data[DOMAIN][config_entry.entry_id]
- async_add_entities([GiosAirQuality(data, name)], True)
+ async_add_entities([GiosAirQuality(coordinator, name)], False)
def round_state(func):
@@ -40,17 +48,10 @@ def round_state(func):
class GiosAirQuality(AirQualityEntity):
"""Define an GIOS sensor."""
- def __init__(self, gios, name):
+ def __init__(self, coordinator, name):
"""Initialize."""
- self.gios = gios
+ self.coordinator = coordinator
self._name = name
- self._aqi = None
- self._co = None
- self._no2 = None
- self._o3 = None
- self._pm_2_5 = None
- self._pm_10 = None
- self._so2 = None
self._attrs = {}
@property
@@ -61,50 +62,50 @@ class GiosAirQuality(AirQualityEntity):
@property
def icon(self):
"""Return the icon."""
- if self._aqi in ICONS_MAP:
- return ICONS_MAP[self._aqi]
+ if self.air_quality_index in ICONS_MAP:
+ return ICONS_MAP[self.air_quality_index]
return "mdi:blur"
@property
def air_quality_index(self):
"""Return the air quality index."""
- return self._aqi
+ return self._get_sensor_value("AQI")
@property
@round_state
def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
- return self._pm_2_5
+ return self._get_sensor_value("PM2.5")
@property
@round_state
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
- return self._pm_10
+ return self._get_sensor_value("PM10")
@property
@round_state
def ozone(self):
"""Return the O3 (ozone) level."""
- return self._o3
+ return self._get_sensor_value("O3")
@property
@round_state
def carbon_monoxide(self):
"""Return the CO (carbon monoxide) level."""
- return self._co
+ return self._get_sensor_value("CO")
@property
@round_state
def sulphur_dioxide(self):
"""Return the SO2 (sulphur dioxide) level."""
- return self._so2
+ return self._get_sensor_value("SO2")
@property
@round_state
def nitrogen_dioxide(self):
"""Return the NO2 (nitrogen dioxide) level."""
- return self._no2
+ return self._get_sensor_value("NO2")
@property
def attribution(self):
@@ -114,45 +115,45 @@ class GiosAirQuality(AirQualityEntity):
@property
def unique_id(self):
"""Return a unique_id for this entity."""
- return self.gios.station_id
+ return self.coordinator.gios.station_id
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement of the entity."""
+ return False
@property
def available(self):
"""Return True if entity is available."""
- return self.gios.available
+ return self.coordinator.last_update_success
@property
def device_state_attributes(self):
"""Return the state attributes."""
- self._attrs[ATTR_STATION] = self.gios.station_name
+ # Different measuring stations have different sets of sensors. We don't know
+ # what data we will get.
+ for sensor in SENSOR_MAP:
+ if sensor in self.coordinator.data:
+ self._attrs[f"{SENSOR_MAP[sensor]}_index"] = self.coordinator.data[
+ sensor
+ ]["index"]
+ self._attrs[ATTR_STATION] = self.coordinator.gios.station_name
return self._attrs
- async def async_update(self):
- """Get the data from GIOS."""
- await self.gios.async_update()
+ async def async_added_to_hass(self):
+ """Connect to dispatcher listening for entity data notifications."""
+ self.coordinator.async_add_listener(self.async_write_ha_state)
- if self.gios.available:
- # Different measuring stations have different sets of sensors. We don't know
- # what data we will get.
- if "AQI" in self.gios.sensors:
- self._aqi = self.gios.sensors["AQI"]["value"]
- if "CO" in self.gios.sensors:
- self._co = self.gios.sensors["CO"]["value"]
- self._attrs[f"{ATTR_CO}_index"] = self.gios.sensors["CO"]["index"]
- if "NO2" in self.gios.sensors:
- self._no2 = self.gios.sensors["NO2"]["value"]
- self._attrs[f"{ATTR_NO2}_index"] = self.gios.sensors["NO2"]["index"]
- if "O3" in self.gios.sensors:
- self._o3 = self.gios.sensors["O3"]["value"]
- self._attrs[f"{ATTR_OZONE}_index"] = self.gios.sensors["O3"]["index"]
- if "PM2.5" in self.gios.sensors:
- self._pm_2_5 = self.gios.sensors["PM2.5"]["value"]
- self._attrs[f"{ATTR_PM_2_5}_index"] = self.gios.sensors["PM2.5"][
- "index"
- ]
- if "PM10" in self.gios.sensors:
- self._pm_10 = self.gios.sensors["PM10"]["value"]
- self._attrs[f"{ATTR_PM_10}_index"] = self.gios.sensors["PM10"]["index"]
- if "SO2" in self.gios.sensors:
- self._so2 = self.gios.sensors["SO2"]["value"]
- self._attrs[f"{ATTR_SO2}_index"] = self.gios.sensors["SO2"]["index"]
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
+
+ async def async_update(self):
+ """Update GIOS entity."""
+ await self.coordinator.async_request_refresh()
+
+ def _get_sensor_value(self, sensor):
+ """Return value of specified sensor."""
+ if sensor in self.coordinator.data:
+ return self.coordinator.data[sensor]["value"]
+ return None
diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py
index 368d610c226..5741af47a07 100644
--- a/homeassistant/components/gios/config_flow.py
+++ b/homeassistant/components/gios/config_flow.py
@@ -3,10 +3,10 @@ import asyncio
from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
-from gios import ApiError, Gios, NoStationError
+from gios import ApiError, Gios, InvalidSensorsData, NoStationError
import voluptuous as vol
-from homeassistant import config_entries, exceptions
+from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -43,9 +43,6 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
gios = Gios(user_input[CONF_STATION_ID], websession)
await gios.update()
- if not gios.available:
- raise InvalidSensorsData()
-
return self.async_create_entry(
title=user_input[CONF_STATION_ID], data=user_input,
)
@@ -59,7 +56,3 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
-
-
-class InvalidSensorsData(exceptions.HomeAssistantError):
- """Error to indicate invalid sensors data."""
diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py
index 3588b5e8dfc..918b4fba2e4 100644
--- a/homeassistant/components/gios/const.py
+++ b/homeassistant/components/gios/const.py
@@ -4,10 +4,9 @@ from datetime import timedelta
ATTR_NAME = "name"
ATTR_STATION = "station"
CONF_STATION_ID = "station_id"
-DATA_CLIENT = "client"
DEFAULT_NAME = "GIOŚ"
# Term of service GIOŚ allow downloading data no more than twice an hour.
-DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)
+SCAN_INTERVAL = timedelta(minutes=30)
DOMAIN = "gios"
AQI_GOOD = "dobry"
diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json
index b3d125d8ab6..3e3d63965d3 100644
--- a/homeassistant/components/gios/manifest.json
+++ b/homeassistant/components/gios/manifest.json
@@ -4,6 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/gios",
"dependencies": [],
"codeowners": ["@bieniu"],
- "requirements": ["gios==0.0.3"],
+ "requirements": ["gios==0.1.1"],
"config_flow": true
}
diff --git a/homeassistant/components/glances/.translations/no.json b/homeassistant/components/glances/.translations/no.json
index 7cf28cc34d0..b25241e34db 100644
--- a/homeassistant/components/glances/.translations/no.json
+++ b/homeassistant/components/glances/.translations/no.json
@@ -13,7 +13,7 @@
"host": "Vert",
"name": "Navn",
"password": "Passord",
- "port": "Port",
+ "port": "",
"ssl": "Bruk SSL / TLS for \u00e5 koble til Glances-systemet",
"username": "Brukernavn",
"verify_ssl": "Bekreft sertifiseringen av systemet",
diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py
index 97c872bdaf8..55e121e2fc7 100644
--- a/homeassistant/components/google_assistant/smart_home.py
+++ b/homeassistant/components/google_assistant/smart_home.py
@@ -131,6 +131,24 @@ async def async_devices_query(hass, data, payload):
return {"devices": devices}
+async def _entity_execute(entity, data, executions):
+ """Execute all commands for an entity.
+
+ Returns a dict if a special result needs to be set.
+ """
+ for execution in executions:
+ try:
+ await entity.execute(data, execution)
+ except SmartHomeError as err:
+ return {
+ "ids": [entity.entity_id],
+ "status": "ERROR",
+ **err.to_response(),
+ }
+
+ return None
+
+
@HANDLERS.register("action.devices.EXECUTE")
async def handle_devices_execute(hass, data, payload):
"""Handle action.devices.EXECUTE request.
@@ -138,6 +156,7 @@ async def handle_devices_execute(hass, data, payload):
https://developers.google.com/assistant/smarthome/develop/process-intents#EXECUTE
"""
entities = {}
+ executions = {}
results = {}
for command in payload["commands"]:
@@ -159,27 +178,33 @@ async def handle_devices_execute(hass, data, payload):
if entity_id in results:
continue
- if entity_id not in entities:
- state = hass.states.get(entity_id)
+ if entity_id in entities:
+ executions[entity_id].append(execution)
+ continue
- if state is None:
- results[entity_id] = {
- "ids": [entity_id],
- "status": "ERROR",
- "errorCode": ERR_DEVICE_OFFLINE,
- }
- continue
+ state = hass.states.get(entity_id)
- entities[entity_id] = GoogleEntity(hass, data.config, state)
-
- try:
- await entities[entity_id].execute(data, execution)
- except SmartHomeError as err:
+ if state is None:
results[entity_id] = {
"ids": [entity_id],
"status": "ERROR",
- **err.to_response(),
+ "errorCode": ERR_DEVICE_OFFLINE,
}
+ continue
+
+ entities[entity_id] = GoogleEntity(hass, data.config, state)
+ executions[entity_id] = [execution]
+
+ execute_results = await asyncio.gather(
+ *[
+ _entity_execute(entities[entity_id], data, executions[entity_id])
+ for entity_id in executions
+ ]
+ )
+
+ for entity_id, result in zip(executions, execute_results):
+ if result is not None:
+ results[entity_id] = result
final_results = list(results.values())
diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py
index 9da319226fa..2bc5f5040d4 100644
--- a/homeassistant/components/google_assistant/trait.py
+++ b/homeassistant/components/google_assistant/trait.py
@@ -647,6 +647,14 @@ class TemperatureSettingTrait(_Trait):
elif domain == climate.DOMAIN:
modes = self.climate_google_modes
+
+ # Some integrations don't support modes (e.g. opentherm), but Google doesn't
+ # support changing the temperature if we don't have any modes. If there's
+ # only one Google doesn't support changing it, so the default mode here is
+ # only cosmetic.
+ if len(modes) == 0:
+ modes.append("heat")
+
if "off" in modes and any(
mode in modes for mode in ("heatcool", "heat", "cool")
):
@@ -1239,7 +1247,11 @@ class OpenCloseTrait(_Trait):
"""
# Cover device classes that require 2FA
- COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE)
+ COVER_2FA = (
+ cover.DEVICE_CLASS_DOOR,
+ cover.DEVICE_CLASS_GARAGE,
+ cover.DEVICE_CLASS_GATE,
+ )
name = TRAIT_OPENCLOSE
commands = [COMMAND_OPENCLOSE]
diff --git a/homeassistant/components/griddy/.translations/ca.json b/homeassistant/components/griddy/.translations/ca.json
new file mode 100644
index 00000000000..17c550636e1
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/ca.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Aquesta zona de c\u00e0rrega ja est\u00e0 configurada"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Zona de c\u00e0rrega (Load Zone)"
+ },
+ "description": "La teva zona de c\u00e0rrega (Load Zone) est\u00e0 al teu compte de Griddy v\u00e9s a \"Account > Meter > Load Zone\".",
+ "title": "Configuraci\u00f3 de la zona de c\u00e0rrega (Load Zone) de Griddy"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/da.json b/homeassistant/components/griddy/.translations/da.json
new file mode 100644
index 00000000000..9bb36f00ba6
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/da.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/de.json b/homeassistant/components/griddy/.translations/de.json
new file mode 100644
index 00000000000..f2012615267
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/de.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Diese Ladezone ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Ladezone (Abwicklungspunkt)"
+ },
+ "description": "Ihre Ladezone befindet sich in Ihrem Griddy-Konto unter \"Konto > Messger\u00e4t > Ladezone\".",
+ "title": "Richten Sie Ihre Griddy Ladezone ein"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/en.json b/homeassistant/components/griddy/.translations/en.json
index bedd85e7508..20b3fbe21eb 100644
--- a/homeassistant/components/griddy/.translations/en.json
+++ b/homeassistant/components/griddy/.translations/en.json
@@ -1,21 +1,21 @@
{
- "config" : {
- "error" : {
- "cannot_connect" : "Failed to connect, please try again",
- "unknown" : "Unexpected error"
- },
- "title" : "Griddy",
- "step" : {
- "user" : {
- "description" : "Your Load Zone is in your Griddy account under “Account > Meter > Load Zone.”",
- "data" : {
- "loadzone" : "Load Zone (Settlement Point)"
- },
- "title" : "Setup your Griddy Load Zone"
- }
- },
- "abort" : {
- "already_configured" : "This Load Zone is already configured"
- }
- }
-}
+ "config": {
+ "abort": {
+ "already_configured": "This Load Zone is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Load Zone (Settlement Point)"
+ },
+ "description": "Your Load Zone is in your Griddy account under \u201cAccount > Meter > Load Zone.\u201d",
+ "title": "Setup your Griddy Load Zone"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/es.json b/homeassistant/components/griddy/.translations/es.json
new file mode 100644
index 00000000000..891564ea4ec
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/es.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Esta Zona de Carga ya est\u00e1 configurada"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Zona de Carga (Punto del Asentamiento)"
+ },
+ "description": "Tu Zona de Carga est\u00e1 en tu cuenta de Griddy en \"Account > Meter > Load Zone\"",
+ "title": "Configurar tu Zona de Carga de Griddy"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/fr.json b/homeassistant/components/griddy/.translations/fr.json
new file mode 100644
index 00000000000..1e0c8c3e9ae
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/fr.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Cette zone de chargement est d\u00e9j\u00e0 configur\u00e9e"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Zone de charge (point d'\u00e9tablissement)"
+ }
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/it.json b/homeassistant/components/griddy/.translations/it.json
new file mode 100644
index 00000000000..2aacd9a4bab
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/it.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Questa Zona di Carico \u00e8 gi\u00e0 configurata"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Zona di Carico (Punto di insediamento)"
+ },
+ "description": "La tua Zona di Carico si trova nel tuo account Griddy in \"Account > Meter > Load zone\".",
+ "title": "Configurazione della Zona di Carico Griddy"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/ko.json b/homeassistant/components/griddy/.translations/ko.json
new file mode 100644
index 00000000000..cc86f0a1b45
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/ko.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc774 \uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed\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.",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "\uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed (\uc815\uc0b0\uc810)"
+ },
+ "description": "\uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed\uc740 Griddy \uacc4\uc815\uc758 \"Account > Meter > Load Zone\"\uc5d0\uc11c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
+ "title": "Griddy \uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed \uc124\uc815"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/lb.json b/homeassistant/components/griddy/.translations/lb.json
new file mode 100644
index 00000000000..c0ee3bc7d5a
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "D\u00ebs Lued Zon ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Lued Zone (Punkt vum R\u00e9glement)"
+ },
+ "description": "Deng Lued Zon ass an dengem Griddy Kont enner \"Account > Meter > Load Zone.\"",
+ "title": "Griddy Lued Zon ariichten"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/no.json b/homeassistant/components/griddy/.translations/no.json
new file mode 100644
index 00000000000..b47fd213ae0
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/no.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Denne Load Zone er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Load Zone (settlingspunkt)"
+ },
+ "description": "Din Load Zone er p\u00e5 din Griddy-konto under \"Konto > M\u00e5ler > Lastesone.\"",
+ "title": "Sett opp din Griddy Load Zone"
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/pl.json b/homeassistant/components/griddy/.translations/pl.json
new file mode 100644
index 00000000000..57484e84d9f
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/pl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ta strefa obci\u0105\u017cenia jest ju\u017c skonfigurowana."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.",
+ "unknown": "Niespodziewany b\u0142\u0105d."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Strefa obci\u0105\u017cenia (punkt rozliczenia)"
+ },
+ "description": "Twoja strefa obci\u0105\u017cenia znajduje si\u0119 na twoim koncie Griddy w sekcji \"Konto > Licznik > Strefa obci\u0105\u017cenia\".",
+ "title": "Konfigurowanie strefy obci\u0105\u017cenia Griddy"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/ru.json b/homeassistant/components/griddy/.translations/ru.json
new file mode 100644
index 00000000000..6f03fecd58a
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/ru.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0439 \u0437\u043e\u043d\u044b \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \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\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "\u0417\u043e\u043d\u0430 \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0438 (\u0440\u0430\u0441\u0447\u0435\u0442\u043d\u0430\u044f \u0442\u043e\u0447\u043a\u0430)"
+ },
+ "description": "\u0417\u043e\u043d\u0430 \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Griddy \u0432 \u0440\u0430\u0437\u0434\u0435\u043b\u0435 Account > Meter > Load Zone.",
+ "title": "Griddy"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/sl.json b/homeassistant/components/griddy/.translations/sl.json
new file mode 100644
index 00000000000..1adbbe39f38
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/sl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ta obremenitvena cona je \u017ee konfigurirana"
+ },
+ "error": {
+ "cannot_connect": "Povezava ni uspela, poskusite znova",
+ "unknown": "Nepri\u010dakovana napaka"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "Obremenitvena cona (poselitvena to\u010dka)"
+ },
+ "description": "Va\u0161a obremenitvena cona je v va\u0161em ra\u010dunu Griddy pod \"Ra\u010dun > Merilnik > Nalo\u017ei cono.\"",
+ "title": "Nastavite svojo Griddy Load Cono"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/tr.json b/homeassistant/components/griddy/.translations/tr.json
new file mode 100644
index 00000000000..d887b148658
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/tr.json
@@ -0,0 +1,8 @@
+{
+ "config": {
+ "error": {
+ "cannot_connect": "Ba\u011flant\u0131 kurulamad\u0131, l\u00fctfen tekrar deneyin",
+ "unknown": "Beklenmeyen hata"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/griddy/.translations/zh-Hant.json b/homeassistant/components/griddy/.translations/zh-Hant.json
new file mode 100644
index 00000000000..d3918269d13
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/zh-Hant.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64\u8ca0\u8f09\u5340\u57df\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "loadzone": "\u8ca0\u8f09\u5340\u57df\uff08\u5c45\u4f4f\u9ede\uff09"
+ },
+ "description": "\u8ca0\u8f09\u5340\u57df\u986f\u793a\u65bc Griddy \u5e33\u865f\uff0c\u4f4d\u65bc \u201cAccount > Meter > Load Zone\u201d\u3002",
+ "title": "\u8a2d\u5b9a Griddy \u8ca0\u8f09\u5340\u57df"
+ }
+ },
+ "title": "Griddy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py
index 2bd0ce1b09f..08550ed80b8 100644
--- a/homeassistant/components/gtfs/sensor.py
+++ b/homeassistant/components/gtfs/sensor.py
@@ -19,7 +19,11 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.helpers.typing import (
+ ConfigType,
+ DiscoveryInfoType,
+ HomeAssistantType,
+)
from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
@@ -332,7 +336,7 @@ def setup_platform(
hass: HomeAssistantType,
config: ConfigType,
add_entities: Callable[[list], None],
- discovery_info: Optional[dict] = None,
+ discovery_info: Optional[DiscoveryInfoType] = None,
) -> None:
"""Set up the GTFS sensor."""
gtfs_dir = hass.config.path(DEFAULT_PATH)
diff --git a/homeassistant/components/harmony/.translations/ca.json b/homeassistant/components/harmony/.translations/ca.json
new file mode 100644
index 00000000000..f4e77752936
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/ca.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "unknown": "Error inesperat"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Vols configurar {name} ({host})?",
+ "title": "Configuraci\u00f3 de Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP",
+ "name": "Nom del Hub"
+ },
+ "title": "Configuraci\u00f3 de Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "Activitat predeterminada a executar quan no se n\u2019especifica cap.",
+ "delay_secs": "Retard entre l\u2019enviament d\u2019ordres."
+ },
+ "description": "Ajusta les opcions de Harmony Hub"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/de.json b/homeassistant/components/harmony/.translations/de.json
new file mode 100644
index 00000000000..70a5c8707ce
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/de.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "M\u00f6chten Sie {name} ({host}) einrichten?",
+ "title": "Richten Sie den Logitech Harmony Hub ein"
+ },
+ "user": {
+ "data": {
+ "host": "Hostname oder IP-Adresse",
+ "name": "Hub-Name"
+ },
+ "title": "Richten Sie den Logitech Harmony Hub ein"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "Die Standardaktivit\u00e4t, die ausgef\u00fchrt werden soll, wenn keine angegeben ist.",
+ "delay_secs": "Die Verz\u00f6gerung zwischen dem Senden von Befehlen."
+ },
+ "description": "Passen Sie die Harmony Hub-Optionen an"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/en.json b/homeassistant/components/harmony/.translations/en.json
new file mode 100644
index 00000000000..00054dbc51e
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/en.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Do you want to setup {name} ({host})?",
+ "title": "Setup Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Hostname or IP Address",
+ "name": "Hub Name"
+ },
+ "title": "Setup Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "The default activity to execute when none is specified.",
+ "delay_secs": "The delay between sending commands."
+ },
+ "description": "Adjust Harmony Hub Options"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/es.json b/homeassistant/components/harmony/.translations/es.json
new file mode 100644
index 00000000000..300b2e4cb8d
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/es.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo",
+ "unknown": "Error inesperado"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "\u00bfQuiere configurar {name} ({host})?",
+ "title": "Configurar Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Nombre del host o direcci\u00f3n IP",
+ "name": "Nombre del concentrador"
+ },
+ "title": "Configurar Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "La actividad por defecto a ejecutar cuando no se especifica ninguna.",
+ "delay_secs": "El retraso entre el env\u00edo de comandos."
+ },
+ "description": "Ajustar las opciones de Harmony Hub"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/fr.json b/homeassistant/components/harmony/.translations/fr.json
new file mode 100644
index 00000000000..60848bea459
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/fr.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "unknown": "Erreur inattendue"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Voulez-vous configurer {name} ( {host} ) ?",
+ "title": "Configuration de Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Nom d'h\u00f4te ou adresse IP",
+ "name": "Nom du Hub"
+ },
+ "title": "Configuration de Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "Activit\u00e9 par d\u00e9faut \u00e0 ex\u00e9cuter lorsqu'aucune n'est sp\u00e9cifi\u00e9e.",
+ "delay_secs": "Le d\u00e9lai entre l'envoi des commandes."
+ },
+ "description": "Ajuster les options du hub Harmony"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/it.json b/homeassistant/components/harmony/.translations/it.json
new file mode 100644
index 00000000000..4b88151f3d6
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/it.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "unknown": "Errore imprevisto"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Vuoi impostare {name} ({host})?",
+ "title": "Impostazione di Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Nome dell'host o indirizzo IP",
+ "name": "Nome Hub"
+ },
+ "title": "Configurare Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "L'attivit\u00e0 predefinita da eseguire quando nessuna \u00e8 specificata.",
+ "delay_secs": "Il ritardo tra l'invio dei comandi."
+ },
+ "description": "Regolare le opzioni di Harmony Hub"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/ko.json b/homeassistant/components/harmony/.translations/ko.json
new file mode 100644
index 00000000000..392c06390aa
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/ko.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "{name} ({host}) \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Logitech Harmony Hub \uc124\uc815"
+ },
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c",
+ "name": "Hub \uc774\ub984"
+ },
+ "title": "Logitech Harmony Hub \uc124\uc815"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "\uc9c0\uc815\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc2e4\ud589\ud560 \uae30\ubcf8 \uc561\uc158.",
+ "delay_secs": "\uba85\ub839 \uc804\uc1a1 \uc0ac\uc774\uc758 \uc9c0\uc5f0 \uc2dc\uac04."
+ },
+ "description": "Harmony Hub \uc635\uc158 \uc870\uc815"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/lb.json b/homeassistant/components/harmony/.translations/lb.json
new file mode 100644
index 00000000000..6cd2ab7d7bf
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/lb.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Soll {name} ({host}) konfigur\u00e9iert ginn?",
+ "title": "Logitech Harmony Hub ariichten"
+ },
+ "user": {
+ "data": {
+ "host": "Host Numm oder IP Adresse",
+ "name": "Numm vum Hub"
+ },
+ "title": "Logitech Harmony Hub ariichten"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "Standard Aktivit\u00e9it d\u00e9i ausgef\u00e9iert g\u00ebtt wann keng uginn ass.",
+ "delay_secs": "Delai zw\u00ebschen dem versch\u00e9cken vun Kommandoen"
+ },
+ "description": "Harmony Hub Optioune ajust\u00e9ieren"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/no.json b/homeassistant/components/harmony/.translations/no.json
new file mode 100644
index 00000000000..4dd86965bfd
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/no.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "unknown": "Uventet feil"
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Vil du konfigurere {name} ({host})?",
+ "title": "Oppsett Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Vertsnavn eller IP-adresse",
+ "name": "Navn p\u00e5 hub"
+ },
+ "title": "Oppsett Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "Standardaktiviteten som skal utf\u00f8res n\u00e5r ingen er angitt.",
+ "delay_secs": "Forsinkelsen mellom sending av kommandoer."
+ },
+ "description": "Juster alternativene for harmonihub"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/pl.json b/homeassistant/components/harmony/.translations/pl.json
new file mode 100644
index 00000000000..e5ace2e0d1d
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/pl.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane"
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.",
+ "unknown": "Niespodziewany b\u0142\u0105d."
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "Czy chcesz skonfigurowa\u0107 {name} ({host})?",
+ "title": "Konfiguracja Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "Nazwa hosta lub adres IP",
+ "name": "Nazwa huba"
+ },
+ "title": "Konfiguracja Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "Domy\u015blna aktywno\u015b\u0107 do wykonania, gdy \u017cadnej nie okre\u015blono.",
+ "delay_secs": "Op\u00f3\u017anienie mi\u0119dzy wysy\u0142aniem polece\u0144."
+ },
+ "description": "Dostosuj opcje huba Harmony"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/ru.json b/homeassistant/components/harmony/.translations/ru.json
new file mode 100644
index 00000000000..b89296616b3
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/ru.json
@@ -0,0 +1,37 @@
+{
+ "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\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} ({host})?",
+ "title": "Logitech Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "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"
+ },
+ "title": "Logitech Harmony Hub"
+ }
+ },
+ "title": "Logitech Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "\u0410\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u043a\u043e\u0433\u0434\u0430 \u043d\u0438 \u043e\u0434\u043d\u0430 \u0438\u0437 \u043d\u0438\u0445 \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u0430.",
+ "delay_secs": "\u0417\u0430\u0434\u0435\u0440\u0436\u043a\u0430 \u043c\u0435\u0436\u0434\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u043e\u0439 \u043a\u043e\u043c\u0430\u043d\u0434."
+ },
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 Harmony Hub"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/.translations/zh-Hant.json b/homeassistant/components/harmony/.translations/zh-Hant.json
new file mode 100644
index 00000000000..9e523c67290
--- /dev/null
+++ b/homeassistant/components/harmony/.translations/zh-Hant.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "flow_title": "\u7f85\u6280 Harmony Hub {name}",
+ "step": {
+ "link": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} ({host})\uff1f",
+ "title": "\u8a2d\u5b9a\u7f85\u6280 Harmony Hub"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740",
+ "name": "Hub \u540d\u7a31"
+ },
+ "title": "\u8a2d\u5b9a\u7f85\u6280 Harmony Hub"
+ }
+ },
+ "title": "\u7f85\u6280 Harmony Hub"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "activity": "\u7576\u672a\u6307\u5b9a\u6642\u9810\u8a2d\u57f7\u884c\u6d3b\u52d5\u3002",
+ "delay_secs": "\u50b3\u9001\u547d\u4ee4\u9593\u9694\u79d2\u6578\u3002"
+ },
+ "description": "\u8abf\u6574 Harmony Hub \u9078\u9805"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py
index 12ccc78077e..540e39f8f44 100644
--- a/homeassistant/components/harmony/__init__.py
+++ b/homeassistant/components/harmony/__init__.py
@@ -1 +1,104 @@
-"""Support for Harmony devices."""
+"""The Logitech Harmony Hub integration."""
+import asyncio
+import logging
+
+from homeassistant.components.remote import (
+ ATTR_ACTIVITY,
+ ATTR_DELAY_SECS,
+ DEFAULT_DELAY_SECS,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS
+from .remote import HarmonyRemote
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Logitech Harmony Hub component."""
+ hass.data.setdefault(DOMAIN, {})
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Logitech Harmony Hub from a config entry."""
+ # As there currently is no way to import options from yaml
+ # when setting up a config entry, we fallback to adding
+ # the options to the config entry and pull them out here if
+ # they are missing from the options
+ _async_import_options_from_data_if_missing(hass, entry)
+
+ address = entry.data[CONF_HOST]
+ name = entry.data[CONF_NAME]
+ activity = entry.options.get(ATTR_ACTIVITY)
+ delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
+
+ harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf")
+ try:
+ device = HarmonyRemote(
+ name, entry.unique_id, address, activity, harmony_conf_file, delay_secs
+ )
+ connected_ok = await device.connect()
+ except (asyncio.TimeoutError, ValueError, AttributeError):
+ raise ConfigEntryNotReady
+
+ if not connected_ok:
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][entry.entry_id] = device
+
+ entry.add_update_listener(_update_listener)
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+@callback
+def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
+ options = dict(entry.options)
+ modified = 0
+ for importable_option in [ATTR_ACTIVITY, ATTR_DELAY_SECS]:
+ if importable_option not in entry.options and importable_option in entry.data:
+ options[importable_option] = entry.data[importable_option]
+ modified = 1
+
+ if modified:
+ hass.config_entries.async_update_entry(entry, options=options)
+
+
+async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
+ """Handle options update."""
+ async_dispatcher_send(
+ hass, f"{HARMONY_OPTIONS_UPDATE}-{entry.unique_id}", entry.options
+ )
+
+
+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
+ ]
+ )
+ )
+
+ # Shutdown a harmony remote for removal
+ device = hass.data[DOMAIN][entry.entry_id]
+ await device.shutdown()
+
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py
new file mode 100644
index 00000000000..8d43b2d69ca
--- /dev/null
+++ b/homeassistant/components/harmony/config_flow.py
@@ -0,0 +1,199 @@
+"""Config flow for Logitech Harmony Hub integration."""
+import logging
+from urllib.parse import urlparse
+
+import voluptuous as vol
+
+from homeassistant import config_entries, exceptions
+from homeassistant.components import ssdp
+from homeassistant.components.remote import (
+ ATTR_ACTIVITY,
+ ATTR_DELAY_SECS,
+ DEFAULT_DELAY_SECS,
+)
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.core import callback
+
+from .const import DOMAIN, UNIQUE_ID
+from .util import (
+ find_best_name_for_remote,
+ find_unique_id_for_remote,
+ get_harmony_client_if_available,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, extra=vol.ALLOW_EXTRA
+)
+
+
+async def validate_input(data):
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+ harmony = await get_harmony_client_if_available(data[CONF_HOST])
+ if not harmony:
+ raise CannotConnect
+
+ return {
+ CONF_NAME: find_best_name_for_remote(data, harmony),
+ CONF_HOST: data[CONF_HOST],
+ UNIQUE_ID: find_unique_id_for_remote(harmony),
+ }
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Logitech Harmony Hub."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize the Harmony config flow."""
+ self.harmony_config = {}
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+
+ try:
+ validated = await validate_input(user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(validated[UNIQUE_ID])
+ self._abort_if_unique_id_configured()
+ return await self._async_create_entry_from_valid_input(
+ validated, user_input
+ )
+
+ # Return form
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_ssdp(self, discovery_info):
+ """Handle a discovered Harmony device."""
+ _LOGGER.debug("SSDP discovery_info: %s", discovery_info)
+
+ parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION])
+ friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]
+
+ # pylint: disable=no-member
+ self.context["title_placeholders"] = {"name": friendly_name}
+
+ self.harmony_config = {
+ CONF_HOST: parsed_url.hostname,
+ CONF_NAME: friendly_name,
+ }
+
+ harmony = await get_harmony_client_if_available(parsed_url.hostname)
+
+ if harmony:
+ unique_id = find_unique_id_for_remote(harmony)
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: self.harmony_config[CONF_HOST]}
+ )
+ self.harmony_config[UNIQUE_ID] = unique_id
+
+ return await self.async_step_link()
+
+ async def async_step_link(self, user_input=None):
+ """Attempt to link with the Harmony."""
+ errors = {}
+
+ if user_input is not None:
+ # Everything was validated in async_step_ssdp
+ # all we do now is create.
+ return await self._async_create_entry_from_valid_input(
+ self.harmony_config, {}
+ )
+
+ return self.async_show_form(
+ step_id="link",
+ errors=errors,
+ description_placeholders={
+ CONF_HOST: self.harmony_config[CONF_NAME],
+ CONF_NAME: self.harmony_config[CONF_HOST],
+ },
+ )
+
+ async def async_step_import(self, validated_input):
+ """Handle import."""
+ await self.async_set_unique_id(
+ validated_input[UNIQUE_ID], raise_on_progress=False
+ )
+ self._abort_if_unique_id_configured()
+
+ # Everything was validated in remote async_setup_platform
+ # all we do now is create.
+ return await self._async_create_entry_from_valid_input(
+ validated_input, validated_input
+ )
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler(config_entry)
+
+ async def _async_create_entry_from_valid_input(self, validated, user_input):
+ """Single path to create the config entry from validated input."""
+
+ data = {CONF_NAME: validated[CONF_NAME], CONF_HOST: validated[CONF_HOST]}
+ # Options from yaml are preserved, we will pull them out when
+ # we setup the config entry
+ data.update(_options_from_user_input(user_input))
+
+ return self.async_create_entry(title=validated[CONF_NAME], data=data)
+
+
+def _options_from_user_input(user_input):
+ options = {}
+ if ATTR_ACTIVITY in user_input:
+ options[ATTR_ACTIVITY] = user_input[ATTR_ACTIVITY]
+ if ATTR_DELAY_SECS in user_input:
+ options[ATTR_DELAY_SECS] = user_input[ATTR_DELAY_SECS]
+ return options
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a option flow for Harmony."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry):
+ """Initialize options flow."""
+ self.config_entry = config_entry
+
+ async def async_step_init(self, user_input=None):
+ """Handle options flow."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ remote = self.hass.data[DOMAIN][self.config_entry.entry_id]
+
+ data_schema = vol.Schema(
+ {
+ vol.Optional(
+ ATTR_DELAY_SECS,
+ default=self.config_entry.options.get(
+ ATTR_DELAY_SECS, DEFAULT_DELAY_SECS
+ ),
+ ): vol.Coerce(float),
+ vol.Optional(
+ ATTR_ACTIVITY, default=self.config_entry.options.get(ATTR_ACTIVITY),
+ ): vol.In(remote.activity_names),
+ }
+ )
+ return self.async_show_form(step_id="init", data_schema=data_schema)
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py
index 12e71050665..4cd5dce0af5 100644
--- a/homeassistant/components/harmony/const.py
+++ b/homeassistant/components/harmony/const.py
@@ -2,3 +2,7 @@
DOMAIN = "harmony"
SERVICE_SYNC = "sync"
SERVICE_CHANGE_CHANNEL = "change_channel"
+PLATFORMS = ["remote"]
+UNIQUE_ID = "unique_id"
+ACTIVITY_POWER_OFF = "PowerOff"
+HARMONY_OPTIONS_UPDATE = "harmony_options_update"
diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json
index a0e8baa0b58..870e3f15044 100644
--- a/homeassistant/components/harmony/manifest.json
+++ b/homeassistant/components/harmony/manifest.json
@@ -4,5 +4,12 @@
"documentation": "https://www.home-assistant.io/integrations/harmony",
"requirements": ["aioharmony==0.1.13"],
"dependencies": [],
- "codeowners": ["@ehendrix23"]
+ "codeowners": ["@ehendrix23","@bramkragten","@bdraco"],
+ "ssdp": [
+ {
+ "manufacturer": "Logitech",
+ "deviceType": "urn:myharmony-com:device:harmony:1"
+ }
+ ],
+ "config_flow": true
}
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
index 126ce0ff992..1d0ed66415c 100644
--- a/homeassistant/components/harmony/remote.py
+++ b/homeassistant/components/harmony/remote.py
@@ -21,38 +21,45 @@ from homeassistant.components.remote import (
DEFAULT_DELAY_SECS,
PLATFORM_SCHEMA,
)
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
- EVENT_HOMEASSISTANT_STOP,
-)
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
-from homeassistant.util import slugify
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from .const import DOMAIN, SERVICE_CHANGE_CHANNEL, SERVICE_SYNC
+from .const import (
+ ACTIVITY_POWER_OFF,
+ DOMAIN,
+ HARMONY_OPTIONS_UPDATE,
+ SERVICE_CHANGE_CHANNEL,
+ SERVICE_SYNC,
+ UNIQUE_ID,
+)
+from .util import (
+ find_best_name_for_remote,
+ find_matching_config_entries_for_host,
+ find_unique_id_for_remote,
+ get_harmony_client_if_available,
+)
_LOGGER = logging.getLogger(__name__)
ATTR_CHANNEL = "channel"
ATTR_CURRENT_ACTIVITY = "current_activity"
-DEFAULT_PORT = 8088
-DEVICES = []
-CONF_DEVICE_CACHE = "harmony_device_cache"
-
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(ATTR_ACTIVITY): cv.string,
vol.Required(CONF_NAME): cv.string,
vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
- vol.Optional(CONF_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- }
+ vol.Required(CONF_HOST): cv.string,
+ # The client ignores port so lets not confuse the user by pretenting we do anything with this
+ },
+ extra=vol.ALLOW_EXTRA,
)
+
HARMONY_SYNC_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema(
@@ -65,69 +72,73 @@ HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema(
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Harmony platform."""
- activity = None
-
- if CONF_DEVICE_CACHE not in hass.data:
- hass.data[CONF_DEVICE_CACHE] = []
if discovery_info:
- # Find the discovered device in the list of user configurations
- override = next(
- (
- c
- for c in hass.data[CONF_DEVICE_CACHE]
- if c.get(CONF_NAME) == discovery_info.get(CONF_NAME)
- ),
- None,
- )
-
- port = DEFAULT_PORT
- delay_secs = DEFAULT_DELAY_SECS
- if override is not None:
- activity = override.get(ATTR_ACTIVITY)
- delay_secs = override.get(ATTR_DELAY_SECS)
- port = override.get(CONF_PORT, DEFAULT_PORT)
-
- host = (discovery_info.get(CONF_NAME), discovery_info.get(CONF_HOST), port)
-
- # Ignore hub name when checking if this hub is known - ip and port only
- if host[1:] in ((h.host, h.port) for h in DEVICES):
- _LOGGER.debug("Discovered host already known: %s", host)
- return
- elif CONF_HOST in config:
- host = (config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT))
- activity = config.get(ATTR_ACTIVITY)
- delay_secs = config.get(ATTR_DELAY_SECS)
- else:
- hass.data[CONF_DEVICE_CACHE].append(config)
+ # Now handled by ssdp in the config flow
return
- name, address, port = host
- _LOGGER.info(
- "Loading Harmony Platform: %s at %s:%s, startup activity: %s",
- name,
- address,
- port,
- activity,
+ if find_matching_config_entries_for_host(hass, config[CONF_HOST]):
+ return
+
+ # We do the validation to verify we can connect
+ # so we can raise PlatformNotReady to force
+ # a retry so we can avoid a scenario where the config
+ # entry cannot be created via import because hub
+ # is not yet ready.
+ harmony = await get_harmony_client_if_available(config[CONF_HOST])
+ if not harmony:
+ raise PlatformNotReady
+
+ validated_config = config.copy()
+ validated_config[UNIQUE_ID] = find_unique_id_for_remote(harmony)
+ validated_config[CONF_NAME] = find_best_name_for_remote(config, harmony)
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=validated_config
+ )
)
- harmony_conf_file = hass.config.path(f"harmony_{slugify(name)}.conf")
- try:
- device = HarmonyRemote(
- name, address, port, activity, harmony_conf_file, delay_secs
- )
- if not await device.connect():
- raise PlatformNotReady
- DEVICES.append(device)
- async_add_entities([device])
- register_services(hass)
- except (ValueError, AttributeError):
- raise PlatformNotReady
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities
+):
+ """Set up the Harmony config entry."""
+
+ device = hass.data[DOMAIN][entry.entry_id]
+
+ _LOGGER.debug("Harmony Remote: %s", device)
+
+ async_add_entities([device])
+ register_services(hass)
def register_services(hass):
"""Register all services for harmony devices."""
+
+ async def _apply_service(service, service_func, *service_func_args):
+ """Handle services to apply."""
+ entity_ids = service.data.get("entity_id")
+
+ want_devices = [
+ hass.data[DOMAIN][config_entry_id] for config_entry_id in hass.data[DOMAIN]
+ ]
+
+ if entity_ids:
+ want_devices = [
+ device for device in want_devices if device.entity_id in entity_ids
+ ]
+
+ for device in want_devices:
+ await service_func(device, *service_func_args)
+
+ async def _sync_service(service):
+ await _apply_service(service, HarmonyRemote.sync)
+
+ async def _change_channel_service(service):
+ channel = service.data.get(ATTR_CHANNEL)
+ await _apply_service(service, HarmonyRemote.change_channel, channel)
+
hass.services.async_register(
DOMAIN, SERVICE_SYNC, _sync_service, schema=HARMONY_SYNC_SCHEMA
)
@@ -140,43 +151,48 @@ def register_services(hass):
)
-async def _apply_service(service, service_func, *service_func_args):
- """Handle services to apply."""
- entity_ids = service.data.get("entity_id")
-
- if entity_ids:
- _devices = [device for device in DEVICES if device.entity_id in entity_ids]
- else:
- _devices = DEVICES
-
- for device in _devices:
- await service_func(device, *service_func_args)
-
-
-async def _sync_service(service):
- await _apply_service(service, HarmonyRemote.sync)
-
-
-async def _change_channel_service(service):
- channel = service.data.get(ATTR_CHANNEL)
- await _apply_service(service, HarmonyRemote.change_channel, channel)
-
-
class HarmonyRemote(remote.RemoteDevice):
"""Remote representation used to control a Harmony device."""
- def __init__(self, name, host, port, activity, out_path, delay_secs):
+ def __init__(self, name, unique_id, host, activity, out_path, delay_secs):
"""Initialize HarmonyRemote class."""
self._name = name
self.host = host
- self.port = port
self._state = None
self._current_activity = None
- self._default_activity = activity
+ self.default_activity = activity
self._client = HarmonyClient(ip_address=host)
self._config_path = out_path
- self._delay_secs = delay_secs
+ self.delay_secs = delay_secs
self._available = False
+ self._unique_id = unique_id
+ self._undo_dispatch_subscription = None
+
+ @property
+ def activity_names(self):
+ """Names of all the remotes activities."""
+ activities = [activity["label"] for activity in self._client.config["activity"]]
+
+ # Remove both ways of representing PowerOff
+ if None in activities:
+ activities.remove(None)
+ if ACTIVITY_POWER_OFF in activities:
+ activities.remove(ACTIVITY_POWER_OFF)
+
+ return activities
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ if self._undo_dispatch_subscription:
+ self._undo_dispatch_subscription()
+
+ async def _async_update_options(self, data):
+ """Change options when the options flow does."""
+ if ATTR_DELAY_SECS in data:
+ self.delay_secs = data[ATTR_DELAY_SECS]
+
+ if ATTR_ACTIVITY in data:
+ self.default_activity = data[ATTR_ACTIVITY]
async def async_added_to_hass(self):
"""Complete the initialization."""
@@ -189,19 +205,44 @@ class HarmonyRemote(remote.RemoteDevice):
disconnect=self.got_disconnected,
)
+ self._undo_dispatch_subscription = async_dispatcher_connect(
+ self.hass,
+ f"{HARMONY_OPTIONS_UPDATE}-{self.unique_id}",
+ self._async_update_options,
+ )
+
# Store Harmony HUB config, this will also update our current
# activity
await self.new_config()
- async def shutdown(_):
- """Close connection on shutdown."""
- _LOGGER.debug("%s: Closing Harmony Hub", self._name)
- try:
- await self._client.close()
- except aioexc.TimeOut:
- _LOGGER.warning("%s: Disconnect timed-out", self._name)
+ async def shutdown(self):
+ """Close connection on shutdown."""
+ _LOGGER.debug("%s: Closing Harmony Hub", self._name)
+ try:
+ await self._client.close()
+ except aioexc.TimeOut:
+ _LOGGER.warning("%s: Disconnect timed-out", self._name)
- self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+ @property
+ def device_info(self):
+ """Return device info."""
+ model = "Harmony Hub"
+ if "ethernetStatus" in self._client.hub_config.info:
+ model = "Harmony Hub Pro 2400"
+ return {
+ "identifiers": {(DOMAIN, self.unique_id)},
+ "manufacturer": "Logitech",
+ "sw_version": self._client.hub_config.info.get(
+ "hubSwVersion", self._client.fw_version
+ ),
+ "name": self.name,
+ "model": model,
+ }
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
@property
def name(self):
@@ -239,7 +280,6 @@ class HarmonyRemote(remote.RemoteDevice):
except aioexc.TimeOut:
_LOGGER.warning("%s: Connection timed-out", self._name)
return False
-
return True
def new_activity(self, activity_info: tuple) -> None:
@@ -280,7 +320,7 @@ class HarmonyRemote(remote.RemoteDevice):
"""Start an activity from the Harmony device."""
_LOGGER.debug("%s: Turn On", self.name)
- activity = kwargs.get(ATTR_ACTIVITY, self._default_activity)
+ activity = kwargs.get(ATTR_ACTIVITY, self.default_activity)
if activity:
activity_id = None
@@ -337,7 +377,7 @@ class HarmonyRemote(remote.RemoteDevice):
return
num_repeats = kwargs[ATTR_NUM_REPEATS]
- delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs)
+ delay_secs = kwargs.get(ATTR_DELAY_SECS, self.delay_secs)
hold_secs = kwargs[ATTR_HOLD_SECS]
_LOGGER.debug(
"Sending commands to device %s holding for %s seconds "
diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json
new file mode 100644
index 00000000000..8af5a5ada1a
--- /dev/null
+++ b/homeassistant/components/harmony/strings.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "title": "Logitech Harmony Hub",
+ "flow_title": "Logitech Harmony Hub {name}",
+ "step": {
+ "user": {
+ "title": "Setup Logitech Harmony Hub",
+ "data": {
+ "host": "Hostname or IP Address",
+ "name": "Hub Name"
+ }
+ },
+ "link": {
+ "title": "Setup Logitech Harmony Hub",
+ "description": "Do you want to setup {name} ({host})?"
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "Device is already configured"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "Adjust Harmony Hub Options",
+ "data": {
+ "activity": "The default activity to execute when none is specified.",
+ "delay_secs": "The delay between sending commands."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/harmony/util.py b/homeassistant/components/harmony/util.py
new file mode 100644
index 00000000000..412aa2c6940
--- /dev/null
+++ b/homeassistant/components/harmony/util.py
@@ -0,0 +1,52 @@
+"""The Logitech Harmony Hub integration utils."""
+import aioharmony.exceptions as harmony_exceptions
+from aioharmony.harmonyapi import HarmonyAPI
+
+from homeassistant.const import CONF_HOST, CONF_NAME
+
+from .const import DOMAIN
+
+
+def find_unique_id_for_remote(harmony: HarmonyAPI):
+ """Find the unique id for both websocket and xmpp clients."""
+ websocket_unique_id = harmony.hub_config.info.get("activeRemoteId")
+ if websocket_unique_id is not None:
+ return str(websocket_unique_id)
+
+ # fallback to the xmpp unique id if websocket is not available
+ return harmony.config["global"]["timeStampHash"].split(";")[-1]
+
+
+def find_best_name_for_remote(data: dict, harmony: HarmonyAPI):
+ """Find the best name from config or fallback to the remote."""
+ # As a last resort we get the name from the harmony client
+ # in the event a name was not provided. harmony.name is
+ # usually the ip address but it can be an empty string.
+ if CONF_NAME not in data or data[CONF_NAME] is None or data[CONF_NAME] == "":
+ return harmony.name
+
+ return data[CONF_NAME]
+
+
+async def get_harmony_client_if_available(ip_address: str):
+ """Connect to a harmony hub and fetch info."""
+ harmony = HarmonyAPI(ip_address=ip_address)
+
+ try:
+ if not await harmony.connect():
+ await harmony.close()
+ return None
+ except harmony_exceptions.TimeOut:
+ return None
+
+ await harmony.close()
+
+ return harmony
+
+
+def find_matching_config_entries_for_host(hass, host):
+ """Search existing config entries for one matching the host."""
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ if entry.data[CONF_HOST] == host:
+ return entry
+ return None
diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py
index e471bfae543..bb41e5335d7 100644
--- a/homeassistant/components/hassio/handler.py
+++ b/homeassistant/components/hassio/handler.py
@@ -10,6 +10,7 @@ from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
+ DEFAULT_SERVER_HOST,
)
from homeassistant.const import SERVER_PORT
@@ -133,9 +134,14 @@ class HassIO:
"refresh_token": refresh_token.token,
}
- if CONF_SERVER_HOST in http_config:
+ if (
+ http_config.get(CONF_SERVER_HOST, DEFAULT_SERVER_HOST)
+ != DEFAULT_SERVER_HOST
+ ):
options["watchdog"] = False
- _LOGGER.warning("Don't use 'server_host' options with Hass.io")
+ _LOGGER.warning(
+ "Found incompatible HTTP option 'server_host'. Watchdog feature disabled"
+ )
return await self.send_command("/homeassistant/options", payload=options)
diff --git a/homeassistant/components/heos/.translations/ko.json b/homeassistant/components/heos/.translations/ko.json
index 9237800bf48..e1cecbe35d9 100644
--- a/homeassistant/components/heos/.translations/ko.json
+++ b/homeassistant/components/heos/.translations/ko.json
@@ -13,7 +13,7 @@
"host": "\ud638\uc2a4\ud2b8"
},
"description": "Heos \uae30\uae30\uc758 \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. (\uc720\uc120 \ub124\ud2b8\uc6cc\ud06c\ub85c \uc5f0\uacb0\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4)",
- "title": "Heos \uc5f0\uacb0"
+ "title": "Heos \uc5d0 \uc5f0\uacb0\ud558\uae30"
}
},
"title": "HEOS"
diff --git a/homeassistant/components/heos/.translations/no.json b/homeassistant/components/heos/.translations/no.json
index d41051b6674..b54b5520943 100644
--- a/homeassistant/components/heos/.translations/no.json
+++ b/homeassistant/components/heos/.translations/no.json
@@ -16,6 +16,6 @@
"title": "Koble til Heos"
}
},
- "title": "HEOS"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/pl.json b/homeassistant/components/heos/.translations/pl.json
index d427acc3a98..e494a6b34df 100644
--- a/homeassistant/components/heos/.translations/pl.json
+++ b/homeassistant/components/heos/.translations/pl.json
@@ -13,7 +13,7 @@
"host": "Host"
},
"description": "Wprowad\u017a nazw\u0119 hosta lub adres IP urz\u0105dzenia Heos (najlepiej pod\u0142\u0105czonego przewodowo do sieci).",
- "title": "Po\u0142\u0105cz si\u0119 z Heos"
+ "title": "Po\u0142\u0105czenie z Heos"
}
},
"title": "Heos"
diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py
index 4c7652484d6..c88aeb8e5a0 100644
--- a/homeassistant/components/here_travel_time/sensor.py
+++ b/homeassistant/components/here_travel_time/sensor.py
@@ -1,5 +1,5 @@
"""Support for HERE travel time sensors."""
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
from typing import Callable, Dict, Optional, Union
@@ -24,6 +24,8 @@ from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import location
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import DiscoveryInfoType
+import homeassistant.util.dt as dt
_LOGGER = logging.getLogger(__name__)
@@ -36,6 +38,8 @@ CONF_ORIGIN_ENTITY_ID = "origin_entity_id"
CONF_API_KEY = "api_key"
CONF_TRAFFIC_MODE = "traffic_mode"
CONF_ROUTE_MODE = "route_mode"
+CONF_ARRIVAL = "arrival"
+CONF_DEPARTURE = "departure"
DEFAULT_NAME = "HERE Travel Time"
@@ -90,32 +94,49 @@ SCAN_INTERVAL = timedelta(minutes=5)
NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input"
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Inclusive(
+ CONF_DESTINATION_LATITUDE, "destination_coordinates"
+ ): cv.latitude,
+ vol.Inclusive(
+ CONF_DESTINATION_LONGITUDE, "destination_coordinates"
+ ): cv.longitude,
+ vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude,
+ vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id,
+ vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude,
+ vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude,
+ vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude,
+ vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id,
+ vol.Optional(CONF_DEPARTURE): cv.time,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE),
+ vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In(ROUTE_MODE),
+ vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean,
+ vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS),
+ }
+)
+
PLATFORM_SCHEMA = vol.All(
cv.has_at_least_one_key(CONF_DESTINATION_LATITUDE, CONF_DESTINATION_ENTITY_ID),
cv.has_at_least_one_key(CONF_ORIGIN_LATITUDE, CONF_ORIGIN_ENTITY_ID),
- PLATFORM_SCHEMA.extend(
+ cv.key_value_schemas(
+ CONF_MODE,
{
- vol.Required(CONF_API_KEY): cv.string,
- vol.Inclusive(
- CONF_DESTINATION_LATITUDE, "destination_coordinates"
- ): cv.latitude,
- vol.Inclusive(
- CONF_DESTINATION_LONGITUDE, "destination_coordinates"
- ): cv.longitude,
- vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude,
- vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id,
- vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude,
- vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude,
- vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude,
- vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE),
- vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In(
- ROUTE_MODE
+ None: PLATFORM_SCHEMA,
+ TRAVEL_MODE_BICYCLE: PLATFORM_SCHEMA,
+ TRAVEL_MODE_CAR: PLATFORM_SCHEMA,
+ TRAVEL_MODE_PEDESTRIAN: PLATFORM_SCHEMA,
+ TRAVEL_MODE_PUBLIC: PLATFORM_SCHEMA,
+ TRAVEL_MODE_TRUCK: PLATFORM_SCHEMA,
+ TRAVEL_MODE_PUBLIC_TIME_TABLE: PLATFORM_SCHEMA.extend(
+ {
+ vol.Exclusive(CONF_ARRIVAL, "arrival_departure"): cv.time,
+ vol.Exclusive(CONF_DEPARTURE, "arrival_departure"): cv.time,
+ }
),
- vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean,
- vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS),
- }
+ },
),
)
@@ -124,7 +145,7 @@ async def async_setup_platform(
hass: HomeAssistant,
config: Dict[str, Union[str, bool]],
async_add_entities: Callable,
- discovery_info: None = None,
+ discovery_info: Optional[DiscoveryInfoType] = None,
) -> None:
"""Set up the HERE travel time platform."""
@@ -160,9 +181,11 @@ async def async_setup_platform(
route_mode = config[CONF_ROUTE_MODE]
name = config[CONF_NAME]
units = config.get(CONF_UNIT_SYSTEM, hass.config.units.name)
+ arrival = config.get(CONF_ARRIVAL)
+ departure = config.get(CONF_DEPARTURE)
here_data = HERETravelTimeData(
- here_client, travel_mode, traffic_mode, route_mode, units
+ here_client, travel_mode, traffic_mode, route_mode, units, arrival, departure
)
sensor = HERETravelTimeSensor(
@@ -361,6 +384,8 @@ class HERETravelTimeData:
traffic_mode: bool,
route_mode: str,
units: str,
+ arrival: datetime,
+ departure: datetime,
) -> None:
"""Initialize herepy."""
self.origin = None
@@ -368,6 +393,8 @@ class HERETravelTimeData:
self.travel_mode = travel_mode
self.traffic_mode = traffic_mode
self.route_mode = route_mode
+ self.arrival = arrival
+ self.departure = departure
self.attribution = None
self.traffic_time = None
self.distance = None
@@ -377,6 +404,7 @@ class HERETravelTimeData:
self.destination_name = None
self.units = units
self._client = here_client
+ self.combine_change = True
def update(self) -> None:
"""Get the latest data from HERE."""
@@ -389,24 +417,36 @@ class HERETravelTimeData:
# Convert location to HERE friendly location
destination = self.destination.split(",")
origin = self.origin.split(",")
+ arrival = self.arrival
+ if arrival is not None:
+ arrival = convert_time_to_isodate(arrival)
+ departure = self.departure
+ if departure is not None:
+ departure = convert_time_to_isodate(departure)
_LOGGER.debug(
- "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s",
+ "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s, arrival: %s, departure: %s",
origin,
destination,
herepy.RouteMode[self.route_mode],
herepy.RouteMode[self.travel_mode],
herepy.RouteMode[traffic_mode],
+ arrival,
+ departure,
)
+
try:
- response = self._client.car_route(
+ response = self._client.public_transport_timetable(
origin,
destination,
+ self.combine_change,
[
herepy.RouteMode[self.route_mode],
herepy.RouteMode[self.travel_mode],
herepy.RouteMode[traffic_mode],
],
+ arrival=arrival,
+ departure=departure,
)
except herepy.NoRouteFoundError:
# Better error message for cryptic no route error codes
@@ -453,3 +493,11 @@ class HERETravelTimeData:
joined_supplier_titles = ",".join(supplier_titles)
attribution = f"With the support of {joined_supplier_titles}. All information is provided without warranty of any kind."
return attribution
+
+
+def convert_time_to_isodate(timestr: str) -> str:
+ """Take a string like 08:00:00 and combine it with the current date."""
+ combined = datetime.combine(dt.start_of_local_day(), dt.parse_time(timestr))
+ if combined < datetime.now():
+ combined = combined + timedelta(days=1)
+ return combined.isoformat()
diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py
index 7fcbf519bf3..7540740a737 100644
--- a/homeassistant/components/history/__init__.py
+++ b/homeassistant/components/history/__init__.py
@@ -39,7 +39,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-SIGNIFICANT_DOMAINS = ("thermostat", "climate", "water_heater")
+SIGNIFICANT_DOMAINS = ("climate", "device_tracker", "thermostat", "water_heater")
IGNORE_DOMAINS = ("zone", "scene")
@@ -50,6 +50,7 @@ def get_significant_states(
entity_ids=None,
filters=None,
include_start_time_state=True,
+ significant_changes_only=True,
):
"""
Return states changes during UTC period start_time - end_time.
@@ -61,13 +62,16 @@ def get_significant_states(
timer_start = time.perf_counter()
with session_scope(hass=hass) as session:
- query = session.query(States).filter(
- (
- States.domain.in_(SIGNIFICANT_DOMAINS)
- | (States.last_changed == States.last_updated)
+ if significant_changes_only:
+ query = session.query(States).filter(
+ (
+ States.domain.in_(SIGNIFICANT_DOMAINS)
+ | (States.last_changed == States.last_updated)
+ )
+ & (States.last_updated > start_time)
)
- & (States.last_updated > start_time)
- )
+ else:
+ query = session.query(States).filter(States.last_updated > start_time)
if filters:
query = filters.apply(query, entity_ids)
@@ -327,6 +331,9 @@ class HistoryPeriodView(HomeAssistantView):
if entity_ids:
entity_ids = entity_ids.lower().split(",")
include_start_time_state = "skip_initial_state" not in request.query
+ significant_changes_only = (
+ request.query.get("significant_changes_only", "1") != "0"
+ )
hass = request.app["hass"]
@@ -338,6 +345,7 @@ class HistoryPeriodView(HomeAssistantView):
entity_ids,
self.filters,
include_start_time_state,
+ significant_changes_only,
)
result = list(result.values())
if _LOGGER.isEnabledFor(logging.DEBUG):
diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py
index 82ec296da4b..c0f0abe8177 100644
--- a/homeassistant/components/homekit/const.py
+++ b/homeassistant/components/homekit/const.py
@@ -1,10 +1,12 @@
"""Constants used be the HomeKit component."""
# #### Misc ####
DEBOUNCE_TIMEOUT = 0.5
+DEVICE_PRECISION_LEEWAY = 6
DOMAIN = "homekit"
HOMEKIT_FILE = ".homekit.state"
HOMEKIT_NOTIFY_ID = 4663548
+
# #### Attributes ####
ATTR_DISPLAY_NAME = "display_name"
ATTR_VALUE = "value"
@@ -106,6 +108,7 @@ CHAR_CURRENT_POSITION = "CurrentPosition"
CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity"
CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState"
CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
+CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
@@ -139,8 +142,10 @@ CHAR_SWING_MODE = "SwingMode"
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition"
+CHAR_TARGET_HUMIDITY = "TargetRelativeHumidity"
CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState"
CHAR_TARGET_TEMPERATURE = "TargetTemperature"
+CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle"
CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits"
CHAR_VALVE_TYPE = "ValveType"
CHAR_VOLUME = "Volume"
diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json
index bbbc6561a87..b0c49a58a6a 100644
--- a/homeassistant/components/homekit/manifest.json
+++ b/homeassistant/components/homekit/manifest.json
@@ -2,7 +2,6 @@
"domain": "homekit",
"name": "HomeKit",
"documentation": "https://www.home-assistant.io/integrations/homekit",
- "requirements": ["HAP-python==2.7.0"],
- "dependencies": [],
+ "requirements": ["HAP-python==2.8.1"],
"codeowners": []
}
diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py
index d77ea22dc96..97940952171 100644
--- a/homeassistant/components/homekit/type_covers.py
+++ b/homeassistant/components/homekit/type_covers.py
@@ -5,8 +5,11 @@ from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
+ ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
+ ATTR_TILT_POSITION,
DOMAIN,
+ SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
)
from homeassistant.const import (
@@ -15,6 +18,7 @@ from homeassistant.const import (
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
+ SERVICE_SET_COVER_TILT_POSITION,
SERVICE_STOP_COVER,
STATE_CLOSED,
STATE_CLOSING,
@@ -27,9 +31,12 @@ from .accessories import HomeAccessory, debounce
from .const import (
CHAR_CURRENT_DOOR_STATE,
CHAR_CURRENT_POSITION,
+ CHAR_CURRENT_TILT_ANGLE,
CHAR_POSITION_STATE,
CHAR_TARGET_DOOR_STATE,
CHAR_TARGET_POSITION,
+ CHAR_TARGET_TILT_ANGLE,
+ DEVICE_PRECISION_LEEWAY,
SERV_GARAGE_DOOR_OPENER,
SERV_WINDOW_COVERING,
)
@@ -94,9 +101,28 @@ class WindowCovering(HomeAccessory):
def __init__(self, *args):
"""Initialize a WindowCovering accessory object."""
super().__init__(*args, category=CATEGORY_WINDOW_COVERING)
- self._homekit_target = None
- serv_cover = self.add_preload_service(SERV_WINDOW_COVERING)
+ self._homekit_target = None
+ self._homekit_target_tilt = None
+
+ serv_cover = self.add_preload_service(
+ SERV_WINDOW_COVERING,
+ chars=[CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE],
+ )
+
+ features = self.hass.states.get(self.entity_id).attributes.get(
+ ATTR_SUPPORTED_FEATURES, 0
+ )
+
+ self._supports_tilt = features & SUPPORT_SET_TILT_POSITION
+ if self._supports_tilt:
+ self.char_target_tilt = serv_cover.configure_char(
+ CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tilt
+ )
+ self.char_current_tilt = serv_cover.configure_char(
+ CHAR_CURRENT_TILT_ANGLE, value=0
+ )
+
self.char_current_position = serv_cover.configure_char(
CHAR_CURRENT_POSITION, value=0
)
@@ -107,6 +133,20 @@ class WindowCovering(HomeAccessory):
CHAR_POSITION_STATE, value=2
)
+ @debounce
+ def set_tilt(self, value):
+ """Set tilt to value if call came from HomeKit."""
+ self._homekit_target_tilt = value
+ _LOGGER.info("%s: Set tilt to %d", self.entity_id, value)
+
+ # HomeKit sends values between -90 and 90.
+ # We'll have to normalize to [0,100]
+ value = round((value + 90) / 180.0 * 100.0)
+
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value}
+
+ self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value)
+
@debounce
def move_cover(self, value):
"""Move cover to value if call came from HomeKit."""
@@ -117,14 +157,20 @@ class WindowCovering(HomeAccessory):
self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value)
def update_state(self, new_state):
- """Update cover position after state changed."""
+ """Update cover position and tilt after state changed."""
current_position = new_state.attributes.get(ATTR_CURRENT_POSITION)
if isinstance(current_position, (float, int)):
current_position = int(current_position)
self.char_current_position.set_value(current_position)
+
+ # We have to assume that the device has worse precision than HomeKit.
+ # If it reports back a state that is only _close_ to HK's requested
+ # state, we'll "fix" what HomeKit requested so that it won't appear
+ # out of sync.
if (
self._homekit_target is None
- or abs(current_position - self._homekit_target) < 6
+ or abs(current_position - self._homekit_target)
+ < DEVICE_PRECISION_LEEWAY
):
self.char_target_position.set_value(current_position)
self._homekit_target = None
@@ -135,6 +181,25 @@ class WindowCovering(HomeAccessory):
else:
self.char_position_state.set_value(2)
+ # update tilt
+ current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
+ if isinstance(current_tilt, (float, int)):
+ # HomeKit sends values between -90 and 90.
+ # We'll have to normalize to [0,100]
+ current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
+ current_tilt = int(current_tilt)
+ self.char_current_tilt.set_value(current_tilt)
+
+ # We have to assume that the device has worse precision than HomeKit.
+ # If it reports back a state that is only _close_ to HK's requested
+ # state, we'll "fix" what HomeKit requested so that it won't appear
+ # out of sync.
+ if self._homekit_target_tilt is None or abs(
+ current_tilt - self._homekit_target_tilt < DEVICE_PRECISION_LEEWAY
+ ):
+ self.char_target_tilt.set_value(current_tilt)
+ self._homekit_target_tilt = None
+
@TYPES.register("WindowCoveringBasic")
class WindowCoveringBasic(HomeAccessory):
diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py
index 734568606b2..e38af1a04eb 100644
--- a/homeassistant/components/homekit/type_lights.py
+++ b/homeassistant/components/homekit/type_lights.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from . import TYPES
-from .accessories import HomeAccessory, debounce
+from .accessories import HomeAccessory
from .const import (
CHAR_BRIGHTNESS,
CHAR_COLOR_TEMPERATURE,
@@ -52,15 +52,6 @@ class Light(HomeAccessory):
def __init__(self, *args):
"""Initialize a new Light accessory object."""
super().__init__(*args, category=CATEGORY_LIGHTBULB)
- self._flag = {
- CHAR_ON: False,
- CHAR_BRIGHTNESS: False,
- CHAR_HUE: False,
- CHAR_SATURATION: False,
- CHAR_COLOR_TEMPERATURE: False,
- RGB_COLOR: False,
- }
- self._state = 0
self.chars = []
self._features = self.hass.states.get(self.entity_id).attributes.get(
@@ -82,17 +73,14 @@ class Light(HomeAccessory):
self.chars.append(CHAR_COLOR_TEMPERATURE)
serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars)
- self.char_on = serv_light.configure_char(
- CHAR_ON, value=self._state, setter_callback=self.set_state
- )
+
+ self.char_on = serv_light.configure_char(CHAR_ON, value=0)
if CHAR_BRIGHTNESS in self.chars:
# Initial value is set to 100 because 0 is a special value (off). 100 is
# an arbitrary non-zero value. It is updated immediately by update_state
# to set to the correct initial value.
- self.char_brightness = serv_light.configure_char(
- CHAR_BRIGHTNESS, value=100, setter_callback=self.set_brightness
- )
+ self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100)
if CHAR_COLOR_TEMPERATURE in self.chars:
min_mireds = self.hass.states.get(self.entity_id).attributes.get(
@@ -105,133 +93,94 @@ class Light(HomeAccessory):
CHAR_COLOR_TEMPERATURE,
value=min_mireds,
properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds},
- setter_callback=self.set_color_temperature,
)
if CHAR_HUE in self.chars:
- self.char_hue = serv_light.configure_char(
- CHAR_HUE, value=0, setter_callback=self.set_hue
- )
+ self.char_hue = serv_light.configure_char(CHAR_HUE, value=0)
if CHAR_SATURATION in self.chars:
- self.char_saturation = serv_light.configure_char(
- CHAR_SATURATION, value=75, setter_callback=self.set_saturation
- )
+ self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75)
- def set_state(self, value):
- """Set state if call came from HomeKit."""
- if self._state == value:
- return
+ serv_light.setter_callback = self._set_chars
- _LOGGER.debug("%s: Set state to %d", self.entity_id, value)
- self._flag[CHAR_ON] = True
+ def _set_chars(self, char_values):
+ _LOGGER.debug("_set_chars: %s", char_values)
+ events = []
+ service = SERVICE_TURN_ON
params = {ATTR_ENTITY_ID: self.entity_id}
- service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF
- self.call_service(DOMAIN, service, params)
+ if CHAR_ON in char_values:
+ if not char_values[CHAR_ON]:
+ service = SERVICE_TURN_OFF
+ events.append(f"Set state to {char_values[CHAR_ON]}")
- @debounce
- def set_brightness(self, value):
- """Set brightness if call came from HomeKit."""
- _LOGGER.debug("%s: Set brightness to %d", self.entity_id, value)
- self._flag[CHAR_BRIGHTNESS] = True
- if value == 0:
- self.set_state(0) # Turn off light
- return
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value}
- self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"brightness at {value}%")
+ if CHAR_BRIGHTNESS in char_values:
+ if char_values[CHAR_BRIGHTNESS] == 0:
+ events[-1] = f"Set state to 0"
+ service = SERVICE_TURN_OFF
+ else:
+ params[ATTR_BRIGHTNESS_PCT] = char_values[CHAR_BRIGHTNESS]
+ events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%")
- def set_color_temperature(self, value):
- """Set color temperature if call came from HomeKit."""
- _LOGGER.debug("%s: Set color temp to %s", self.entity_id, value)
- self._flag[CHAR_COLOR_TEMPERATURE] = True
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value}
- self.call_service(
- DOMAIN, SERVICE_TURN_ON, params, f"color temperature at {value}"
- )
+ if CHAR_COLOR_TEMPERATURE in char_values:
+ params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE]
+ events.append(f"color temperature at {char_values[CHAR_COLOR_TEMPERATURE]}")
- def set_saturation(self, value):
- """Set saturation if call came from HomeKit."""
- _LOGGER.debug("%s: Set saturation to %d", self.entity_id, value)
- self._flag[CHAR_SATURATION] = True
- self._saturation = value
- self.set_color()
-
- def set_hue(self, value):
- """Set hue if call came from HomeKit."""
- _LOGGER.debug("%s: Set hue to %d", self.entity_id, value)
- self._flag[CHAR_HUE] = True
- self._hue = value
- self.set_color()
-
- def set_color(self):
- """Set color if call came from HomeKit."""
if (
self._features & SUPPORT_COLOR
- and self._flag[CHAR_HUE]
- and self._flag[CHAR_SATURATION]
+ and CHAR_HUE in char_values
+ and CHAR_SATURATION in char_values
):
- color = (self._hue, self._saturation)
+ color = (char_values[CHAR_HUE], char_values[CHAR_SATURATION])
_LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color)
- self._flag.update(
- {CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True}
- )
- params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color}
- self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"set color at {color}")
+ params[ATTR_HS_COLOR] = color
+ events.append(f"set color at {color}")
+
+ self.call_service(DOMAIN, service, params, ", ".join(events))
def update_state(self, new_state):
"""Update light after state change."""
# Handle State
state = new_state.state
- if state in (STATE_ON, STATE_OFF):
- self._state = 1 if state == STATE_ON else 0
- if not self._flag[CHAR_ON] and self.char_on.value != self._state:
- self.char_on.set_value(self._state)
- self._flag[CHAR_ON] = False
+ if state == STATE_ON and self.char_on.value != 1:
+ self.char_on.set_value(1)
+ elif state == STATE_OFF and self.char_on.value != 0:
+ self.char_on.set_value(0)
# Handle Brightness
if CHAR_BRIGHTNESS in self.chars:
brightness = new_state.attributes.get(ATTR_BRIGHTNESS)
- if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int):
+ if isinstance(brightness, (int, float)):
brightness = round(brightness / 255 * 100, 0)
+ # The homeassistant component might report its brightness as 0 but is
+ # not off. But 0 is a special value in homekit. When you turn on a
+ # homekit accessory it will try to restore the last brightness state
+ # which will be the last value saved by char_brightness.set_value.
+ # But if it is set to 0, HomeKit will update the brightness to 100 as
+ # it thinks 0 is off.
+ #
+ # 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 and state == STATE_ON:
+ brightness = 1
if self.char_brightness.value != brightness:
- # The homeassistant component might report its brightness as 0 but is
- # not off. But 0 is a special value in homekit. When you turn on a
- # homekit accessory it will try to restore the last brightness state
- # which will be the last value saved by char_brightness.set_value.
- # But if it is set to 0, HomeKit will update the brightness to 100 as
- # it thinks 0 is off.
- #
- # 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:
- if state == STATE_ON:
- self.char_brightness.set_value(1)
- else:
- self.char_brightness.set_value(brightness)
- self._flag[CHAR_BRIGHTNESS] = False
+ self.char_brightness.set_value(brightness)
# Handle color temperature
if CHAR_COLOR_TEMPERATURE in self.chars:
color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP)
- if (
- not self._flag[CHAR_COLOR_TEMPERATURE]
- and isinstance(color_temperature, int)
- and self.char_color_temperature.value != color_temperature
- ):
- self.char_color_temperature.set_value(color_temperature)
- self._flag[CHAR_COLOR_TEMPERATURE] = False
+ if isinstance(color_temperature, (int, float)):
+ color_temperature = round(color_temperature, 0)
+ if self.char_color_temperature.value != color_temperature:
+ self.char_color_temperature.set_value(color_temperature)
# Handle Color
if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars:
hue, saturation = new_state.attributes.get(ATTR_HS_COLOR, (None, None))
- if (
- not self._flag[RGB_COLOR]
- and (hue != self._hue or saturation != self._saturation)
- and isinstance(hue, (int, float))
- and isinstance(saturation, (int, float))
- ):
- self.char_hue.set_value(hue)
- self.char_saturation.set_value(saturation)
- self._hue, self._saturation = (hue, saturation)
- self._flag[RGB_COLOR] = False
+ if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)):
+ hue = round(hue, 0)
+ saturation = round(saturation, 0)
+ if hue != self.char_hue.value:
+ self.char_hue.set_value(hue)
+ if saturation != self.char_saturation.value:
+ self.char_saturation.set_value(saturation)
diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py
index 79a9d156f10..b8c3b3f0197 100644
--- a/homeassistant/components/homekit/type_thermostats.py
+++ b/homeassistant/components/homekit/type_thermostats.py
@@ -4,20 +4,23 @@ import logging
from pyhap.const import CATEGORY_THERMOSTAT
from homeassistant.components.climate.const import (
+ ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
+ ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
ATTR_MAX_TEMP,
+ ATTR_MIN_HUMIDITY,
ATTR_MIN_TEMP,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
- ATTR_TARGET_TEMP_STEP,
CURRENT_HVAC_COOL,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
DEFAULT_MAX_TEMP,
+ DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
DOMAIN as DOMAIN_CLIMATE,
HVAC_MODE_AUTO,
@@ -26,8 +29,10 @@ from homeassistant.components.climate.const import (
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
+ SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT,
SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT,
+ SUPPORT_TARGET_HUMIDITY,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.components.water_heater import (
@@ -40,6 +45,7 @@ from homeassistant.const import (
ATTR_TEMPERATURE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from . import TYPES
@@ -47,15 +53,16 @@ from .accessories import HomeAccessory, debounce
from .const import (
CHAR_COOLING_THRESHOLD_TEMPERATURE,
CHAR_CURRENT_HEATING_COOLING,
+ CHAR_CURRENT_HUMIDITY,
CHAR_CURRENT_TEMPERATURE,
CHAR_HEATING_THRESHOLD_TEMPERATURE,
CHAR_TARGET_HEATING_COOLING,
+ CHAR_TARGET_HUMIDITY,
CHAR_TARGET_TEMPERATURE,
CHAR_TEMP_DISPLAY_UNITS,
DEFAULT_MAX_TEMP_WATER_HEATER,
DEFAULT_MIN_TEMP_WATER_HEATER,
PROP_MAX_VALUE,
- PROP_MIN_STEP,
PROP_MIN_VALUE,
SERV_THERMOSTAT,
)
@@ -67,6 +74,7 @@ HC_HOMEKIT_VALID_MODES_WATER_HEATER = {
"Heat": 1,
}
UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1}
+
UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()}
HC_HASS_TO_HOMEKIT = {
HVAC_MODE_OFF: 0,
@@ -99,8 +107,9 @@ class Thermostat(HomeAccessory):
self._flag_coolingthresh = False
self._flag_heatingthresh = False
min_temp, max_temp = self.get_temperature_range()
- temp_step = self.hass.states.get(self.entity_id).attributes.get(
- ATTR_TARGET_TEMP_STEP, 0.5
+
+ min_humidity = self.hass.states.get(self.entity_id).attributes.get(
+ ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY
)
# Add additional characteristics if auto mode is supported
@@ -113,6 +122,9 @@ class Thermostat(HomeAccessory):
(CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE)
)
+ if features & SUPPORT_TARGET_HUMIDITY:
+ self.chars.extend((CHAR_TARGET_HUMIDITY, CHAR_CURRENT_HUMIDITY))
+
serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars)
# Current mode characteristics
@@ -162,11 +174,10 @@ class Thermostat(HomeAccessory):
self.char_target_temp = serv_thermostat.configure_char(
CHAR_TARGET_TEMPERATURE,
value=21.0,
- properties={
- PROP_MIN_VALUE: min_temp,
- PROP_MAX_VALUE: max_temp,
- PROP_MIN_STEP: temp_step,
- },
+ # We do not set PROP_MIN_STEP here and instead use the HomeKit
+ # default of 0.1 in order to have enough precision to convert
+ # temperature units and avoid setting to 73F will result in 74F
+ properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp},
setter_callback=self.set_target_temperature,
)
@@ -182,24 +193,39 @@ class Thermostat(HomeAccessory):
self.char_cooling_thresh_temp = serv_thermostat.configure_char(
CHAR_COOLING_THRESHOLD_TEMPERATURE,
value=23.0,
- properties={
- PROP_MIN_VALUE: min_temp,
- PROP_MAX_VALUE: max_temp,
- PROP_MIN_STEP: temp_step,
- },
+ # We do not set PROP_MIN_STEP here and instead use the HomeKit
+ # default of 0.1 in order to have enough precision to convert
+ # temperature units and avoid setting to 73F will result in 74F
+ properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp},
setter_callback=self.set_cooling_threshold,
)
if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars:
self.char_heating_thresh_temp = serv_thermostat.configure_char(
CHAR_HEATING_THRESHOLD_TEMPERATURE,
value=19.0,
- properties={
- PROP_MIN_VALUE: min_temp,
- PROP_MAX_VALUE: max_temp,
- PROP_MIN_STEP: temp_step,
- },
+ # We do not set PROP_MIN_STEP here and instead use the HomeKit
+ # default of 0.1 in order to have enough precision to convert
+ # temperature units and avoid setting to 73F will result in 74F
+ properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp},
setter_callback=self.set_heating_threshold,
)
+ self.char_target_humidity = None
+ self.char_current_humidity = None
+ if CHAR_TARGET_HUMIDITY in self.chars:
+ self.char_target_humidity = serv_thermostat.configure_char(
+ CHAR_TARGET_HUMIDITY,
+ value=50,
+ # We do not set a max humidity because
+ # homekit currently has a bug that will show the lower bound
+ # shifted upwards. For example if you have a max humidity
+ # of 80% homekit will give you the options 20%-100% instead
+ # of 0-80%
+ properties={PROP_MIN_VALUE: min_humidity},
+ setter_callback=self.set_target_humidity,
+ )
+ self.char_current_humidity = serv_thermostat.configure_char(
+ CHAR_CURRENT_HUMIDITY, value=50,
+ )
def get_temperature_range(self):
"""Return min and max temperature range."""
@@ -231,6 +257,15 @@ class Thermostat(HomeAccessory):
DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE_THERMOSTAT, params, hass_value
)
+ @debounce
+ def set_target_humidity(self, value):
+ """Set target humidity to value if call came from HomeKit."""
+ _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value)
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value}
+ self.call_service(
+ DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{UNIT_PERCENTAGE}",
+ )
+
@debounce
def set_cooling_threshold(self, value):
"""Set cooling threshold temp to value if call came from HomeKit."""
@@ -295,6 +330,12 @@ class Thermostat(HomeAccessory):
current_temp = temperature_to_homekit(current_temp, self._unit)
self.char_current_temp.set_value(current_temp)
+ # Update current humidity
+ if CHAR_CURRENT_HUMIDITY in self.chars:
+ current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY)
+ if isinstance(current_humdity, (int, float)):
+ self.char_current_humidity.set_value(current_humdity)
+
# Update target temperature
target_temp = new_state.attributes.get(ATTR_TEMPERATURE)
if isinstance(target_temp, (int, float)):
@@ -303,6 +344,12 @@ class Thermostat(HomeAccessory):
self.char_target_temp.set_value(target_temp)
self._flag_temperature = False
+ # Update target humidity
+ if CHAR_TARGET_HUMIDITY in self.chars:
+ target_humdity = new_state.attributes.get(ATTR_HUMIDITY)
+ if isinstance(target_humdity, (int, float)):
+ self.char_target_humidity.set_value(target_humdity)
+
# Update cooling threshold temperature if characteristic exists
if self.char_cooling_thresh_temp:
cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH)
@@ -370,11 +417,10 @@ class WaterHeater(HomeAccessory):
self.char_target_temp = serv_thermostat.configure_char(
CHAR_TARGET_TEMPERATURE,
value=50.0,
- properties={
- PROP_MIN_VALUE: min_temp,
- PROP_MAX_VALUE: max_temp,
- PROP_MIN_STEP: 0.5,
- },
+ # We do not set PROP_MIN_STEP here and instead use the HomeKit
+ # default of 0.1 in order to have enough precision to convert
+ # temperature units and avoid setting to 73F will result in 74F
+ properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp},
setter_callback=self.set_target_temperature,
)
diff --git a/homeassistant/components/homekit_controller/.translations/hu.json b/homeassistant/components/homekit_controller/.translations/hu.json
index 264e635d7f4..53ca9a39015 100644
--- a/homeassistant/components/homekit_controller/.translations/hu.json
+++ b/homeassistant/components/homekit_controller/.translations/hu.json
@@ -14,7 +14,7 @@
"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.",
+ "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 az eszk\u00f6z jelenleg m\u00e9g nem t\u00e1mogatott.",
"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."
},
diff --git a/homeassistant/components/homekit_controller/.translations/sl.json b/homeassistant/components/homekit_controller/.translations/sl.json
index 2af8a2a7ab5..aa7977f5bfe 100644
--- a/homeassistant/components/homekit_controller/.translations/sl.json
+++ b/homeassistant/components/homekit_controller/.translations/sl.json
@@ -6,7 +6,7 @@
"already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.",
"already_paired": "Ta dodatna oprema je \u017ee povezana z drugo napravo. Ponastavite dodatno opremo in poskusite znova.",
"ignored_model": "Podpora za HomeKit za ta model je blokirana, saj je na voljo ve\u010d funkcij popolne nativne integracije.",
- "invalid_config_entry": "Ta naprava se prikazuje kot pripravljena za povezavo, vendar je konflikt v nastavitvah Home Assistant, ki ga je treba najprej odstraniti.",
+ "invalid_config_entry": "Ta naprava je prikazana kot pripravljena za seznanjanje, vendar je v programu Home Assistant zanj \u017ee vpisan konfliktni vnos konfiguracije, ki ga je treba najprej odstraniti.",
"no_devices": "Ni bilo mogo\u010de najti neuparjenih naprav"
},
"error": {
@@ -14,7 +14,7 @@
"busy_error": "Naprava je zavrnila seznanjanje, saj se \u017ee povezuje z drugim krmilnikom.",
"max_peers_error": "Naprava je zavrnila seznanjanje, saj nima prostega pomnilnika za seznanjanje.",
"max_tries_error": "Napravaje zavrnila seznanjanje, saj je prejela ve\u010d kot 100 neuspe\u0161nih poskusov overjanja.",
- "pairing_failed": "Pri poskusu seznanjanja s to napravo je pri\u0161lo do napake. To je lahko za\u010dasna napaka ali pa naprava trenutno ni podprta.",
+ "pairing_failed": "Med poskusom seznanitev s to napravo je pri\u0161lo do napake. To je lahko za\u010dasna napaka ali pa va\u0161a naprava trenutno ni podprta.",
"unable_to_pair": "Ni mogo\u010de seznaniti. Poskusite znova.",
"unknown_error": "Naprava je sporo\u010dila neznano napako. Seznanjanje ni uspelo."
},
diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py
index 2089471f288..cf1cf28fc32 100644
--- a/homeassistant/components/homekit_controller/__init__.py
+++ b/homeassistant/components/homekit_controller/__init__.py
@@ -132,14 +132,6 @@ class HomeKitEntity(Entity):
if CharacteristicPermissions.events in char.perms:
self.watchable_characteristics.append((self._aid, char.iid))
- # Callback to allow entity to configure itself based on this
- # characteristics metadata (valid values, value ranges, features, etc)
- setup_fn_name = escape_characteristic_name(char.type_name)
- setup_fn = getattr(self, f"_setup_{setup_fn_name}", None)
- if not setup_fn:
- return
- setup_fn(char.to_accessory_and_service_list())
-
@property
def unique_id(self) -> str:
"""Return the ID of this device."""
diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py
index 39d0e19ba40..b96e5f651e3 100644
--- a/homeassistant/components/homekit_controller/binary_sensor.py
+++ b/homeassistant/components/homekit_controller/binary_sensor.py
@@ -4,6 +4,7 @@ import logging
from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OCCUPANCY,
DEVICE_CLASS_OPENING,
@@ -89,11 +90,30 @@ class HomeKitOccupancySensor(HomeKitEntity, BinarySensorDevice):
return self.service.value(CharacteristicsTypes.OCCUPANCY_DETECTED) == 1
+class HomeKitLeakSensor(HomeKitEntity, BinarySensorDevice):
+ """Representation of a Homekit leak sensor."""
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity is tracking."""
+ return [CharacteristicsTypes.LEAK_DETECTED]
+
+ @property
+ def device_class(self):
+ """Define this binary_sensor as a leak sensor."""
+ return DEVICE_CLASS_MOISTURE
+
+ @property
+ def is_on(self):
+ """Return true if a leak is detected from the binary sensor."""
+ return self.service.value(CharacteristicsTypes.LEAK_DETECTED) == 1
+
+
ENTITY_TYPES = {
"motion": HomeKitMotionSensor,
"contact": HomeKitContactSensor,
"smoke": HomeKitSmokeSensor,
"occupancy": HomeKitOccupancySensor,
+ "leak": HomeKitLeakSensor,
}
diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py
index 133c100b125..2262fa54770 100644
--- a/homeassistant/components/homekit_controller/climate.py
+++ b/homeassistant/components/homekit_controller/climate.py
@@ -1,7 +1,12 @@
"""Support for Homekit climate devices."""
import logging
-from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import (
+ CharacteristicsTypes,
+ HeatingCoolingCurrentValues,
+ HeatingCoolingTargetValues,
+)
+from aiohomekit.utils import clamp_enum_to_char
from homeassistant.components.climate import (
DEFAULT_MAX_HUMIDITY,
@@ -28,21 +33,19 @@ _LOGGER = logging.getLogger(__name__)
# Map of Homekit operation modes to hass modes
MODE_HOMEKIT_TO_HASS = {
- 0: HVAC_MODE_OFF,
- 1: HVAC_MODE_HEAT,
- 2: HVAC_MODE_COOL,
- 3: HVAC_MODE_HEAT_COOL,
+ HeatingCoolingTargetValues.OFF: HVAC_MODE_OFF,
+ HeatingCoolingTargetValues.HEAT: HVAC_MODE_HEAT,
+ HeatingCoolingTargetValues.COOL: HVAC_MODE_COOL,
+ HeatingCoolingTargetValues.AUTO: HVAC_MODE_HEAT_COOL,
}
# Map of hass operation modes to homekit modes
MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()}
-DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS)
-
CURRENT_MODE_HOMEKIT_TO_HASS = {
- 0: CURRENT_HVAC_IDLE,
- 1: CURRENT_HVAC_HEAT,
- 2: CURRENT_HVAC_COOL,
+ HeatingCoolingCurrentValues.IDLE: CURRENT_HVAC_IDLE,
+ HeatingCoolingCurrentValues.HEATING: CURRENT_HVAC_HEAT,
+ HeatingCoolingCurrentValues.COOLING: CURRENT_HVAC_COOL,
}
@@ -65,15 +68,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
"""Representation of a Homekit climate device."""
- def __init__(self, *args):
- """Initialise the device."""
- self._valid_modes = []
- self._min_target_temp = None
- self._max_target_temp = None
- self._min_target_humidity = DEFAULT_MIN_HUMIDITY
- self._max_target_humidity = DEFAULT_MAX_HUMIDITY
- super().__init__(*args)
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -85,44 +79,6 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET,
]
- def _setup_heating_cooling_target(self, characteristic):
- if "valid-values" in characteristic:
- valid_values = [
- val
- for val in DEFAULT_VALID_MODES
- if val in characteristic["valid-values"]
- ]
- else:
- valid_values = DEFAULT_VALID_MODES
- if "minValue" in characteristic:
- valid_values = [
- val for val in valid_values if val >= characteristic["minValue"]
- ]
- if "maxValue" in characteristic:
- valid_values = [
- val for val in valid_values if val <= characteristic["maxValue"]
- ]
-
- self._valid_modes = [MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values]
-
- def _setup_temperature_target(self, characteristic):
- self._features |= SUPPORT_TARGET_TEMPERATURE
-
- if "minValue" in characteristic:
- self._min_target_temp = characteristic["minValue"]
-
- if "maxValue" in characteristic:
- self._max_target_temp = characteristic["maxValue"]
-
- def _setup_relative_humidity_target(self, characteristic):
- self._features |= SUPPORT_TARGET_HUMIDITY
-
- if "minValue" in characteristic:
- self._min_target_humidity = characteristic["minValue"]
-
- if "maxValue" in characteristic:
- self._max_target_humidity = characteristic["maxValue"]
-
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
@@ -160,15 +116,17 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
@property
def min_temp(self):
"""Return the minimum target temp."""
- if self._max_target_temp:
- return self._min_target_temp
+ if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET):
+ char = self.service[CharacteristicsTypes.TEMPERATURE_TARGET]
+ return char.minValue
return super().min_temp
@property
def max_temp(self):
"""Return the maximum target temp."""
- if self._max_target_temp:
- return self._max_target_temp
+ if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET):
+ char = self.service[CharacteristicsTypes.TEMPERATURE_TARGET]
+ return char.maxValue
return super().max_temp
@property
@@ -184,12 +142,14 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
@property
def min_humidity(self):
"""Return the minimum humidity."""
- return self._min_target_humidity
+ char = self.service[CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET]
+ return char.minValue or DEFAULT_MIN_HUMIDITY
@property
def max_humidity(self):
"""Return the maximum humidity."""
- return self._max_target_humidity
+ char = self.service[CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET]
+ return char.maxValue or DEFAULT_MAX_HUMIDITY
@property
def hvac_action(self):
@@ -213,12 +173,24 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
@property
def hvac_modes(self):
"""Return the list of available hvac operation modes."""
- return self._valid_modes
+ valid_values = clamp_enum_to_char(
+ HeatingCoolingTargetValues,
+ self.service[CharacteristicsTypes.HEATING_COOLING_TARGET],
+ )
+ return [MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values]
@property
def supported_features(self):
"""Return the list of supported features."""
- return self._features
+ features = 0
+
+ if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET):
+ features |= SUPPORT_TARGET_TEMPERATURE
+
+ if self.service.has(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET):
+ features |= SUPPORT_TARGET_HUMIDITY
+
+ return features
@property
def temperature_unit(self):
diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py
index f5ae6cbd644..7b40863141c 100644
--- a/homeassistant/components/homekit_controller/const.py
+++ b/homeassistant/components/homekit_controller/const.py
@@ -27,9 +27,11 @@ HOMEKIT_ACCESSORY_DISPATCH = {
"temperature": "sensor",
"battery": "sensor",
"smoke": "binary_sensor",
+ "leak": "binary_sensor",
"fan": "fan",
"fanv2": "fan",
"air-quality": "air_quality",
"occupancy": "binary_sensor",
"television": "media_player",
+ "valve": "switch",
}
diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py
index 9b73846d6a7..88885d49b8e 100644
--- a/homeassistant/components/homekit_controller/cover.py
+++ b/homeassistant/components/homekit_controller/cover.py
@@ -131,12 +131,6 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice):
class HomeKitWindowCover(HomeKitEntity, CoverDevice):
"""Representation of a HomeKit Window or Window Covering."""
- def __init__(self, accessory, discovery_info):
- """Initialise the Cover."""
- super().__init__(accessory, discovery_info)
-
- self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -151,23 +145,27 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice):
CharacteristicsTypes.OBSTRUCTION_DETECTED,
]
- def _setup_position_hold(self, char):
- self._features |= SUPPORT_STOP
-
- def _setup_vertical_tilt_current(self, char):
- self._features |= (
- SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION
- )
-
- def _setup_horizontal_tilt_current(self, char):
- self._features |= (
- SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION
- )
-
@property
def supported_features(self):
"""Flag supported features."""
- return self._features
+ features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
+
+ if self.service.has(CharacteristicsTypes.POSITION_HOLD):
+ features |= SUPPORT_STOP
+
+ supports_tilt = any(
+ (
+ self.service.has(CharacteristicsTypes.VERTICAL_TILT_CURRENT),
+ self.service.has(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT),
+ )
+ )
+
+ if supports_tilt:
+ features |= (
+ SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION
+ )
+
+ return features
@property
def current_cover_position(self):
diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py
index f0d6967684c..e3b392ea107 100644
--- a/homeassistant/components/homekit_controller/fan.py
+++ b/homeassistant/components/homekit_controller/fan.py
@@ -44,11 +44,6 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
# that controls whether the fan is on or off.
on_characteristic = None
- def __init__(self, *args):
- """Initialise the fan."""
- self._features = 0
- super().__init__(*args)
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -58,15 +53,6 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
self.on_characteristic,
]
- def _setup_rotation_direction(self, char):
- self._features |= SUPPORT_DIRECTION
-
- def _setup_rotation_speed(self, char):
- self._features |= SUPPORT_SET_SPEED
-
- def _setup_swing_mode(self, char):
- self._features |= SUPPORT_OSCILLATE
-
@property
def is_on(self):
"""Return true if device is on."""
@@ -113,7 +99,18 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
@property
def supported_features(self):
"""Flag supported features."""
- return self._features
+ features = 0
+
+ if self.service.has(CharacteristicsTypes.ROTATION_DIRECTION):
+ features |= SUPPORT_DIRECTION
+
+ if self.service.has(CharacteristicsTypes.ROTATION_SPEED):
+ features |= SUPPORT_SET_SPEED
+
+ if self.service.has(CharacteristicsTypes.SWING_MODE):
+ features |= SUPPORT_OSCILLATE
+
+ return features
async def async_set_direction(self, direction):
"""Set the direction of the fan."""
diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py
index 14ed74cc085..e78ed48ad0c 100644
--- a/homeassistant/components/homekit_controller/light.py
+++ b/homeassistant/components/homekit_controller/light.py
@@ -48,18 +48,6 @@ class HomeKitLight(HomeKitEntity, Light):
CharacteristicsTypes.SATURATION,
]
- def _setup_brightness(self, char):
- self._features |= SUPPORT_BRIGHTNESS
-
- def _setup_color_temperature(self, char):
- self._features |= SUPPORT_COLOR_TEMP
-
- def _setup_hue(self, char):
- self._features |= SUPPORT_COLOR
-
- def _setup_saturation(self, char):
- self._features |= SUPPORT_COLOR
-
@property
def is_on(self):
"""Return true if device is on."""
@@ -86,7 +74,21 @@ class HomeKitLight(HomeKitEntity, Light):
@property
def supported_features(self):
"""Flag supported features."""
- return self._features
+ features = 0
+
+ if self.service.has(CharacteristicsTypes.BRIGHTNESS):
+ features |= SUPPORT_BRIGHTNESS
+
+ if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE):
+ features |= SUPPORT_COLOR_TEMP
+
+ if self.service.has(CharacteristicsTypes.HUE):
+ features |= SUPPORT_COLOR
+
+ if self.service.has(CharacteristicsTypes.SATURATION):
+ features |= SUPPORT_COLOR
+
+ return features
async def async_turn_on(self, **kwargs):
"""Turn the specified light on."""
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index b09cc198006..009dc285150 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
- "requirements": ["aiohomekit[IP]==0.2.29.2"],
+ "requirements": ["aiohomekit[IP]==0.2.37"],
"dependencies": [],
"zeroconf": ["_hap._tcp.local."],
"codeowners": ["@Jc2k"]
diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py
index 3a1a7359e13..3d5e194ed94 100644
--- a/homeassistant/components/homekit_controller/media_player.py
+++ b/homeassistant/components/homekit_controller/media_player.py
@@ -57,14 +57,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice):
"""Representation of a HomeKit Controller Television."""
- def __init__(self, accessory, discovery_info):
- """Initialise the TV."""
- self._state = None
- self._features = 0
- self._supported_target_media_state = set()
- self._supported_remote_key = set()
- super().__init__(accessory, discovery_info)
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -78,28 +70,6 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice):
CharacteristicsTypes.IDENTIFIER,
]
- def _setup_active_identifier(self, char):
- self._features |= SUPPORT_SELECT_SOURCE
-
- def _setup_target_media_state(self, char):
- self._supported_target_media_state = clamp_enum_to_char(
- TargetMediaStateValues, char
- )
-
- if TargetMediaStateValues.PAUSE in self._supported_target_media_state:
- self._features |= SUPPORT_PAUSE
-
- if TargetMediaStateValues.PLAY in self._supported_target_media_state:
- self._features |= SUPPORT_PLAY
-
- if TargetMediaStateValues.STOP in self._supported_target_media_state:
- self._features |= SUPPORT_STOP
-
- def _setup_remote_key(self, char):
- self._supported_remote_key = clamp_enum_to_char(RemoteKeyValues, char)
- if RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key:
- self._features |= SUPPORT_PAUSE | SUPPORT_PLAY
-
@property
def device_class(self):
"""Define the device class for a HomeKit enabled TV."""
@@ -108,7 +78,47 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice):
@property
def supported_features(self):
"""Flag media player features that are supported."""
- return self._features
+ features = 0
+
+ if self.service.has(CharacteristicsTypes.ACTIVE_IDENTIFIER):
+ features |= SUPPORT_SELECT_SOURCE
+
+ if self.service.has(CharacteristicsTypes.TARGET_MEDIA_STATE):
+ if TargetMediaStateValues.PAUSE in self.supported_media_states:
+ features |= SUPPORT_PAUSE
+
+ if TargetMediaStateValues.PLAY in self.supported_media_states:
+ features |= SUPPORT_PLAY
+
+ if TargetMediaStateValues.STOP in self.supported_media_states:
+ features |= SUPPORT_STOP
+
+ if self.service.has(CharacteristicsTypes.REMOTE_KEY):
+ if RemoteKeyValues.PLAY_PAUSE in self.supported_remote_keys:
+ features |= SUPPORT_PAUSE | SUPPORT_PLAY
+
+ return features
+
+ @property
+ def supported_media_states(self):
+ """Mediate state flags that are supported."""
+ if not self.service.has(CharacteristicsTypes.TARGET_MEDIA_STATE):
+ return frozenset()
+
+ return clamp_enum_to_char(
+ TargetMediaStateValues,
+ self.service[CharacteristicsTypes.TARGET_MEDIA_STATE],
+ )
+
+ @property
+ def supported_remote_keys(self):
+ """Remote key buttons that are supported."""
+ if not self.service.has(CharacteristicsTypes.REMOTE_KEY):
+ return frozenset()
+
+ return clamp_enum_to_char(
+ RemoteKeyValues, self.service[CharacteristicsTypes.REMOTE_KEY]
+ )
@property
def source_list(self):
@@ -164,11 +174,11 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice):
_LOGGER.debug("Cannot play while already playing")
return
- if TargetMediaStateValues.PLAY in self._supported_target_media_state:
+ if TargetMediaStateValues.PLAY in self.supported_media_states:
await self.async_put_characteristics(
{CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PLAY}
)
- elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key:
+ elif RemoteKeyValues.PLAY_PAUSE in self.supported_remote_keys:
await self.async_put_characteristics(
{CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE}
)
@@ -179,11 +189,11 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice):
_LOGGER.debug("Cannot pause while already paused")
return
- if TargetMediaStateValues.PAUSE in self._supported_target_media_state:
+ if TargetMediaStateValues.PAUSE in self.supported_media_states:
await self.async_put_characteristics(
{CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PAUSE}
)
- elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key:
+ elif RemoteKeyValues.PLAY_PAUSE in self.supported_remote_keys:
await self.async_put_characteristics(
{CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE}
)
@@ -194,7 +204,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice):
_LOGGER.debug("Cannot stop when already idle")
return
- if TargetMediaStateValues.STOP in self._supported_target_media_state:
+ if TargetMediaStateValues.STOP in self.supported_media_states:
await self.async_put_characteristics(
{CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.STOP}
)
diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py
index 5897bbb7b3f..61595b504ca 100644
--- a/homeassistant/components/homekit_controller/switch.py
+++ b/homeassistant/components/homekit_controller/switch.py
@@ -1,7 +1,11 @@
"""Support for Homekit switches."""
import logging
-from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import (
+ CharacteristicsTypes,
+ InUseValues,
+ IsConfiguredValues,
+)
from homeassistant.components.switch import SwitchDevice
from homeassistant.core import callback
@@ -12,21 +16,9 @@ OUTLET_IN_USE = "outlet_in_use"
_LOGGER = logging.getLogger(__name__)
-
-async def async_setup_entry(hass, config_entry, async_add_entities):
- """Set up Homekit lock."""
- 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
- info = {"aid": aid, "iid": service["iid"]}
- async_add_entities([HomeKitSwitch(conn, info)], True)
- return True
-
- conn.add_listener(async_add_service)
+ATTR_IN_USE = "in_use"
+ATTR_IS_CONFIGURED = "is_configured"
+ATTR_REMAINING_DURATION = "remaining_duration"
class HomeKitSwitch(HomeKitEntity, SwitchDevice):
@@ -55,3 +47,77 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice):
outlet_in_use = self.service.value(CharacteristicsTypes.OUTLET_IN_USE)
if outlet_in_use is not None:
return {OUTLET_IN_USE: outlet_in_use}
+
+
+class HomeKitValve(HomeKitEntity, SwitchDevice):
+ """Represents a valve in an irrigation system."""
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ return [
+ CharacteristicsTypes.ACTIVE,
+ CharacteristicsTypes.IN_USE,
+ CharacteristicsTypes.IS_CONFIGURED,
+ CharacteristicsTypes.REMAINING_DURATION,
+ ]
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the specified valve on."""
+ await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True})
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the specified valve off."""
+ await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False})
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return "mdi:water"
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self.service.value(CharacteristicsTypes.ACTIVE)
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ attrs = {}
+
+ in_use = self.service.value(CharacteristicsTypes.IN_USE)
+ if in_use is not None:
+ attrs[ATTR_IN_USE] = in_use == InUseValues.IN_USE
+
+ is_configured = self.service.value(CharacteristicsTypes.IS_CONFIGURED)
+ if is_configured is not None:
+ attrs[ATTR_IS_CONFIGURED] = is_configured == IsConfiguredValues.CONFIGURED
+
+ remaining = self.service.value(CharacteristicsTypes.REMAINING_DURATION)
+ if remaining is not None:
+ attrs[ATTR_REMAINING_DURATION] = remaining
+
+ return attrs
+
+
+ENTITY_TYPES = {
+ "switch": HomeKitSwitch,
+ "outlet": HomeKitSwitch,
+ "valve": HomeKitValve,
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit switches."""
+ 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:
+ return False
+ info = {"aid": aid, "iid": service["iid"]}
+ async_add_entities([entity_class(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py
index cd2d528044a..188ec1e2445 100644
--- a/homeassistant/components/homematic/const.py
+++ b/homeassistant/components/homematic/const.py
@@ -63,6 +63,7 @@ HM_DEVICE_TYPES = {
"IPDimmer",
"ColorEffectLight",
"IPKeySwitchLevel",
+ "ColdWarmDimmer",
],
DISCOVER_SENSORS: [
"SwitchPowermeter",
diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py
index 52b2f9a7996..6e6ccb78371 100644
--- a/homeassistant/components/homematic/light.py
+++ b/homeassistant/components/homematic/light.py
@@ -3,11 +3,13 @@ import logging
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_TRANSITION,
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
+ SUPPORT_COLOR_TEMP,
SUPPORT_EFFECT,
Light,
)
@@ -60,6 +62,8 @@ class HMLight(HMDevice, Light):
features |= SUPPORT_COLOR
if "PROGRAM" in self._hmdevice.WRITENODE:
features |= SUPPORT_EFFECT
+ if hasattr(self._hmdevice, "get_color_temp"):
+ features |= SUPPORT_COLOR_TEMP
return features
@property
@@ -70,6 +74,14 @@ class HMLight(HMDevice, Light):
hue, sat = self._hmdevice.get_hs_color(self._channel)
return hue * 360.0, sat * 100.0
+ @property
+ def color_temp(self):
+ """Return the color temp in mireds [int]."""
+ if not self.supported_features & SUPPORT_COLOR_TEMP:
+ return None
+ hm_color_temp = self._hmdevice.get_color_temp(self._channel)
+ return self.max_mireds - (self.max_mireds - self.min_mireds) * hm_color_temp
+
@property
def effect_list(self):
"""Return the list of supported effects."""
@@ -92,7 +104,11 @@ class HMLight(HMDevice, Light):
if ATTR_BRIGHTNESS in kwargs and self._state == "LEVEL":
percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255
self._hmdevice.set_level(percent_bright, self._channel)
- elif ATTR_HS_COLOR not in kwargs and ATTR_EFFECT not in kwargs:
+ elif (
+ ATTR_HS_COLOR not in kwargs
+ and ATTR_COLOR_TEMP not in kwargs
+ and ATTR_EFFECT not in kwargs
+ ):
self._hmdevice.on(self._channel)
if ATTR_HS_COLOR in kwargs:
@@ -101,6 +117,11 @@ class HMLight(HMDevice, Light):
saturation=kwargs[ATTR_HS_COLOR][1] / 100.0,
channel=self._channel,
)
+ if ATTR_COLOR_TEMP in kwargs:
+ hm_temp = (self.max_mireds - kwargs[ATTR_COLOR_TEMP]) / (
+ self.max_mireds - self.min_mireds
+ )
+ self._hmdevice.set_color_temp(hm_temp)
if ATTR_EFFECT in kwargs:
self._hmdevice.set_effect(kwargs[ATTR_EFFECT])
diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py
index f35b696767c..0407a1a0fe2 100644
--- a/homeassistant/components/homematicip_cloud/device.py
+++ b/homeassistant/components/homematicip_cloud/device.py
@@ -121,9 +121,13 @@ class HomematicipGenericDevice(Entity):
# Only go further if the device/entity should be removed from registries
# due to a removal of the HmIP device.
+
if self.hmip_device_removed:
- del self._hap.hmip_device_by_entity_id[self.entity_id]
- await self.async_remove_from_registries()
+ try:
+ del self._hap.hmip_device_by_entity_id[self.entity_id]
+ await self.async_remove_from_registries()
+ except KeyError as err:
+ _LOGGER.debug("Error removing HMIP entity from registry: %s", err)
async def async_remove_from_registries(self) -> None:
"""Remove entity/device from registry."""
@@ -162,12 +166,19 @@ class HomematicipGenericDevice(Entity):
def name(self) -> str:
"""Return the name of the generic device."""
name = self._device.label
- if self._home.name is not None and self._home.name != "":
+ if name and self._home.name:
name = f"{self._home.name} {name}"
- if self.post is not None and self.post != "":
+ if name and self.post:
name = f"{name} {self.post}"
return name
+ def _get_label_by_channel(self, channel: int) -> str:
+ """Return the name of the channel."""
+ name = self._device.functionalChannels[channel].label
+ if name and self._home.name:
+ name = f"{self._home.name} {name}"
+ return name
+
@property
def should_poll(self) -> bool:
"""No polling needed."""
diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py
index cead186db95..42c18239ac2 100644
--- a/homeassistant/components/homematicip_cloud/light.py
+++ b/homeassistant/components/homematicip_cloud/light.py
@@ -71,6 +71,14 @@ class HomematicipLight(HomematicipGenericDevice, Light):
"""Initialize the light device."""
super().__init__(hap, device)
+ @property
+ def name(self) -> str:
+ """Return the name of the multi switch channel."""
+ label = self._get_label_by_channel(1)
+ if label:
+ return label
+ return super().name
+
@property
def is_on(self) -> bool:
"""Return true if device is on."""
@@ -193,6 +201,9 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light):
@property
def name(self) -> str:
"""Return the name of the generic device."""
+ label = self._get_label_by_channel(self.channel)
+ if label:
+ return label
return f"{super().name} Notification"
@property
diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py
index 45adf54df2b..79f7b9dfa5c 100644
--- a/homeassistant/components/homematicip_cloud/switch.py
+++ b/homeassistant/components/homematicip_cloud/switch.py
@@ -153,6 +153,14 @@ class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice):
self.channel = channel
super().__init__(hap, device, f"Channel{channel}")
+ @property
+ def name(self) -> str:
+ """Return the name of the multi switch channel."""
+ label = self._get_label_by_channel(self.channel)
+ if label:
+ return label
+ return super().name
+
@property
def unique_id(self) -> str:
"""Return a unique ID."""
diff --git a/homeassistant/components/huawei_lte/.translations/lb.json b/homeassistant/components/huawei_lte/.translations/lb.json
index 56d383edba3..d99c31d2d63 100644
--- a/homeassistant/components/huawei_lte/.translations/lb.json
+++ b/homeassistant/components/huawei_lte/.translations/lb.json
@@ -33,7 +33,7 @@
"step": {
"init": {
"data": {
- "name": "Numm vum Notifikatioun's Service",
+ "name": "Numm vum Notifikatioun's Service (Restart n\u00e9ideg bei \u00c4nnerung)",
"recipient": "Empf\u00e4nger vun SMS Notifikatioune",
"track_new_devices": "Nei Apparater verfollegen"
}
diff --git a/homeassistant/components/huawei_lte/.translations/no.json b/homeassistant/components/huawei_lte/.translations/no.json
index 39cb5bf87fe..606f6f96375 100644
--- a/homeassistant/components/huawei_lte/.translations/no.json
+++ b/homeassistant/components/huawei_lte/.translations/no.json
@@ -20,14 +20,14 @@
"user": {
"data": {
"password": "Passord",
- "url": "URL",
+ "url": "",
"username": "Brukernavn"
},
"description": "Angi detaljer for enhetstilgang. Angivelse av brukernavn og passord er valgfritt, men gir st\u00f8tte for flere integreringsfunksjoner. P\u00e5 den annen side kan bruk av en autorisert tilkobling f\u00f8re til problemer med tilgang til enhetens webgrensesnitt utenfor Home Assistant mens integreringen er aktiv, og omvendt.",
"title": "Konfigurer Huawei LTE"
}
},
- "title": "Huawei LTE"
+ "title": ""
},
"options": {
"step": {
diff --git a/homeassistant/components/huawei_lte/.translations/pl.json b/homeassistant/components/huawei_lte/.translations/pl.json
index 4029b24df3f..17e4f7b8ef2 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": "To urz\u0105dzenie jest ju\u017c skonfigurowane.",
- "already_in_progress": "To urz\u0105dzenie jest ju\u017c skonfigurowane.",
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.",
+ "already_in_progress": "Urz\u0105dzenie jest ju\u017c skonfigurowane.",
"not_huawei_lte": "To nie jest urz\u0105dzenie Huawei LTE"
},
"error": {
diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py
index e4291ae7e67..5d618c1fdb5 100644
--- a/homeassistant/components/huawei_lte/__init__.py
+++ b/homeassistant/components/huawei_lte/__init__.py
@@ -69,6 +69,7 @@ from .const import (
KEY_NET_CURRENT_PLMN,
KEY_NET_NET_MODE,
KEY_WLAN_HOST_LIST,
+ KEY_WLAN_WIFI_FEATURE_SWITCH,
NOTIFY_SUPPRESS_TIMEOUT,
SERVICE_CLEAR_TRAFFIC_STATISTICS,
SERVICE_REBOOT,
@@ -243,6 +244,9 @@ class Router:
self._get_data(KEY_NET_CURRENT_PLMN, self.client.net.current_plmn)
self._get_data(KEY_NET_NET_MODE, self.client.net.net_mode)
self._get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list)
+ self._get_data(
+ KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch
+ )
self.signal_update()
diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py
index 104933fe714..af6ed75d591 100644
--- a/homeassistant/components/huawei_lte/binary_sensor.py
+++ b/homeassistant/components/huawei_lte/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import CONF_URL
from . import HuaweiLteBaseEntity
-from .const import DOMAIN, KEY_MONITORING_STATUS
+from .const import DOMAIN, KEY_MONITORING_STATUS, KEY_WLAN_WIFI_FEATURE_SWITCH
_LOGGER = logging.getLogger(__name__)
@@ -25,6 +25,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if router.data.get(KEY_MONITORING_STATUS):
entities.append(HuaweiLteMobileConnectionBinarySensor(router))
+ entities.append(HuaweiLteWifiStatusBinarySensor(router))
+ entities.append(HuaweiLteWifi24ghzStatusBinarySensor(router))
+ entities.append(HuaweiLteWifi5ghzStatusBinarySensor(router))
async_add_entities(entities, True)
@@ -37,6 +40,15 @@ class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntity, BinarySensorDevice):
item: str
_raw_state: Optional[str] = attr.ib(init=False, default=None)
+ @property
+ def entity_registry_enabled_default(self) -> bool:
+ """Return if the entity should be enabled when first added to the entity registry."""
+ return False
+
+ @property
+ def _device_unique_id(self) -> str:
+ return f"{self.key}.{self.item}"
+
async def async_added_to_hass(self):
"""Subscribe to needed data on add."""
await super().async_added_to_hass()
@@ -83,10 +95,6 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor):
def _entity_name(self) -> str:
return "Mobile connection"
- @property
- def _device_unique_id(self) -> str:
- return f"{self.key}.{self.item}"
-
@property
def is_on(self) -> bool:
"""Return whether the binary sensor is on."""
@@ -109,6 +117,11 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor):
"""Return mobile connectivity sensor icon."""
return "mdi:signal" if self.is_on else "mdi:signal-off"
+ @property
+ def entity_registry_enabled_default(self) -> bool:
+ """Return if the entity should be enabled when first added to the entity registry."""
+ return True
+
@property
def device_state_attributes(self):
"""Get additional attributes related to connection status."""
@@ -120,3 +133,64 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor):
self._raw_state
]
return attributes
+
+
+class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor):
+ """Huawei LTE WiFi status binary sensor base class."""
+
+ @property
+ def is_on(self) -> bool:
+ """Return whether the binary sensor is on."""
+ return self._raw_state is not None and int(self._raw_state) == 1
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return True if real state is assumed, not known."""
+ return self._raw_state is None
+
+ @property
+ def icon(self):
+ """Return WiFi status sensor icon."""
+ return "mdi:wifi" if self.is_on else "mdi:wifi-off"
+
+
+@attr.s
+class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor):
+ """Huawei LTE WiFi status binary sensor."""
+
+ def __attrs_post_init__(self):
+ """Initialize identifiers."""
+ self.key = KEY_MONITORING_STATUS
+ self.item = "WifiStatus"
+
+ @property
+ def _entity_name(self) -> str:
+ return "WiFi status"
+
+
+@attr.s
+class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor):
+ """Huawei LTE 2.4GHz WiFi status binary sensor."""
+
+ def __attrs_post_init__(self):
+ """Initialize identifiers."""
+ self.key = KEY_WLAN_WIFI_FEATURE_SWITCH
+ self.item = "wifi24g_switch_enable"
+
+ @property
+ def _entity_name(self) -> str:
+ return "2.4GHz WiFi status"
+
+
+@attr.s
+class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor):
+ """Huawei LTE 5GHz WiFi status binary sensor."""
+
+ def __attrs_post_init__(self):
+ """Initialize identifiers."""
+ self.key = KEY_WLAN_WIFI_FEATURE_SWITCH
+ self.item = "wifi5g_enabled"
+
+ @property
+ def _entity_name(self) -> str:
+ return "5GHz WiFi status"
diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py
index 5279dd65b92..583c1c7d6f1 100644
--- a/homeassistant/components/huawei_lte/const.py
+++ b/homeassistant/components/huawei_lte/const.py
@@ -33,8 +33,9 @@ KEY_MONITORING_TRAFFIC_STATISTICS = "monitoring_traffic_statistics"
KEY_NET_CURRENT_PLMN = "net_current_plmn"
KEY_NET_NET_MODE = "net_net_mode"
KEY_WLAN_HOST_LIST = "wlan_host_list"
+KEY_WLAN_WIFI_FEATURE_SWITCH = "wlan_wifi_feature_switch"
-BINARY_SENSOR_KEYS = {KEY_MONITORING_STATUS}
+BINARY_SENSOR_KEYS = {KEY_MONITORING_STATUS, KEY_WLAN_WIFI_FEATURE_SWITCH}
DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST}
diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json
index 471ce2181fb..53c248fe179 100644
--- a/homeassistant/components/hue/.translations/ca.json
+++ b/homeassistant/components/hue/.translations/ca.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Primer bot\u00f3",
+ "button_2": "Segon bot\u00f3",
+ "button_3": "Tercer bot\u00f3",
+ "button_4": "Quart bot\u00f3",
+ "dim_down": "Atenua la brillantor",
+ "dim_up": "Augmenta la brillantor",
+ "turn_off": "Apaga",
+ "turn_on": "Enc\u00e9n"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut",
+ "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut",
+ "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/da.json b/homeassistant/components/hue/.translations/da.json
index afcfd7071e7..c00c19be42a 100644
--- a/homeassistant/components/hue/.translations/da.json
+++ b/homeassistant/components/hue/.translations/da.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "F\u00f8rste knap",
+ "button_2": "Anden knap",
+ "button_3": "Tredje knap",
+ "button_4": "Fjerde knap",
+ "dim_down": "D\u00e6mp ned",
+ "dim_up": "D\u00e6mp op",
+ "turn_off": "Sluk",
+ "turn_on": "T\u00e6nd"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\"-knappen frigivet efter langt tryk",
+ "remote_button_short_press": "\"{subtype}\"-knappen trykket p\u00e5",
+ "remote_button_short_release": "\"{subtype}\"-knappen frigivet"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json
index 1907d9d23ca..a4ab9123b48 100644
--- a/homeassistant/components/hue/.translations/de.json
+++ b/homeassistant/components/hue/.translations/de.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Erste Taste",
+ "button_2": "Zweite Taste",
+ "button_3": "Dritte Taste",
+ "button_4": "Vierte Taste",
+ "dim_down": "Dimmer runter",
+ "dim_up": "Dimmer hoch",
+ "turn_off": "Ausschalten",
+ "turn_on": "Einschalten"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen",
+ "remote_button_short_press": "\"{subtype}\" Taste gedr\u00fcckt",
+ "remote_button_short_release": "\"{subtype}\" Taste losgelassen"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json
index 350360285af..b16213bfbf8 100644
--- a/homeassistant/components/hue/.translations/en.json
+++ b/homeassistant/components/hue/.translations/en.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "First button",
+ "button_2": "Second button",
+ "button_3": "Third button",
+ "button_4": "Fourth button",
+ "dim_down": "Dim down",
+ "dim_up": "Dim up",
+ "turn_off": "Turn off",
+ "turn_on": "Turn on"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\" button released after long press",
+ "remote_button_short_press": "\"{subtype}\" button pressed",
+ "remote_button_short_release": "\"{subtype}\" button released"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json
index bc41d3d2df0..6a5074c6e4a 100644
--- a/homeassistant/components/hue/.translations/es.json
+++ b/homeassistant/components/hue/.translations/es.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Primer bot\u00f3n",
+ "button_2": "Segundo bot\u00f3n",
+ "button_3": "Tercer bot\u00f3n",
+ "button_4": "Cuarto bot\u00f3n",
+ "dim_down": "Bajar la intensidad",
+ "dim_up": "Subir la intensidad",
+ "turn_off": "Apagar",
+ "turn_on": "Encender"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga",
+ "remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado",
+ "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json
index 99319f07ce4..8b1c413b205 100644
--- a/homeassistant/components/hue/.translations/ko.json
+++ b/homeassistant/components/hue/.translations/ko.json
@@ -12,7 +12,7 @@
},
"error": {
"linking": "\uc54c \uc218 \uc5c6\ub294 \uc5f0\uacb0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
- "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\ubcf4\uc138\uc694"
+ "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694"
},
"step": {
"init": {
@@ -27,5 +27,22 @@
}
},
"title": "\ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0"
+ },
+ "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",
+ "dim_down": "\uc5b4\ub461\uac8c \ud558\uae30",
+ "dim_up": "\ubc1d\uac8c \ud558\uae30",
+ "turn_off": "\ub044\uae30",
+ "turn_on": "\ucf1c\uae30"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c",
+ "remote_button_short_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub20c\ub9b4 \ub54c",
+ "remote_button_short_release": "\"{subtype}\" \ubc84\ud2bc\uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/lb.json b/homeassistant/components/hue/.translations/lb.json
index ac83609ff02..2b5b168817f 100644
--- a/homeassistant/components/hue/.translations/lb.json
+++ b/homeassistant/components/hue/.translations/lb.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "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",
+ "dim_down": "Verd\u00e4ischteren",
+ "dim_up": "Erhellen",
+ "turn_off": "Ausschalten",
+ "turn_on": "Uschalten"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss",
+ "remote_button_short_press": "\"{subtype}\" Kn\u00e4ppche gedr\u00e9ckt",
+ "remote_button_short_release": "\"{subtype}\" Kn\u00e4ppche lassgelooss"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/no.json b/homeassistant/components/hue/.translations/no.json
index e8718fe778b..961311d7304 100644
--- a/homeassistant/components/hue/.translations/no.json
+++ b/homeassistant/components/hue/.translations/no.json
@@ -23,9 +23,9 @@
},
"link": {
"description": "Trykk p\u00e5 knappen p\u00e5 Bridgen for \u00e5 registrere Philips Hue med Home Assistant. \n\n ",
- "title": "Link Hub"
+ "title": ""
}
},
- "title": "Philips Hue"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json
index 3425cb82d01..fa2f2e55744 100644
--- a/homeassistant/components/hue/.translations/ru.json
+++ b/homeassistant/components/hue/.translations/ru.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "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",
+ "dim_down": "\u042f\u0440\u043a\u043e\u0441\u0442\u044c \u0443\u043c\u0435\u043d\u044c\u0448\u0430\u0435\u0442\u0441\u044f",
+ "dim_up": "\u042f\u0440\u043a\u043e\u0441\u0442\u044c \u0443\u0432\u0435\u043b\u0438\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f",
+ "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": {
+ "remote_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",
+ "remote_button_short_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430",
+ "remote_button_short_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json
index 6bbe75a8019..0aa75438f7b 100644
--- a/homeassistant/components/hue/.translations/zh-Hant.json
+++ b/homeassistant/components/hue/.translations/zh-Hant.json
@@ -27,5 +27,22 @@
}
},
"title": "Philips Hue"
+ },
+ "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",
+ "dim_down": "\u8abf\u6697",
+ "dim_up": "\u8abf\u4eae",
+ "turn_off": "\u95dc\u9589",
+ "turn_on": "\u958b\u555f"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e",
+ "remote_button_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b",
+ "remote_button_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py
index 319f8f5fa19..8a6b5d203a8 100644
--- a/homeassistant/components/hue/binary_sensor.py
+++ b/homeassistant/components/hue/binary_sensor.py
@@ -17,7 +17,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Defer binary sensor setup to the shared sensor module."""
await hass.data[HUE_DOMAIN][
config_entry.entry_id
- ].sensor_manager.async_register_component(True, async_add_entities)
+ ].sensor_manager.async_register_component("binary_sensor", async_add_entities)
class HuePresence(GenericZLLSensor, BinarySensorDevice):
@@ -44,7 +44,7 @@ class HuePresence(GenericZLLSensor, BinarySensorDevice):
SENSOR_CONFIG_MAP.update(
{
TYPE_ZLL_PRESENCE: {
- "binary": True,
+ "platform": "binary_sensor",
"name_format": PRESENCE_NAME_FORMAT,
"class": HuePresence,
}
diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py
new file mode 100644
index 00000000000..81877654746
--- /dev/null
+++ b/homeassistant/components/hue/device_trigger.py
@@ -0,0 +1,149 @@
+"""Provides device automations for Philips Hue events."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.components.automation.event as event
+from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
+from homeassistant.components.device_automation.exceptions import (
+ InvalidDeviceAutomationConfig,
+)
+from homeassistant.const import (
+ CONF_DEVICE_ID,
+ CONF_DOMAIN,
+ CONF_EVENT,
+ CONF_PLATFORM,
+ CONF_TYPE,
+)
+
+from . import DOMAIN
+from .hue_event import CONF_HUE_EVENT, CONF_UNIQUE_ID
+
+_LOGGER = logging.getLogger(__file__)
+
+CONF_SUBTYPE = "subtype"
+
+CONF_SHORT_PRESS = "remote_button_short_press"
+CONF_SHORT_RELEASE = "remote_button_short_release"
+CONF_LONG_RELEASE = "remote_button_long_release"
+
+CONF_TURN_ON = "turn_on"
+CONF_TURN_OFF = "turn_off"
+CONF_DIM_UP = "dim_up"
+CONF_DIM_DOWN = "dim_down"
+CONF_BUTTON_1 = "button_1"
+CONF_BUTTON_2 = "button_2"
+CONF_BUTTON_3 = "button_3"
+CONF_BUTTON_4 = "button_4"
+
+
+HUE_DIMMER_REMOTE_MODEL = "Hue dimmer switch" # RWL020/021
+HUE_DIMMER_REMOTE = {
+ (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
+ (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003},
+ (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002},
+ (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003},
+ (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002},
+ (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003},
+ (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002},
+ (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003},
+}
+
+HUE_TAP_REMOTE_MODEL = "Hue tap switch" # ZGPSWITCH
+HUE_TAP_REMOTE = {
+ (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34},
+ (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16},
+ (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17},
+ (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18},
+}
+
+REMOTES = {
+ HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE,
+ HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE,
+}
+
+TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend(
+ {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str}
+)
+
+
+def _get_hue_event_from_device_id(hass, device_id):
+ """Resolve hue event from device id."""
+ for bridge in hass.data.get(DOMAIN, {}).values():
+ for hue_event in bridge.sensor_manager.current_events.values():
+ if device_id == hue_event.device_registry_id:
+ return hue_event
+
+ return None
+
+
+async def async_validate_trigger_config(hass, config):
+ """Validate config."""
+ config = TRIGGER_SCHEMA(config)
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ device = device_registry.async_get(config[CONF_DEVICE_ID])
+
+ trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
+
+ if (
+ not device
+ or device.model not in REMOTES
+ or trigger not in REMOTES[device.model]
+ ):
+ raise InvalidDeviceAutomationConfig
+
+ return config
+
+
+async def async_attach_trigger(hass, config, action, automation_info):
+ """Listen for state changes based on configuration."""
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ device = device_registry.async_get(config[CONF_DEVICE_ID])
+
+ hue_event = _get_hue_event_from_device_id(hass, device.id)
+ if hue_event is None:
+ raise InvalidDeviceAutomationConfig
+
+ trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
+
+ trigger = REMOTES[device.model][trigger]
+
+ event_config = {
+ event.CONF_PLATFORM: "event",
+ event.CONF_EVENT_TYPE: CONF_HUE_EVENT,
+ event.CONF_EVENT_DATA: {CONF_UNIQUE_ID: hue_event.unique_id, **trigger},
+ }
+
+ event_config = event.TRIGGER_SCHEMA(event_config)
+ return await event.async_attach_trigger(
+ hass, event_config, action, automation_info, platform_type="device"
+ )
+
+
+async def async_get_triggers(hass, device_id):
+ """List device triggers.
+
+ Make sure device is a supported remote model.
+ Retrieve the hue event object matching device entry.
+ Generate device trigger list.
+ """
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ device = device_registry.async_get(device_id)
+
+ if device.model not in REMOTES:
+ return
+
+ triggers = []
+ for trigger, subtype in REMOTES[device.model].keys():
+ triggers.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_PLATFORM: "device",
+ CONF_TYPE: trigger,
+ CONF_SUBTYPE: subtype,
+ }
+ )
+
+ return triggers
diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py
new file mode 100644
index 00000000000..ed1bc1c8f7d
--- /dev/null
+++ b/homeassistant/components/hue/hue_event.py
@@ -0,0 +1,95 @@
+"""Representation of a Hue remote firing events for button presses."""
+import logging
+
+from aiohue.sensors import TYPE_ZGP_SWITCH, TYPE_ZLL_ROTARY, TYPE_ZLL_SWITCH
+
+from homeassistant.const import CONF_EVENT, CONF_ID
+from homeassistant.core import callback
+from homeassistant.util import slugify
+
+from .sensor_device import GenericHueDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HUE_EVENT = "hue_event"
+CONF_LAST_UPDATED = "last_updated"
+CONF_UNIQUE_ID = "unique_id"
+
+EVENT_NAME_FORMAT = "{}"
+
+
+class HueEvent(GenericHueDevice):
+ """When you want signals instead of entities.
+
+ Stateless sensors such as remotes are expected to generate an event
+ instead of a sensor entity in hass.
+ """
+
+ def __init__(self, sensor, name, bridge, primary_sensor=None):
+ """Register callback that will be used for signals."""
+ super().__init__(sensor, name, bridge, primary_sensor)
+ self.device_registry_id = None
+
+ self.event_id = slugify(self.sensor.name)
+ # Use the 'lastupdated' string to detect new remote presses
+ self._last_updated = self.sensor.lastupdated
+
+ # Register callback in coordinator and add job to remove it on bridge reset.
+ self.bridge.sensor_manager.coordinator.async_add_listener(
+ self.async_update_callback
+ )
+ self.bridge.reset_jobs.append(self.async_will_remove_from_hass)
+ _LOGGER.debug("Hue event created: %s", self.event_id)
+
+ @callback
+ def async_will_remove_from_hass(self):
+ """Remove listener on bridge reset."""
+ self.bridge.sensor_manager.coordinator.async_remove_listener(
+ self.async_update_callback
+ )
+
+ @callback
+ def async_update_callback(self):
+ """Fire the event if reason is that state is updated."""
+ if self.sensor.lastupdated == self._last_updated:
+ return
+
+ # Extract the press code as state
+ if hasattr(self.sensor, "rotaryevent"):
+ state = self.sensor.rotaryevent
+ else:
+ state = self.sensor.buttonevent
+
+ self._last_updated = self.sensor.lastupdated
+
+ # Fire event
+ data = {
+ CONF_ID: self.event_id,
+ CONF_UNIQUE_ID: self.unique_id,
+ CONF_EVENT: state,
+ CONF_LAST_UPDATED: self.sensor.lastupdated,
+ }
+ self.bridge.hass.bus.async_fire(CONF_HUE_EVENT, data)
+
+ async def async_update_device_registry(self):
+ """Update device registry."""
+ device_registry = (
+ await self.bridge.hass.helpers.device_registry.async_get_registry()
+ )
+
+ entry = device_registry.async_get_or_create(
+ config_entry_id=self.bridge.config_entry.entry_id, **self.device_info
+ )
+ self.device_registry_id = entry.id
+ _LOGGER.debug(
+ "Event registry with entry_id: %s and device_id: %s",
+ self.device_registry_id,
+ self.device_id,
+ )
+
+
+EVENT_CONFIG_MAP = {
+ TYPE_ZGP_SWITCH: {"name_format": EVENT_NAME_FORMAT, "class": HueEvent},
+ TYPE_ZLL_SWITCH: {"name_format": EVENT_NAME_FORMAT, "class": HueEvent},
+ TYPE_ZLL_ROTARY: {"name_format": EVENT_NAME_FORMAT, "class": HueEvent},
+}
diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json
index 5471632f9c5..a5c801f4124 100644
--- a/homeassistant/components/hue/manifest.json
+++ b/homeassistant/components/hue/manifest.json
@@ -3,7 +3,7 @@
"name": "Philips Hue",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hue",
- "requirements": ["aiohue==2.0.0"],
+ "requirements": ["aiohue==2.1.0"],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics",
diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py
index 5fa2ed68389..0da8e77eeee 100644
--- a/homeassistant/components/hue/sensor.py
+++ b/homeassistant/components/hue/sensor.py
@@ -1,17 +1,25 @@
"""Hue sensor entities."""
-from aiohue.sensors import TYPE_ZLL_LIGHTLEVEL, TYPE_ZLL_TEMPERATURE
+from aiohue.sensors import (
+ TYPE_ZLL_LIGHTLEVEL,
+ TYPE_ZLL_ROTARY,
+ TYPE_ZLL_SWITCH,
+ TYPE_ZLL_TEMPERATURE,
+)
from homeassistant.const import (
+ DEVICE_CLASS_BATTERY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
from .const import DOMAIN as HUE_DOMAIN
-from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor
+from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor
LIGHT_LEVEL_NAME_FORMAT = "{} light level"
+REMOTE_NAME_FORMAT = "{} battery level"
TEMPERATURE_NAME_FORMAT = "{} temperature"
@@ -19,7 +27,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Defer sensor setup to the shared sensor module."""
await hass.data[HUE_DOMAIN][
config_entry.entry_id
- ].sensor_manager.async_register_component(False, async_add_entities)
+ ].sensor_manager.async_register_component("sensor", async_add_entities)
class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity):
@@ -79,17 +87,51 @@ class HueTemperature(GenericHueGaugeSensorEntity):
return self.sensor.temperature / 100
+class HueBattery(GenericHueSensor):
+ """Battery class for when a batt-powered device is only represented as an event."""
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this device."""
+ return f"{self.sensor.uniqueid}-battery"
+
+ @property
+ def state(self):
+ """Return the state of the battery."""
+ return self.sensor.battery
+
+ @property
+ def device_class(self):
+ """Return the class of the sensor."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return UNIT_PERCENTAGE
+
+
SENSOR_CONFIG_MAP.update(
{
TYPE_ZLL_LIGHTLEVEL: {
- "binary": False,
+ "platform": "sensor",
"name_format": LIGHT_LEVEL_NAME_FORMAT,
"class": HueLightLevel,
},
TYPE_ZLL_TEMPERATURE: {
- "binary": False,
+ "platform": "sensor",
"name_format": TEMPERATURE_NAME_FORMAT,
"class": HueTemperature,
},
+ TYPE_ZLL_SWITCH: {
+ "platform": "sensor",
+ "name_format": REMOTE_NAME_FORMAT,
+ "class": HueBattery,
+ },
+ TYPE_ZLL_ROTARY: {
+ "platform": "sensor",
+ "name_format": REMOTE_NAME_FORMAT,
+ "class": HueBattery,
+ },
}
)
diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py
index 507415963a5..113957d140e 100644
--- a/homeassistant/components/hue/sensor_base.py
+++ b/homeassistant/components/hue/sensor_base.py
@@ -10,8 +10,10 @@ 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 .const import REQUEST_REFRESH_DELAY
from .helpers import remove_devices
+from .hue_event import EVENT_CONFIG_MAP
+from .sensor_device import GenericHueDevice
SENSOR_CONFIG_MAP = {}
_LOGGER = logging.getLogger(__name__)
@@ -38,6 +40,9 @@ class SensorManager:
self.bridge = bridge
self._component_add_entities = {}
self.current = {}
+ self.current_events = {}
+
+ self._enabled_platforms = ("binary_sensor", "sensor")
self.coordinator = DataUpdateCoordinator(
bridge.hass,
_LOGGER,
@@ -62,11 +67,12 @@ class SensorManager:
except AiohueException as err:
raise UpdateFailed(f"Hue error: {err}")
- async def async_register_component(self, binary, async_add_entities):
+ async def async_register_component(self, platform, async_add_entities):
"""Register async_add_entities methods for components."""
- self._component_add_entities[binary] = async_add_entities
+ self._component_add_entities[platform] = async_add_entities
- if len(self._component_add_entities) < 2:
+ if len(self._component_add_entities) < len(self._enabled_platforms):
+ _LOGGER.debug("Aborting start with %s, waiting for the rest", platform)
return
# We have all components available, start the updating.
@@ -81,11 +87,10 @@ class SensorManager:
"""Update sensors from the bridge."""
api = self.bridge.api.sensors
- if len(self._component_add_entities) < 2:
+ if len(self._component_add_entities) < len(self._enabled_platforms):
return
- new_sensors = []
- new_binary_sensors = []
+ to_add = {}
primary_sensor_devices = {}
current = self.current
@@ -111,12 +116,24 @@ class SensorManager:
# Iterate again now we have all the presence sensors, and add the
# related sensors with nice names where appropriate.
for item_id in api:
- existing = current.get(api[item_id].uniqueid)
- if existing is not None:
+ uniqueid = api[item_id].uniqueid
+ if current.get(uniqueid, self.current_events.get(uniqueid)) is not None:
continue
- primary_sensor = None
- sensor_config = SENSOR_CONFIG_MAP.get(api[item_id].type)
+ sensor_type = api[item_id].type
+
+ # Check for event generator devices
+ event_config = EVENT_CONFIG_MAP.get(sensor_type)
+ if event_config is not None:
+ base_name = api[item_id].name
+ name = event_config["name_format"].format(base_name)
+ new_event = event_config["class"](api[item_id], name, self.bridge)
+ self.bridge.hass.async_create_task(
+ new_event.async_update_device_registry()
+ )
+ self.current_events[uniqueid] = new_event
+
+ sensor_config = SENSOR_CONFIG_MAP.get(sensor_type)
if sensor_config is None:
continue
@@ -126,13 +143,11 @@ class SensorManager:
base_name = primary_sensor.name
name = sensor_config["name_format"].format(base_name)
- current[api[item_id].uniqueid] = sensor_config["class"](
+ current[uniqueid] = sensor_config["class"](
api[item_id], name, self.bridge, primary_sensor=primary_sensor
)
- if sensor_config["binary"]:
- new_binary_sensors.append(current[api[item_id].uniqueid])
- else:
- new_sensors.append(current[api[item_id].uniqueid])
+
+ to_add.setdefault(sensor_config["platform"], []).append(current[uniqueid])
self.bridge.hass.async_create_task(
remove_devices(
@@ -140,59 +155,27 @@ class SensorManager:
)
)
- if new_sensors:
- self._component_add_entities[False](new_sensors)
- if new_binary_sensors:
- self._component_add_entities[True](new_binary_sensors)
+ for platform in to_add:
+ self._component_add_entities[platform](to_add[platform])
-class GenericHueSensor(entity.Entity):
+class GenericHueSensor(GenericHueDevice, entity.Entity):
"""Representation of a Hue sensor."""
should_poll = False
- def __init__(self, sensor, name, bridge, primary_sensor=None):
- """Initialize the sensor."""
- self.sensor = sensor
- self._name = name
- self._primary_sensor = primary_sensor
- self.bridge = bridge
-
async def _async_update_ha_state(self, *args, **kwargs):
raise NotImplementedError
- @property
- def primary_sensor(self):
- """Return the primary sensor entity of the physical device."""
- return self._primary_sensor or self.sensor
-
- @property
- def device_id(self):
- """Return the ID of the physical device this sensor is part of."""
- return self.unique_id[:23]
-
- @property
- def unique_id(self):
- """Return the ID of this Hue sensor."""
- return self.sensor.uniqueid
-
- @property
- def name(self):
- """Return a friendly name for the sensor."""
- return self._name
-
@property
def available(self):
"""Return if sensor is available."""
return self.bridge.sensor_manager.coordinator.last_update_success and (
- self.bridge.allow_unreachable or self.sensor.config["reachable"]
+ self.bridge.allow_unreachable
+ # remotes like Hue Tap (ZGPSwitchSensor) have no _reachability_
+ or self.sensor.config.get("reachable", True)
)
- @property
- def swupdatestate(self):
- """Return detail of available software updates for this device."""
- return self.primary_sensor.raw.get("swupdate", {}).get("state")
-
async def async_added_to_hass(self):
"""When entity is added to hass."""
self.bridge.sensor_manager.coordinator.async_add_listener(
@@ -212,21 +195,6 @@ class GenericHueSensor(entity.Entity):
"""
await self.bridge.sensor_manager.coordinator.async_request_refresh()
- @property
- def device_info(self):
- """Return the device info.
-
- Links individual entities together in the hass device registry.
- """
- return {
- "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),
- }
-
class GenericZLLSensor(GenericHueSensor):
"""Representation of a Hue-brand, physical sensor."""
diff --git a/homeassistant/components/hue/sensor_device.py b/homeassistant/components/hue/sensor_device.py
new file mode 100644
index 00000000000..91719debeb5
--- /dev/null
+++ b/homeassistant/components/hue/sensor_device.py
@@ -0,0 +1,53 @@
+"""Support for the Philips Hue sensor devices."""
+from .const import DOMAIN as HUE_DOMAIN
+
+
+class GenericHueDevice:
+ """Representation of a Hue device."""
+
+ def __init__(self, sensor, name, bridge, primary_sensor=None):
+ """Initialize the sensor."""
+ self.sensor = sensor
+ self._name = name
+ self._primary_sensor = primary_sensor
+ self.bridge = bridge
+
+ @property
+ def primary_sensor(self):
+ """Return the primary sensor entity of the physical device."""
+ return self._primary_sensor or self.sensor
+
+ @property
+ def device_id(self):
+ """Return the ID of the physical device this sensor is part of."""
+ return self.unique_id[:23]
+
+ @property
+ def unique_id(self):
+ """Return the ID of this Hue sensor."""
+ return self.sensor.uniqueid
+
+ @property
+ def name(self):
+ """Return a friendly name for the sensor."""
+ return self._name
+
+ @property
+ def swupdatestate(self):
+ """Return detail of available software updates for this device."""
+ return self.primary_sensor.raw.get("swupdate", {}).get("state")
+
+ @property
+ def device_info(self):
+ """Return the device info.
+
+ Links individual entities together in the hass device registry.
+ """
+ return {
+ "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),
+ }
diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json
index 78b990d5f42..0f70c49ff2e 100644
--- a/homeassistant/components/hue/strings.json
+++ b/homeassistant/components/hue/strings.json
@@ -27,5 +27,22 @@
"already_in_progress": "Config flow for bridge is already in progress.",
"not_hue_bridge": "Not a Hue bridge"
}
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "First button",
+ "button_2": "Second button",
+ "button_3": "Third button",
+ "button_4": "Fourth button",
+ "dim_down": "Dim down",
+ "dim_up": "Dim up",
+ "turn_off": "Turn off",
+ "turn_on": "Turn on"
+ },
+ "trigger_type": {
+ "remote_button_long_release": "\"{subtype}\" button released after long press",
+ "remote_button_short_press": "\"{subtype}\" button pressed",
+ "remote_button_short_release": "\"{subtype}\" button released"
+ }
}
-}
+}
\ No newline at end of file
diff --git a/homeassistant/components/iaqualink/.translations/ko.json b/homeassistant/components/iaqualink/.translations/ko.json
index 9b2519077e2..26bfa37d6be 100644
--- a/homeassistant/components/iaqualink/.translations/ko.json
+++ b/homeassistant/components/iaqualink/.translations/ko.json
@@ -13,7 +13,7 @@
"username": "\uc0ac\uc6a9\uc790 \uc774\ub984 / \uc774\uba54\uc77c \uc8fc\uc18c"
},
"description": "iAqualink \uacc4\uc815\uc758 \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.",
- "title": "iAqualink \uc5f0\uacb0"
+ "title": "iAqualink \uc5d0 \uc5f0\uacb0\ud558\uae30"
}
},
"title": "Jandy iAqualink"
diff --git a/homeassistant/components/iaqualink/.translations/no.json b/homeassistant/components/iaqualink/.translations/no.json
index 9d464a6d516..0647f2ecfb8 100644
--- a/homeassistant/components/iaqualink/.translations/no.json
+++ b/homeassistant/components/iaqualink/.translations/no.json
@@ -16,6 +16,6 @@
"title": "Koble til iAqualink"
}
},
- "title": "Jandy iAqualink"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/icloud/.translations/ca.json b/homeassistant/components/icloud/.translations/ca.json
index aa8f8374124..c9e6f046d8a 100644
--- a/homeassistant/components/icloud/.translations/ca.json
+++ b/homeassistant/components/icloud/.translations/ca.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "El compte ja ha estat configurat"
+ "already_configured": "El compte ja ha estat configurat",
+ "no_device": "Cap dels teus dispositius t\u00e9 activada la opci\u00f3 \"Troba el meu iPhone\""
},
"error": {
"login": "Error d\u2019inici de sessi\u00f3: comprova el correu electr\u00f2nic i la contrasenya",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Contrasenya",
- "username": "Correu electr\u00f2nic"
+ "username": "Correu electr\u00f2nic",
+ "with_family": "Amb fam\u00edlia"
},
"description": "Introdueix les teves credencials",
"title": "Credencials d'iCloud"
diff --git a/homeassistant/components/icloud/.translations/da.json b/homeassistant/components/icloud/.translations/da.json
index e60b5120a83..49d1a82a753 100644
--- a/homeassistant/components/icloud/.translations/da.json
+++ b/homeassistant/components/icloud/.translations/da.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Kontoen er allerede konfigureret"
+ "already_configured": "Kontoen er allerede konfigureret",
+ "no_device": "Ingen af dine enheder har aktiveret \"Find min iPhone\""
},
"error": {
"login": "Loginfejl: Kontroller din email og adgangskode",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Adgangskode",
- "username": "Email"
+ "username": "Email",
+ "with_family": "Med familien"
},
"description": "Indtast dine legitimationsoplysninger",
"title": "iCloud-legitimationsoplysninger"
diff --git a/homeassistant/components/icloud/.translations/de.json b/homeassistant/components/icloud/.translations/de.json
index c31f648a4ad..e317741a0a2 100644
--- a/homeassistant/components/icloud/.translations/de.json
+++ b/homeassistant/components/icloud/.translations/de.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Konto bereits konfiguriert"
+ "already_configured": "Konto bereits konfiguriert",
+ "no_device": "Auf keinem Ihrer Ger\u00e4te ist \"Find my iPhone\" aktiviert"
},
"error": {
"login": "Login-Fehler: Bitte \u00fcberpr\u00fcfe deine E-Mail & Passwort",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Passwort",
- "username": "E-Mail"
+ "username": "E-Mail",
+ "with_family": "Mit Familie"
},
"description": "Gib deine Zugangsdaten ein",
"title": "iCloud-Anmeldeinformationen"
diff --git a/homeassistant/components/icloud/.translations/es.json b/homeassistant/components/icloud/.translations/es.json
index 7a0d4b66047..02f07e5d492 100644
--- a/homeassistant/components/icloud/.translations/es.json
+++ b/homeassistant/components/icloud/.translations/es.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Cuenta ya configurada"
+ "already_configured": "Cuenta ya configurada",
+ "no_device": "Ninguno de tus dispositivos tiene activado \"Buscar mi iPhone\""
},
"error": {
"login": "Error de inicio de sesi\u00f3n: compruebe su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Contrase\u00f1a",
- "username": "Correo electr\u00f3nico"
+ "username": "Correo electr\u00f3nico",
+ "with_family": "Con la familia"
},
"description": "Ingrese sus credenciales",
"title": "Credenciales iCloud"
diff --git a/homeassistant/components/icloud/.translations/fr.json b/homeassistant/components/icloud/.translations/fr.json
index 91cff9912b6..e1a2517e4d7 100644
--- a/homeassistant/components/icloud/.translations/fr.json
+++ b/homeassistant/components/icloud/.translations/fr.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9"
+ "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9",
+ "no_device": "Aucun de vos appareils n'a activ\u00e9 \"Find my iPhone\""
},
"error": {
"login": "Erreur de connexion: veuillez v\u00e9rifier votre e-mail et votre mot de passe",
diff --git a/homeassistant/components/icloud/.translations/it.json b/homeassistant/components/icloud/.translations/it.json
index 9d93a07565f..61cd4690179 100644
--- a/homeassistant/components/icloud/.translations/it.json
+++ b/homeassistant/components/icloud/.translations/it.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Account gi\u00e0 configurato"
+ "already_configured": "Account gi\u00e0 configurato",
+ "no_device": "Nessuno dei tuoi dispositivi ha attivato \"Trova il mio iPhone\""
},
"error": {
"login": "Errore di accesso: si prega di controllare la tua e-mail e la password",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Password",
- "username": "E-mail"
+ "username": "E-mail",
+ "with_family": "Con la famiglia"
},
"description": "Inserisci le tue credenziali",
"title": "Credenziali iCloud"
diff --git a/homeassistant/components/icloud/.translations/ko.json b/homeassistant/components/icloud/.translations/ko.json
index 10df5c4519c..8bc26c300e0 100644
--- a/homeassistant/components/icloud/.translations/ko.json
+++ b/homeassistant/components/icloud/.translations/ko.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "no_device": "\"\ub098\uc758 iPhone \ucc3e\uae30\"\uac00 \ud65c\uc131\ud654\ub41c \uae30\uae30\uac00 \uc5c6\uc2b5\ub2c8\ub2e4"
},
"error": {
"login": "\ub85c\uadf8\uc778 \uc624\ub958: \uc774\uba54\uc77c \ubc0f \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "\ube44\ubc00\ubc88\ud638",
- "username": "\uc774\uba54\uc77c"
+ "username": "\uc774\uba54\uc77c",
+ "with_family": "\uac00\uc871\uc6a9"
},
"description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694",
"title": "iCloud \uc790\uaca9 \uc99d\uba85"
diff --git a/homeassistant/components/icloud/.translations/lb.json b/homeassistant/components/icloud/.translations/lb.json
index f90ec545c39..0aa3c90eff0 100644
--- a/homeassistant/components/icloud/.translations/lb.json
+++ b/homeassistant/components/icloud/.translations/lb.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Kont ass scho konfigur\u00e9iert"
+ "already_configured": "Kont ass scho konfigur\u00e9iert",
+ "no_device": "Kee vun dengen Apparater huet \"Find my iPhone\" aktiv\u00e9iert"
},
"error": {
"login": "Feeler beim Login: iwwerpr\u00e9ift \u00e4r E-Mail & Passwuert",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Passwuert",
- "username": "E-Mail"
+ "username": "E-Mail",
+ "with_family": "Mat der Famill"
},
"description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus",
"title": "iCloud Umeldungs Informatiounen"
diff --git a/homeassistant/components/icloud/.translations/no.json b/homeassistant/components/icloud/.translations/no.json
index 589c220ec9c..3ba3207cc24 100644
--- a/homeassistant/components/icloud/.translations/no.json
+++ b/homeassistant/components/icloud/.translations/no.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Kontoen er allerede konfigurert"
+ "already_configured": "Kontoen er allerede konfigurert",
+ "no_device": "Ingen av enhetene dine har \"Finn min iPhone\" aktivert"
},
"error": {
"login": "Innloggingsfeil: vennligst sjekk e-postadressen og passordet ditt",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Passord",
- "username": "E-post"
+ "username": "E-post",
+ "with_family": "Med familie"
},
"description": "Angi legitimasjonsbeskrivelsen",
"title": "iCloud-legitimasjon"
diff --git a/homeassistant/components/icloud/.translations/pl.json b/homeassistant/components/icloud/.translations/pl.json
index 41e182eceee..9c65891d261 100644
--- a/homeassistant/components/icloud/.translations/pl.json
+++ b/homeassistant/components/icloud/.translations/pl.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Konto jest ju\u017c skonfigurowane."
+ "already_configured": "Konto jest ju\u017c skonfigurowane.",
+ "no_device": "\u017badne z Twoich urz\u0105dze\u0144 nie ma aktywowanej funkcji \"Znajd\u017a m\u00f3j iPhone\""
},
"error": {
"login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Has\u0142o",
- "username": "E-mail"
+ "username": "E-mail",
+ "with_family": "Z rodzin\u0105"
},
"description": "Wprowad\u017a dane uwierzytelniaj\u0105ce",
"title": "Dane uwierzytelniaj\u0105ce iCloud"
diff --git a/homeassistant/components/icloud/.translations/ru.json b/homeassistant/components/icloud/.translations/ru.json
index b3a9578ad1e..b0869df14b1 100644
--- a/homeassistant/components/icloud/.translations/ru.json
+++ b/homeassistant/components/icloud/.translations/ru.json
@@ -1,7 +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."
+ "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.",
+ "no_device": "\u041d\u0438 \u043d\u0430 \u043e\u0434\u043d\u043e\u043c \u0438\u0437 \u0412\u0430\u0448\u0438\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f \"\u041d\u0430\u0439\u0442\u0438 iPhone\"."
},
"error": {
"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.",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
- "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b"
+ "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b",
+ "with_family": "\u0421 \u0441\u0435\u043c\u044c\u0451\u0439"
},
"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": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 iCloud"
diff --git a/homeassistant/components/icloud/.translations/sl.json b/homeassistant/components/icloud/.translations/sl.json
index 14d6168409c..6887eddde66 100644
--- a/homeassistant/components/icloud/.translations/sl.json
+++ b/homeassistant/components/icloud/.translations/sl.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Ra\u010dun \u017ee nastavljen"
+ "already_configured": "Ra\u010dun \u017ee nastavljen",
+ "no_device": "V nobeni od va\u0161ih naprav ni aktiviran \u00bbFind my iPhone\u00ab"
},
"error": {
"login": "Napaka pri prijavi: preverite svoj e-po\u0161tni naslov in geslo",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Geslo",
- "username": "E-po\u0161tni naslov"
+ "username": "E-po\u0161tni naslov",
+ "with_family": "Z dru\u017eino"
},
"description": "Vnesite svoje poverilnice",
"title": "iCloud poverilnice"
diff --git a/homeassistant/components/icloud/.translations/tr.json b/homeassistant/components/icloud/.translations/tr.json
new file mode 100644
index 00000000000..3d74852ce50
--- /dev/null
+++ b/homeassistant/components/icloud/.translations/tr.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "no_device": "Hi\u00e7bir cihaz\u0131n\u0131zda \"iPhone'umu bul\" etkin de\u011fil"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/icloud/.translations/zh-Hant.json b/homeassistant/components/icloud/.translations/zh-Hant.json
index a3f4e68e167..cdaff703be7 100644
--- a/homeassistant/components/icloud/.translations/zh-Hant.json
+++ b/homeassistant/components/icloud/.translations/zh-Hant.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "no_device": "\u8a2d\u5099\u7686\u672a\u958b\u555f\u300c\u5c0b\u627e\u6211\u7684 iPhone\u300d\u529f\u80fd\u3002"
},
"error": {
"login": "\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u78ba\u8a8d\u96fb\u5b50\u90f5\u4ef6\u8207\u79d8\u5bc6\u6b63\u78ba\u6027",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "\u5bc6\u78bc",
- "username": "\u96fb\u5b50\u90f5\u4ef6"
+ "username": "\u96fb\u5b50\u90f5\u4ef6",
+ "with_family": "\u8207\u5bb6\u4eba\u5171\u4eab"
},
"description": "\u8f38\u5165\u6191\u8b49",
"title": "iCloud \u6191\u8b49"
diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json
index b4ef46cfbaf..fd970ce4441 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.5"],
+ "requirements": ["pyicloud==0.9.6.1"],
"dependencies": [],
"codeowners": ["@Quentame"]
}
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
index 57ec6fefe29..6201348f21c 100644
--- a/homeassistant/components/integration/sensor.py
+++ b/homeassistant/components/integration/sensor.py
@@ -39,7 +39,7 @@ RIGHT_METHOD = "right"
INTEGRATION_METHOD = [TRAPEZOIDAL_METHOD, LEFT_METHOD, RIGHT_METHOD]
# SI Metric prefixes
-UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "G": 10 ** 6, "T": 10 ** 9}
+UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "M": 10 ** 6, "G": 10 ** 9, "T": 10 ** 12}
# SI Time prefixes
UNIT_TIME = {
diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py
index 669d1155d80..a3a06a52c9c 100644
--- a/homeassistant/components/intesishome/climate.py
+++ b/homeassistant/components/intesishome/climate.py
@@ -14,7 +14,11 @@ from homeassistant.components.climate.const import (
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
+ PRESET_BOOST,
+ PRESET_COMFORT,
+ PRESET_ECO,
SUPPORT_FAN_MODE,
+ SUPPORT_PRESET_MODE,
SUPPORT_SWING_MODE,
SUPPORT_TARGET_TEMPERATURE,
SWING_BOTH,
@@ -57,9 +61,25 @@ MAP_IH_TO_HVAC_MODE = {
"heat": HVAC_MODE_HEAT,
"off": HVAC_MODE_OFF,
}
-
MAP_HVAC_MODE_TO_IH = {v: k for k, v in MAP_IH_TO_HVAC_MODE.items()}
+MAP_IH_TO_PRESET_MODE = {
+ "eco": PRESET_ECO,
+ "comfort": PRESET_COMFORT,
+ "powerful": PRESET_BOOST,
+}
+MAP_PRESET_MODE_TO_IH = {v: k for k, v in MAP_IH_TO_PRESET_MODE.items()}
+
+IH_SWING_STOP = "auto/stop"
+IH_SWING_SWING = "swing"
+MAP_SWING_TO_IH = {
+ SWING_OFF: {"vvane": IH_SWING_STOP, "hvane": IH_SWING_STOP},
+ SWING_BOTH: {"vvane": IH_SWING_SWING, "hvane": IH_SWING_SWING},
+ SWING_HORIZONTAL: {"vvane": IH_SWING_STOP, "hvane": IH_SWING_SWING},
+ SWING_VERTICAL: {"vvane": IH_SWING_SWING, "hvane": IH_SWING_STOP},
+}
+
+
MAP_STATE_ICONS = {
HVAC_MODE_COOL: "mdi:snowflake",
HVAC_MODE_DRY: "mdi:water-off",
@@ -68,15 +88,6 @@ MAP_STATE_ICONS = {
HVAC_MODE_HEAT_COOL: "mdi:cached",
}
-IH_HVAC_MODES = [
- HVAC_MODE_HEAT_COOL,
- HVAC_MODE_COOL,
- HVAC_MODE_HEAT,
- HVAC_MODE_DRY,
- HVAC_MODE_FAN_ONLY,
- HVAC_MODE_OFF,
-]
-
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Create the IntesisHome climate devices."""
@@ -132,31 +143,53 @@ class IntesisAC(ClimateDevice):
self._setpoint_step = 1
self._current_temp = None
self._max_temp = None
+ self._hvac_mode_list = []
self._min_temp = None
self._target_temp = None
self._outdoor_temp = None
+ self._hvac_mode = None
+ self._preset = None
+ self._preset_list = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST]
self._run_hours = None
self._rssi = None
- self._swing = None
- self._swing_list = None
+ self._swing_list = [SWING_OFF]
self._vvane = None
self._hvane = None
self._power = False
self._fan_speed = None
- self._hvac_mode = None
- self._fan_modes = controller.get_fan_speed_list(ih_device_id)
- self._support = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE
- self._swing_list = [SWING_OFF]
+ self._support = 0
+ self._power_consumption_heat = None
+ self._power_consumption_cool = None
- if ih_device.get("config_vertical_vanes"):
+ # Setpoint support
+ if controller.has_setpoint_control(ih_device_id):
+ self._support |= SUPPORT_TARGET_TEMPERATURE
+
+ # Setup swing list
+ if controller.has_vertical_swing(ih_device_id):
self._swing_list.append(SWING_VERTICAL)
- if ih_device.get("config_horizontal_vanes"):
+ if controller.has_horizontal_swing(ih_device_id):
self._swing_list.append(SWING_HORIZONTAL)
- if len(self._swing_list) == 3:
+ if SWING_HORIZONTAL in self._swing_list and SWING_VERTICAL in self._swing_list:
self._swing_list.append(SWING_BOTH)
+ if len(self._swing_list) > 1:
self._support |= SUPPORT_SWING_MODE
- elif len(self._swing_list) == 2:
- self._support |= SUPPORT_SWING_MODE
+
+ # Setup fan speeds
+ self._fan_modes = controller.get_fan_speed_list(ih_device_id)
+ if self._fan_modes:
+ self._support |= SUPPORT_FAN_MODE
+
+ # Preset support
+ if ih_device.get("climate_working_mode"):
+ self._support |= SUPPORT_PRESET_MODE
+
+ # Setup HVAC modes
+ modes = controller.get_mode_list(ih_device_id)
+ if modes:
+ mode_list = [MAP_IH_TO_HVAC_MODE[mode] for mode in modes]
+ self._hvac_mode_list.extend(mode_list)
+ self._hvac_mode_list.append(HVAC_MODE_OFF)
async def async_added_to_hass(self):
"""Subscribe to event updates."""
@@ -181,11 +214,17 @@ class IntesisAC(ClimateDevice):
def device_state_attributes(self):
"""Return the device specific state attributes."""
attrs = {}
- if len(self._swing_list) > 1:
- attrs["vertical_vane"] = self._vvane
- attrs["horizontal_vane"] = self._hvane
if self._outdoor_temp:
attrs["outdoor_temp"] = self._outdoor_temp
+ if self._power_consumption_heat:
+ attrs["power_consumption_heat_kw"] = round(
+ self._power_consumption_heat / 1000, 1
+ )
+ if self._power_consumption_cool:
+ attrs["power_consumption_cool_kw"] = round(
+ self._power_consumption_cool / 1000, 1
+ )
+
return attrs
@property
@@ -198,6 +237,16 @@ class IntesisAC(ClimateDevice):
"""Return whether setpoint should be whole or half degree precision."""
return self._setpoint_step
+ @property
+ def preset_modes(self):
+ """Return a list of HVAC preset modes."""
+ return self._preset_list
+
+ @property
+ def preset_mode(self):
+ """Return the current preset mode."""
+ return self._preset
+
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
@@ -248,21 +297,21 @@ class IntesisAC(ClimateDevice):
self._fan_speed = fan_mode
self.async_write_ha_state()
+ async def async_set_preset_mode(self, preset_mode):
+ """Set preset mode."""
+ ih_preset_mode = MAP_PRESET_MODE_TO_IH.get(preset_mode)
+ await self._controller.set_preset_mode(self._device_id, ih_preset_mode)
+
async def async_set_swing_mode(self, swing_mode):
"""Set the vertical vane."""
- if swing_mode == SWING_OFF:
- await self._controller.set_vertical_vane(self._device_id, "auto/stop")
- await self._controller.set_horizontal_vane(self._device_id, "auto/stop")
- elif swing_mode == SWING_BOTH:
- await self._controller.set_vertical_vane(self._device_id, "swing")
- await self._controller.set_horizontal_vane(self._device_id, "swing")
- elif swing_mode == SWING_HORIZONTAL:
- await self._controller.set_vertical_vane(self._device_id, "auto/stop")
- await self._controller.set_horizontal_vane(self._device_id, "swing")
- elif swing_mode == SWING_VERTICAL:
- await self._controller.set_vertical_vane(self._device_id, "swing")
- await self._controller.set_horizontal_vane(self._device_id, "auto/stop")
- self._swing = swing_mode
+ swing_settings = MAP_SWING_TO_IH.get(swing_mode)
+ if swing_settings:
+ await self._controller.set_vertical_vane(
+ self._device_id, swing_settings.get("vvane")
+ )
+ await self._controller.set_horizontal_vane(
+ self._device_id, swing_settings.get("hvane")
+ )
async def async_update(self):
"""Copy values from controller dictionary to climate device."""
@@ -282,19 +331,22 @@ class IntesisAC(ClimateDevice):
mode = self._controller.get_mode(self._device_id)
self._hvac_mode = MAP_IH_TO_HVAC_MODE.get(mode)
+ # Preset mode
+ preset = self._controller.get_preset_mode(self._device_id)
+ self._preset = MAP_IH_TO_PRESET_MODE.get(preset)
+
# Swing mode
# Climate module only supports one swing setting.
self._vvane = self._controller.get_vertical_swing(self._device_id)
self._hvane = self._controller.get_horizontal_swing(self._device_id)
- if self._vvane == "swing" and self._hvane == "swing":
- self._swing = SWING_BOTH
- elif self._vvane == "swing":
- self._swing = SWING_VERTICAL
- elif self._hvane == "swing":
- self._swing = SWING_HORIZONTAL
- else:
- self._swing = SWING_OFF
+ # Power usage
+ self._power_consumption_heat = self._controller.get_heat_power_consumption(
+ self._device_id
+ )
+ self._power_consumption_cool = self._controller.get_cool_power_consumption(
+ self._device_id
+ )
async def async_will_remove_from_hass(self):
"""Shutdown the controller when the device is being removed."""
@@ -357,7 +409,7 @@ class IntesisAC(ClimateDevice):
@property
def hvac_modes(self):
"""List of available operation modes."""
- return IH_HVAC_MODES
+ return self._hvac_mode_list
@property
def fan_mode(self):
@@ -367,7 +419,15 @@ class IntesisAC(ClimateDevice):
@property
def swing_mode(self):
"""Return current swing mode."""
- return self._swing
+ if self._vvane == IH_SWING_SWING and self._hvane == IH_SWING_SWING:
+ swing = SWING_BOTH
+ elif self._vvane == IH_SWING_SWING:
+ swing = SWING_VERTICAL
+ elif self._hvane == IH_SWING_SWING:
+ swing = SWING_HORIZONTAL
+ else:
+ swing = SWING_OFF
+ return swing
@property
def fan_modes(self):
diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json
index f0caf88808a..f1647f5d97e 100644
--- a/homeassistant/components/intesishome/manifest.json
+++ b/homeassistant/components/intesishome/manifest.json
@@ -4,5 +4,5 @@
"documentation": "https://www.home-assistant.io/integrations/intesishome",
"dependencies": [],
"codeowners": ["@jnimmo"],
- "requirements": ["pyintesishome==1.6"]
+ "requirements": ["pyintesishome==1.7.1"]
}
diff --git a/homeassistant/components/ipp/.translations/ca.json b/homeassistant/components/ipp/.translations/ca.json
new file mode 100644
index 00000000000..f193878d952
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/ca.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Aquesta impressora ja est\u00e0 configurada.",
+ "connection_error": "No s'ha pogut connectar amb la impressora.",
+ "connection_upgrade": "No s'ha pogut connectar amb la impressora, es necessita actualitzar la connexi\u00f3."
+ },
+ "error": {
+ "connection_error": "No s'ha pogut connectar amb la impressora."
+ },
+ "flow_title": "Impressora: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Ruta relativa a la impressora",
+ "host": "Amfitri\u00f3 o adre\u00e7a IP",
+ "port": "Port",
+ "ssl": "La impressora \u00e9s compatible amb comunicaci\u00f3 SSL/TLS",
+ "verify_ssl": "La impressora utilitza un certificat SSL adequat"
+ },
+ "description": "Configura la impressora amb el protocol d'impressi\u00f3 per Internet (IPP) per integrar-la amb Home Assistant.",
+ "title": "Enlla\u00e7 d'impressora"
+ },
+ "zeroconf_confirm": {
+ "description": "Vols afegir la impressora {name} a Home Assistant?",
+ "title": "Impressora descoberta"
+ }
+ },
+ "title": "Protocol d'impressi\u00f3 per Internet (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/da.json b/homeassistant/components/ipp/.translations/da.json
new file mode 100644
index 00000000000..ede4601928e
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/da.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Denne printer er allerede konfigureret.",
+ "connection_error": "Kunne ikke oprette forbindelse til printeren.",
+ "connection_upgrade": "Der kunne ikke oprettes forbindelse til printeren, fordi der kr\u00e6ves opgradering af forbindelsen."
+ },
+ "error": {
+ "connection_error": "Kunne ikke oprette forbindelse til printeren.",
+ "connection_upgrade": "Kunne ikke oprette forbindelse til printeren. Pr\u00f8v igen med indstillingen SSL/TLS markeret."
+ },
+ "flow_title": "Printer: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Relativ sti til printeren",
+ "host": "V\u00e6rt eller IP-adresse",
+ "port": "Port",
+ "ssl": "Printeren underst\u00f8tter kommunikation via SSL/TLS",
+ "verify_ssl": "Printeren bruger et korrekt SSL-certifikat"
+ },
+ "description": "Konfigurer din printer via Internet Printing Protocol (IPP) til at integrere med Home Assistant.",
+ "title": "Forbind din printer"
+ },
+ "zeroconf_confirm": {
+ "description": "Vil du tilf\u00f8je printeren med navnet '{name}' til Home Assistant?",
+ "title": "Fandt printer"
+ }
+ },
+ "title": "Internet Printing Protocol (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/de.json b/homeassistant/components/ipp/.translations/de.json
new file mode 100644
index 00000000000..7e72fb2403f
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/de.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dieser Drucker ist bereits konfiguriert",
+ "connection_error": "Verbindung zum Drucker fehlgeschlagen.",
+ "connection_upgrade": "Verbindung zum Drucker fehlgeschlagen, da ein Verbindungsupgrade erforderlich ist."
+ },
+ "error": {
+ "connection_error": "Verbindung zum Drucker fehlgeschlagen.",
+ "connection_upgrade": "Verbindung zum Drucker fehlgeschlagen. Bitte versuchen Sie es erneut mit aktivierter SSL / TLS-Option."
+ },
+ "flow_title": "Drucker: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Relativer Pfad zum Drucker",
+ "host": "Host oder IP-Adresse",
+ "port": "Port",
+ "ssl": "Der Drucker unterst\u00fctzt die Kommunikation \u00fcber SSL / TLS",
+ "verify_ssl": "Der Drucker verwendet ein ordnungsgem\u00e4\u00dfes SSL-Zertifikat"
+ },
+ "description": "Richten Sie Ihren Drucker \u00fcber das Internet Printing Protocol (IPP) f\u00fcr die Integration in Home Assistant ein.",
+ "title": "Verbinden Sie Ihren Drucker"
+ },
+ "zeroconf_confirm": {
+ "description": "M\u00f6chten Sie den Drucker mit dem Namen \"{name}\" zu Home Assistant hinzuf\u00fcgen?",
+ "title": "Entdeckter Drucker"
+ }
+ },
+ "title": "Internet-Druckprotokoll (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/en.json b/homeassistant/components/ipp/.translations/en.json
new file mode 100644
index 00000000000..c3fc9be6d45
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/en.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This printer is already configured.",
+ "connection_error": "Failed to connect to printer.",
+ "connection_upgrade": "Failed to connect to printer due to connection upgrade being required.",
+ "parse_error": "Failed to parse response from printer."
+ },
+ "error": {
+ "connection_error": "Failed to connect to printer.",
+ "connection_upgrade": "Failed to connect to printer. Please try again with SSL/TLS option checked."
+ },
+ "flow_title": "Printer: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Relative path to the printer",
+ "host": "Host or IP address",
+ "port": "Port",
+ "ssl": "Printer supports communication over SSL/TLS",
+ "verify_ssl": "Printer uses a proper SSL certificate"
+ },
+ "description": "Set up your printer via Internet Printing Protocol (IPP) to integrate with Home Assistant.",
+ "title": "Link your printer"
+ },
+ "zeroconf_confirm": {
+ "description": "Do you want to add the printer named `{name}` to Home Assistant?",
+ "title": "Discovered printer"
+ }
+ },
+ "title": "Internet Printing Protocol (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/es.json b/homeassistant/components/ipp/.translations/es.json
new file mode 100644
index 00000000000..6e86f702902
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/es.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Esta impresora ya est\u00e1 configurada.",
+ "connection_error": "No se pudo conectar con la impresora.",
+ "connection_upgrade": "No se pudo conectar con la impresora debido a que se requiere una actualizaci\u00f3n de la conexi\u00f3n."
+ },
+ "error": {
+ "connection_error": "No se pudo conectar con la impresora.",
+ "connection_upgrade": "No se pudo conectar con la impresora. Int\u00e9ntalo de nuevo con la opci\u00f3n SSL/TLS marcada."
+ },
+ "flow_title": "Impresora: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Ruta relativa a la impresora",
+ "host": "Host o direcci\u00f3n IP",
+ "port": "Puerto",
+ "ssl": "La impresora admite la comunicaci\u00f3n a trav\u00e9s de SSL/TLS",
+ "verify_ssl": "La impresora usa un certificado SSL adecuado"
+ },
+ "description": "Configura tu impresora a trav\u00e9s del Protocolo de Impresi\u00f3n de Internet (IPP) para integrarla con Home Assistant.",
+ "title": "Vincula tu impresora"
+ },
+ "zeroconf_confirm": {
+ "description": "\u00bfQuieres a\u00f1adir la impresora llamada `{name}` a Home Assistant?",
+ "title": "Impresora encontrada"
+ }
+ },
+ "title": "Protocolo de Impresi\u00f3n de Internet (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/ko.json b/homeassistant/components/ipp/.translations/ko.json
new file mode 100644
index 00000000000..ab556519e07
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/ko.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc774 \ud504\ub9b0\ud130\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "connection_error": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "connection_upgrade": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud558\ub824\uba74 \uc5f0\uacb0\uc744 \uc5c5\uadf8\ub808\uc774\ub4dc\ud574\uc57c \ud569\ub2c8\ub2e4."
+ },
+ "error": {
+ "connection_error": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "connection_upgrade": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. SSL/TLS \uc635\uc158\uc744 \ud655\uc778\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "flow_title": "\ud504\ub9b0\ud130: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "\ud504\ub9b0\ud130\uc758 \uc0c1\ub300 \uacbd\ub85c",
+ "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c",
+ "port": "\ud3ec\ud2b8",
+ "ssl": "\ud504\ub9b0\ud130\ub294 SSL/TLS \ub97c \ud1b5\ud55c \ud1b5\uc2e0\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4",
+ "verify_ssl": "\ud504\ub9b0\ud130\ub294 \uc62c\ubc14\ub978 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4"
+ },
+ "description": "\uc778\ud130\ub137 \uc778\uc1c4 \ud504\ub85c\ud1a0\ucf5c (IPP) \ub97c \ud1b5\ud574 \ud504\ub9b0\ud130\ub97c \uc124\uc815\ud558\uc5ec Home Assistant \uc640 \uc5f0\ub3d9\ud569\ub2c8\ub2e4.",
+ "title": "\ud504\ub9b0\ud130 \uc5f0\uacb0"
+ },
+ "zeroconf_confirm": {
+ "description": "Home Assistant \uc5d0 `{name}` \ud504\ub9b0\ud130\ub97c \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "\ubc1c\uacac\ub41c \ud504\ub9b0\ud130"
+ }
+ },
+ "title": "\uc778\ud130\ub137 \uc778\uc1c4 \ud504\ub85c\ud1a0\ucf5c (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/lb.json b/homeassistant/components/ipp/.translations/lb.json
new file mode 100644
index 00000000000..bdda2cf1c14
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/lb.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "D\u00ebse Printer ass scho konfigur\u00e9iert.",
+ "connection_error": "Feeler beim verbannen mam Printer.",
+ "connection_upgrade": "Feeler beim verbannen mam Printer well eng Aktualis\u00e9ierung vun der Verbindung erfuerderlech ass."
+ },
+ "error": {
+ "connection_error": "Feeler beim verbannen mam Printer.",
+ "connection_upgrade": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol mat aktiv\u00e9ierter SSL/TLS Optioun."
+ },
+ "flow_title": "Printer: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Relative Pad zum Printer",
+ "host": "Numm oder IP Adresse",
+ "port": "Port",
+ "ssl": "Printer \u00ebnnerst\u00ebtze Kommunikatioun iwwer SSL/TLS",
+ "verify_ssl": "Printer benotzt ee g\u00ebltegen SSL Zertifikat"
+ },
+ "description": "Konfigur\u00e9ier d\u00e4in Printer mat Internet Printing Protocol (IPP) fir en am Home Assistant z'int\u00e9gr\u00e9ieren.",
+ "title": "\u00c4re Printer verbannen"
+ },
+ "zeroconf_confirm": {
+ "description": "W\u00ebllt dir de Printer mam Numm `{name}` am Home Assistant dob\u00e4isetzen?",
+ "title": "Entdeckte Printer"
+ }
+ },
+ "title": "Internet Printing Protocol (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/no.json b/homeassistant/components/ipp/.translations/no.json
new file mode 100644
index 00000000000..2357aaaa86d
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/no.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Denne skriveren er allerede konfigurert.",
+ "connection_error": "Klarte ikke \u00e5 koble til skriveren.",
+ "connection_upgrade": "Kunne ikke koble til skriveren fordi tilkoblingsoppgradering var n\u00f8dvendig."
+ },
+ "error": {
+ "connection_error": "Klarte ikke \u00e5 koble til skriveren.",
+ "connection_upgrade": "Kunne ikke koble til skriveren. Vennligst pr\u00f8v igjen med alternativet SSL / TLS merket."
+ },
+ "flow_title": "Skriver: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "Relativ bane til skriveren",
+ "host": "Vert eller IP-adresse",
+ "port": "Port",
+ "ssl": "Skriveren st\u00f8tter kommunikasjon over SSL/TLS",
+ "verify_ssl": "Skriveren bruker et riktig SSL-sertifikat"
+ },
+ "description": "Konfigurer skriveren din via Internet Printing Protocol (IPP) for \u00e5 integrere med Home Assistant.",
+ "title": "Koble til skriveren din"
+ },
+ "zeroconf_confirm": {
+ "description": "\u00d8nsker du \u00e5 legge skriveren med navnet {name} til Home Assistant?",
+ "title": "Oppdaget skriver"
+ }
+ },
+ "title": "Internet Printing Protocol (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/ru.json b/homeassistant/components/ipp/.translations/ru.json
new file mode 100644
index 00000000000..902289b2e8f
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/ru.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0443.",
+ "connection_upgrade": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0443 \u0438\u0437-\u0437\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f."
+ },
+ "error": {
+ "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0443.",
+ "connection_upgrade": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0443. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u0447\u0435\u0440\u0435\u0437 SSL/TLS."
+ },
+ "flow_title": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "\u041e\u0442\u043d\u043e\u0441\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0443",
+ "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",
+ "ssl": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0441\u0432\u044f\u0437\u044c \u043f\u043e SSL/TLS",
+ "verify_ssl": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL"
+ },
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043d\u0442\u0435\u0440 \u043f\u043e \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0443 IPP \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Home Assistant.",
+ "title": "Internet Printing Protocol (IPP)"
+ },
+ "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 \u043f\u0440\u0438\u043d\u0442\u0435\u0440 `{name}`?",
+ "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u044b\u0439 \u043f\u0440\u0438\u043d\u0442\u0435\u0440"
+ }
+ },
+ "title": "Internet Printing Protocol (IPP)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/.translations/zh-Hant.json b/homeassistant/components/ipp/.translations/zh-Hant.json
new file mode 100644
index 00000000000..fe79b4b88cd
--- /dev/null
+++ b/homeassistant/components/ipp/.translations/zh-Hant.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64\u5370\u8868\u6a5f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "connection_error": "\u5370\u8868\u6a5f\u9023\u7dda\u5931\u6557\u3002",
+ "connection_upgrade": "\u7531\u65bc\u9700\u8981\u5148\u5347\u7d1a\u9023\u7dda\u3001\u9023\u7dda\u81f3\u5370\u8868\u6a5f\u5931\u6557\u3002"
+ },
+ "error": {
+ "connection_error": "\u5370\u8868\u6a5f\u9023\u7dda\u5931\u6557\u3002",
+ "connection_upgrade": "\u9023\u7dda\u81f3\u5370\u8868\u6a5f\u5931\u6557\u3002\u8acb\u52fe\u9078 SSL/TLS \u9078\u9805\u5f8c\u518d\u8a66\u4e00\u6b21\u3002"
+ },
+ "flow_title": "\u5370\u8868\u6a5f\uff1a{name}",
+ "step": {
+ "user": {
+ "data": {
+ "base_path": "\u5370\u8868\u6a5f\u76f8\u5c0d\u8def\u5f91",
+ "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740",
+ "port": "\u901a\u8a0a\u57e0",
+ "ssl": "\u5370\u8868\u6a5f\u652f\u63f4 SSL/TLS \u901a\u8a0a",
+ "verify_ssl": "\u5370\u8868\u6a5f\u4f7f\u7528\u5c0d\u61c9\u8a8d\u8b49"
+ },
+ "description": "\u900f\u904e\u7db2\u969b\u7db2\u8def\u5217\u5370\u5354\u5b9a\uff08IPP\uff09\u8a2d\u5b9a\u5370\u8868\u6a5f\u4ee5\u6574\u5408\u81f3 Home Assistant\u3002",
+ "title": "\u9023\u7d50\u5370\u8868\u6a5f"
+ },
+ "zeroconf_confirm": {
+ "description": "\u662f\u5426\u8981\u65b0\u589e\u540d\u7a31 `{name}` \u5370\u8868\u6a5f\u81f3 Home Assistant\uff1f",
+ "title": "\u81ea\u52d5\u641c\u7d22\u5230\u7684\u5370\u8868\u6a5f"
+ }
+ },
+ "title": "\u7db2\u969b\u7db2\u8def\u5217\u5370\u5354\u5b9a\uff08IPP\uff09"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py
new file mode 100644
index 00000000000..447665a3676
--- /dev/null
+++ b/homeassistant/components/ipp/__init__.py
@@ -0,0 +1,190 @@
+"""The Internet Printing Protocol (IPP) integration."""
+import asyncio
+from datetime import timedelta
+import logging
+from typing import Any, Dict
+
+from pyipp import IPP, IPPError, Printer as IPPPrinter
+
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ ATTR_NAME,
+ CONF_HOST,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import (
+ ATTR_IDENTIFIERS,
+ ATTR_MANUFACTURER,
+ ATTR_MODEL,
+ ATTR_SOFTWARE_VERSION,
+ CONF_BASE_PATH,
+ DOMAIN,
+)
+
+PLATFORMS = [SENSOR_DOMAIN]
+SCAN_INTERVAL = timedelta(seconds=60)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
+ """Set up the IPP component."""
+ hass.data.setdefault(DOMAIN, {})
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up IPP from a config entry."""
+
+ # Create IPP instance for this entry
+ coordinator = IPPDataUpdateCoordinator(
+ hass,
+ host=entry.data[CONF_HOST],
+ port=entry.data[CONF_PORT],
+ base_path=entry.data[CONF_BASE_PATH],
+ tls=entry.data[CONF_SSL],
+ verify_ssl=entry.data[CONF_VERIFY_SSL],
+ )
+ await coordinator.async_refresh()
+
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][entry.entry_id] = coordinator
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """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 IPPDataUpdateCoordinator(DataUpdateCoordinator):
+ """Class to manage fetching IPP data from single endpoint."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ *,
+ host: str,
+ port: int,
+ base_path: str,
+ tls: bool,
+ verify_ssl: bool,
+ ):
+ """Initialize global IPP data updater."""
+ self.ipp = IPP(
+ host=host,
+ port=port,
+ base_path=base_path,
+ tls=tls,
+ verify_ssl=verify_ssl,
+ session=async_get_clientsession(hass, verify_ssl),
+ )
+
+ super().__init__(
+ hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL,
+ )
+
+ async def _async_update_data(self) -> IPPPrinter:
+ """Fetch data from IPP."""
+ try:
+ return await self.ipp.printer()
+ except IPPError as error:
+ raise UpdateFailed(f"Invalid response from API: {error}")
+
+
+class IPPEntity(Entity):
+ """Defines a base IPP entity."""
+
+ def __init__(
+ self,
+ *,
+ entry_id: str,
+ coordinator: IPPDataUpdateCoordinator,
+ name: str,
+ icon: str,
+ enabled_default: bool = True,
+ ) -> None:
+ """Initialize the IPP entity."""
+ self._enabled_default = enabled_default
+ self._entry_id = entry_id
+ self._icon = icon
+ self._name = name
+ self._unsub_dispatcher = None
+ self.coordinator = coordinator
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def icon(self) -> str:
+ """Return the mdi icon of the entity."""
+ return self._icon
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.coordinator.last_update_success
+
+ @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 should_poll(self) -> bool:
+ """Return the polling requirement of the entity."""
+ return False
+
+ async def async_added_to_hass(self) -> None:
+ """Connect to dispatcher listening for entity data notifications."""
+ self.coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect from update signal."""
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
+
+ async def async_update(self) -> None:
+ """Update an IPP entity."""
+ await self.coordinator.async_request_refresh()
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this IPP device."""
+ return {
+ ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.uuid)},
+ ATTR_NAME: self.coordinator.data.info.name,
+ ATTR_MANUFACTURER: self.coordinator.data.info.manufacturer,
+ ATTR_MODEL: self.coordinator.data.info.model,
+ ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version,
+ }
diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py
new file mode 100644
index 00000000000..fe0808414ad
--- /dev/null
+++ b/homeassistant/components/ipp/config_flow.py
@@ -0,0 +1,157 @@
+"""Config flow to configure the IPP integration."""
+import logging
+from typing import Any, Dict, Optional
+
+from pyipp import (
+ IPP,
+ IPPConnectionError,
+ IPPConnectionUpgradeRequired,
+ IPPParseError,
+ IPPResponseError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from .const import CONF_BASE_PATH, CONF_UUID
+from .const import DOMAIN # pylint: disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]:
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+ session = async_get_clientsession(hass)
+ ipp = IPP(
+ host=data[CONF_HOST],
+ port=data[CONF_PORT],
+ base_path=data[CONF_BASE_PATH],
+ tls=data[CONF_SSL],
+ verify_ssl=data[CONF_VERIFY_SSL],
+ session=session,
+ )
+
+ printer = await ipp.printer()
+
+ return {CONF_UUID: printer.info.uuid}
+
+
+class IPPFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle an IPP config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Set up the instance."""
+ self.discovery_info = {}
+
+ async def async_step_user(
+ self, user_input: Optional[ConfigType] = None
+ ) -> Dict[str, Any]:
+ """Handle a flow initiated by the user."""
+ if user_input is None:
+ return self._show_setup_form()
+
+ try:
+ info = await validate_input(self.hass, user_input)
+ except IPPConnectionUpgradeRequired:
+ return self._show_setup_form({"base": "connection_upgrade"})
+ except (IPPConnectionError, IPPResponseError):
+ return self._show_setup_form({"base": "connection_error"})
+ except IPPParseError:
+ _LOGGER.exception("IPP Parse Error")
+ return self.async_abort(reason="parse_error")
+
+ user_input[CONF_UUID] = info[CONF_UUID]
+
+ await self.async_set_unique_id(user_input[CONF_UUID])
+ self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
+
+ return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
+
+ async def async_step_zeroconf(self, discovery_info: ConfigType) -> Dict[str, Any]:
+ """Handle zeroconf discovery."""
+ # Hostname is format: EPSON123456.local.
+ host = discovery_info["hostname"].rstrip(".")
+ port = discovery_info["port"]
+ name, _ = host.rsplit(".")
+ tls = discovery_info["type"] == "_ipps._tcp.local."
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ self.context.update({"title_placeholders": {"name": name}})
+
+ self.discovery_info.update(
+ {
+ CONF_HOST: discovery_info[CONF_HOST],
+ CONF_PORT: port,
+ CONF_SSL: tls,
+ CONF_VERIFY_SSL: False,
+ CONF_BASE_PATH: "/"
+ + discovery_info["properties"].get("rp", "ipp/print"),
+ CONF_NAME: name,
+ CONF_UUID: discovery_info["properties"].get("UUID"),
+ }
+ )
+
+ try:
+ info = await validate_input(self.hass, self.discovery_info)
+ except IPPConnectionUpgradeRequired:
+ return self.async_abort(reason="connection_upgrade")
+ except (IPPConnectionError, IPPResponseError):
+ return self.async_abort(reason="connection_error")
+ except IPPParseError:
+ _LOGGER.exception("IPP Parse Error")
+ return self.async_abort(reason="parse_error")
+
+ self.discovery_info[CONF_UUID] = info[CONF_UUID]
+
+ await self.async_set_unique_id(self.discovery_info[CONF_UUID])
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: self.discovery_info[CONF_HOST]}
+ )
+
+ return await self.async_step_zeroconf_confirm()
+
+ async def async_step_zeroconf_confirm(
+ self, user_input: ConfigType = None
+ ) -> Dict[str, Any]:
+ """Handle a confirmation flow initiated by zeroconf."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="zeroconf_confirm",
+ description_placeholders={"name": self.discovery_info[CONF_NAME]},
+ errors={},
+ )
+
+ return self.async_create_entry(
+ title=self.discovery_info[CONF_NAME], data=self.discovery_info,
+ )
+
+ def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
+ """Show the setup form to the user."""
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=631): int,
+ vol.Required(CONF_BASE_PATH, default="/ipp/print"): str,
+ vol.Required(CONF_SSL, default=False): bool,
+ vol.Required(CONF_VERIFY_SSL, default=False): bool,
+ }
+ ),
+ errors=errors or {},
+ )
diff --git a/homeassistant/components/ipp/const.py b/homeassistant/components/ipp/const.py
new file mode 100644
index 00000000000..7caf60b7edd
--- /dev/null
+++ b/homeassistant/components/ipp/const.py
@@ -0,0 +1,25 @@
+"""Constants for the IPP integration."""
+
+# Integration domain
+DOMAIN = "ipp"
+
+# Attributes
+ATTR_COMMAND_SET = "command_set"
+ATTR_IDENTIFIERS = "identifiers"
+ATTR_INFO = "info"
+ATTR_LOCATION = "location"
+ATTR_MANUFACTURER = "manufacturer"
+ATTR_MARKER_TYPE = "marker_type"
+ATTR_MARKER_LOW_LEVEL = "marker_low_level"
+ATTR_MARKER_HIGH_LEVEL = "marker_high_level"
+ATTR_MODEL = "model"
+ATTR_SERIAL = "serial"
+ATTR_SOFTWARE_VERSION = "sw_version"
+ATTR_STATE_MESSAGE = "state_message"
+ATTR_STATE_REASON = "state_reason"
+ATTR_URI_SUPPORTED = "uri_supported"
+
+# Config Keys
+CONF_BASE_PATH = "base_path"
+CONF_TLS = "tls"
+CONF_UUID = "uuid"
diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json
new file mode 100644
index 00000000000..9e491a54896
--- /dev/null
+++ b/homeassistant/components/ipp/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ipp",
+ "name": "Internet Printing Protocol (IPP)",
+ "documentation": "https://www.home-assistant.io/integrations/ipp",
+ "requirements": ["pyipp==0.9.0"],
+ "codeowners": ["@ctalkington"],
+ "config_flow": true,
+ "quality_scale": "platinum",
+ "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
+}
diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py
new file mode 100644
index 00000000000..1ce162500c5
--- /dev/null
+++ b/homeassistant/components/ipp/sensor.py
@@ -0,0 +1,178 @@
+"""Support for IPP sensors."""
+from datetime import timedelta
+from typing import Any, Callable, Dict, List, Optional, Union
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP, UNIT_PERCENTAGE
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util.dt import utcnow
+
+from . import IPPDataUpdateCoordinator, IPPEntity
+from .const import (
+ ATTR_COMMAND_SET,
+ ATTR_INFO,
+ ATTR_LOCATION,
+ ATTR_MARKER_HIGH_LEVEL,
+ ATTR_MARKER_LOW_LEVEL,
+ ATTR_MARKER_TYPE,
+ ATTR_SERIAL,
+ ATTR_STATE_MESSAGE,
+ ATTR_STATE_REASON,
+ ATTR_URI_SUPPORTED,
+ DOMAIN,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType,
+ entry: ConfigEntry,
+ async_add_entities: Callable[[List[Entity], bool], None],
+) -> None:
+ """Set up IPP sensor based on a config entry."""
+ coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+
+ sensors = []
+
+ sensors.append(IPPPrinterSensor(entry.entry_id, coordinator))
+ sensors.append(IPPUptimeSensor(entry.entry_id, coordinator))
+
+ for marker_index in range(len(coordinator.data.markers)):
+ sensors.append(IPPMarkerSensor(entry.entry_id, coordinator, marker_index))
+
+ async_add_entities(sensors, True)
+
+
+class IPPSensor(IPPEntity):
+ """Defines an IPP sensor."""
+
+ def __init__(
+ self,
+ *,
+ coordinator: IPPDataUpdateCoordinator,
+ enabled_default: bool = True,
+ entry_id: str,
+ icon: str,
+ key: str,
+ name: str,
+ unit_of_measurement: Optional[str] = None,
+ ) -> None:
+ """Initialize IPP sensor."""
+ self._unit_of_measurement = unit_of_measurement
+ self._key = key
+
+ super().__init__(
+ entry_id=entry_id,
+ coordinator=coordinator,
+ name=name,
+ icon=icon,
+ enabled_default=enabled_default,
+ )
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID for this sensor."""
+ return f"{self.coordinator.data.info.uuid}_{self._key}"
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+
+class IPPMarkerSensor(IPPSensor):
+ """Defines an IPP marker sensor."""
+
+ def __init__(
+ self, entry_id: str, coordinator: IPPDataUpdateCoordinator, marker_index: int
+ ) -> None:
+ """Initialize IPP marker sensor."""
+ self.marker_index = marker_index
+
+ super().__init__(
+ coordinator=coordinator,
+ entry_id=entry_id,
+ icon="mdi:water",
+ key=f"marker_{marker_index}",
+ name=f"{coordinator.data.info.name} {coordinator.data.markers[marker_index].name}",
+ unit_of_measurement=UNIT_PERCENTAGE,
+ )
+
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ return {
+ ATTR_MARKER_HIGH_LEVEL: self.coordinator.data.markers[
+ self.marker_index
+ ].high_level,
+ ATTR_MARKER_LOW_LEVEL: self.coordinator.data.markers[
+ self.marker_index
+ ].low_level,
+ ATTR_MARKER_TYPE: self.coordinator.data.markers[
+ self.marker_index
+ ].marker_type,
+ }
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.markers[self.marker_index].level
+
+
+class IPPPrinterSensor(IPPSensor):
+ """Defines an IPP printer sensor."""
+
+ def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None:
+ """Initialize IPP printer sensor."""
+ super().__init__(
+ coordinator=coordinator,
+ entry_id=entry_id,
+ icon="mdi:printer",
+ key="printer",
+ name=coordinator.data.info.name,
+ unit_of_measurement=None,
+ )
+
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ return {
+ ATTR_INFO: self.coordinator.data.info.printer_info,
+ ATTR_SERIAL: self.coordinator.data.info.serial,
+ ATTR_LOCATION: self.coordinator.data.info.location,
+ ATTR_STATE_MESSAGE: self.coordinator.data.state.message,
+ ATTR_STATE_REASON: self.coordinator.data.state.reasons,
+ ATTR_COMMAND_SET: self.coordinator.data.info.command_set,
+ ATTR_URI_SUPPORTED: self.coordinator.data.info.printer_uri_supported,
+ }
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.state.printer_state
+
+
+class IPPUptimeSensor(IPPSensor):
+ """Defines a IPP uptime sensor."""
+
+ def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None:
+ """Initialize IPP uptime sensor."""
+ super().__init__(
+ coordinator=coordinator,
+ enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:clock-outline",
+ key="uptime",
+ name=f"{coordinator.data.info.name} Uptime",
+ )
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime)
+ return uptime.replace(microsecond=0).isoformat()
+
+ @property
+ def device_class(self) -> Optional[str]:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_TIMESTAMP
diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json
new file mode 100644
index 00000000000..a80a7f2e0ba
--- /dev/null
+++ b/homeassistant/components/ipp/strings.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "title": "Internet Printing Protocol (IPP)",
+ "flow_title": "Printer: {name}",
+ "step": {
+ "user": {
+ "title": "Link your printer",
+ "description": "Set up your printer via Internet Printing Protocol (IPP) to integrate with Home Assistant.",
+ "data": {
+ "host": "Host or IP address",
+ "port": "Port",
+ "base_path": "Relative path to the printer",
+ "ssl": "Printer supports communication over SSL/TLS",
+ "verify_ssl": "Printer uses a proper SSL certificate"
+ }
+ },
+ "zeroconf_confirm": {
+ "description": "Do you want to add the printer named `{name}` to Home Assistant?",
+ "title": "Discovered printer"
+ }
+ },
+ "error": {
+ "connection_error": "Failed to connect to printer.",
+ "connection_upgrade": "Failed to connect to printer. Please try again with SSL/TLS option checked."
+ },
+ "abort": {
+ "already_configured": "This printer is already configured.",
+ "connection_error": "Failed to connect to printer.",
+ "connection_upgrade": "Failed to connect to printer due to connection upgrade being required.",
+ "parse_error": "Failed to parse response from printer."
+ }
+ }
+}
diff --git a/homeassistant/components/iqvia/.translations/no.json b/homeassistant/components/iqvia/.translations/no.json
index f04caf5bc8b..9ccca663fe5 100644
--- a/homeassistant/components/iqvia/.translations/no.json
+++ b/homeassistant/components/iqvia/.translations/no.json
@@ -10,7 +10,7 @@
"zip_code": "Postnummer"
},
"description": "Fyll ut ditt amerikanske eller kanadiske postnummer.",
- "title": "IQVIA"
+ "title": ""
}
},
"title": "IQVIA"
diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py
index a33dabeadeb..1f487dd345c 100644
--- a/homeassistant/components/iqvia/__init__.py
+++ b/homeassistant/components/iqvia/__init__.py
@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from pyiqvia import Client
-from pyiqvia.errors import InvalidZipError
+from pyiqvia.errors import InvalidZipError, IQVIAError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
@@ -17,7 +17,6 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
-from homeassistant.util.decorator import Registry
from .config_flow import configured_instances
from .const import (
@@ -43,20 +42,20 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+API_CATEGORY_MAPPING = {
+ TYPE_ALLERGY_TODAY: TYPE_ALLERGY_INDEX,
+ TYPE_ALLERGY_TOMORROW: TYPE_ALLERGY_INDEX,
+ TYPE_ALLERGY_TOMORROW: TYPE_ALLERGY_INDEX,
+ TYPE_ASTHMA_TODAY: TYPE_ASTHMA_INDEX,
+ TYPE_ASTHMA_TOMORROW: TYPE_ALLERGY_INDEX,
+ TYPE_DISEASE_TODAY: TYPE_DISEASE_INDEX,
+}
+
DATA_CONFIG = "config"
DEFAULT_ATTRIBUTION = "Data provided by IQVIA™"
DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)
-FETCHER_MAPPING = {
- (TYPE_ALLERGY_FORECAST,): (TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK),
- (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): (TYPE_ALLERGY_INDEX,),
- (TYPE_ASTHMA_FORECAST,): (TYPE_ASTHMA_FORECAST,),
- (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): (TYPE_ASTHMA_INDEX,),
- (TYPE_DISEASE_FORECAST,): (TYPE_DISEASE_FORECAST,),
- (TYPE_DISEASE_TODAY,): (TYPE_DISEASE_INDEX,),
-}
-
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
@@ -75,6 +74,12 @@ CONFIG_SCHEMA = vol.Schema(
)
+@callback
+def async_get_api_category(sensor_type):
+ """Return the API category that a particular sensor type should use."""
+ return API_CATEGORY_MAPPING.get(sensor_type, sensor_type)
+
+
async def async_setup(hass, config):
"""Set up the IQVIA component."""
hass.data[DOMAIN] = {}
@@ -102,8 +107,9 @@ async def async_setup_entry(hass, config_entry):
"""Set up IQVIA as config entry."""
websession = aiohttp_client.async_get_clientsession(hass)
+ iqvia = IQVIAData(hass, Client(config_entry.data[CONF_ZIP_CODE], websession))
+
try:
- iqvia = IQVIAData(Client(config_entry.data[CONF_ZIP_CODE], websession))
await iqvia.async_update()
except InvalidZipError:
_LOGGER.error("Invalid ZIP code provided: %s", config_entry.data[CONF_ZIP_CODE])
@@ -115,16 +121,6 @@ async def async_setup_entry(hass, config_entry):
hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
)
- async def refresh(event_time):
- """Refresh IQVIA data."""
- _LOGGER.debug("Updating IQVIA data")
- await iqvia.async_update()
- async_dispatcher_send(hass, TOPIC_DATA_UPDATE)
-
- hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval(
- hass, refresh, DEFAULT_SCAN_INTERVAL
- )
-
return True
@@ -143,42 +139,99 @@ async def async_unload_entry(hass, config_entry):
class IQVIAData:
"""Define a data object to retrieve info from IQVIA."""
- def __init__(self, client):
+ def __init__(self, hass, client):
"""Initialize."""
+ self._async_cancel_time_interval_listener = None
self._client = client
+ self._hass = hass
self.data = {}
self.zip_code = client.zip_code
- self.fetchers = Registry()
- self.fetchers.register(TYPE_ALLERGY_FORECAST)(self._client.allergens.extended)
- self.fetchers.register(TYPE_ALLERGY_OUTLOOK)(self._client.allergens.outlook)
- self.fetchers.register(TYPE_ALLERGY_INDEX)(self._client.allergens.current)
- self.fetchers.register(TYPE_ASTHMA_FORECAST)(self._client.asthma.extended)
- self.fetchers.register(TYPE_ASTHMA_INDEX)(self._client.asthma.current)
- self.fetchers.register(TYPE_DISEASE_FORECAST)(self._client.disease.extended)
- self.fetchers.register(TYPE_DISEASE_INDEX)(self._client.disease.current)
+ self._api_coros = {
+ TYPE_ALLERGY_FORECAST: client.allergens.extended,
+ TYPE_ALLERGY_INDEX: client.allergens.current,
+ TYPE_ALLERGY_OUTLOOK: client.allergens.outlook,
+ TYPE_ASTHMA_FORECAST: client.asthma.extended,
+ TYPE_ASTHMA_INDEX: client.asthma.current,
+ TYPE_DISEASE_FORECAST: client.disease.extended,
+ TYPE_DISEASE_INDEX: client.disease.current,
+ }
+ self._api_category_count = {
+ TYPE_ALLERGY_FORECAST: 0,
+ TYPE_ALLERGY_INDEX: 0,
+ TYPE_ALLERGY_OUTLOOK: 0,
+ TYPE_ASTHMA_FORECAST: 0,
+ TYPE_ASTHMA_INDEX: 0,
+ TYPE_DISEASE_FORECAST: 0,
+ TYPE_DISEASE_INDEX: 0,
+ }
+ self._api_category_locks = {
+ TYPE_ALLERGY_FORECAST: asyncio.Lock(),
+ TYPE_ALLERGY_INDEX: asyncio.Lock(),
+ TYPE_ALLERGY_OUTLOOK: asyncio.Lock(),
+ TYPE_ASTHMA_FORECAST: asyncio.Lock(),
+ TYPE_ASTHMA_INDEX: asyncio.Lock(),
+ TYPE_DISEASE_FORECAST: asyncio.Lock(),
+ TYPE_DISEASE_INDEX: asyncio.Lock(),
+ }
+
+ async def _async_get_data_from_api(self, api_category):
+ """Update and save data for a particular API category."""
+ if self._api_category_count[api_category] == 0:
+ return
+
+ try:
+ self.data[api_category] = await self._api_coros[api_category]()
+ except IQVIAError as err:
+ _LOGGER.error("Unable to get %s data: %s", api_category, err)
+ self.data[api_category] = None
+
+ 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_api_interest(self, sensor_type):
+ """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
+
+ api_category = async_get_api_category(sensor_type)
+ self._api_category_count[api_category] -= 1
+
+ async def async_register_api_interest(self, sensor_type):
+ """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, DEFAULT_SCAN_INTERVAL,
+ )
+
+ api_category = async_get_api_category(sensor_type)
+ 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_get_data_from_api(api_category)
async def async_update(self):
"""Update IQVIA data."""
- tasks = {}
+ tasks = [
+ self._async_get_data_from_api(api_category)
+ for api_category in self._api_coros
+ ]
- for conditions, fetcher_types in FETCHER_MAPPING.items():
- if not any(c in SENSORS for c in conditions):
- continue
+ await asyncio.gather(*tasks)
- for fetcher_type in fetcher_types:
- tasks[fetcher_type] = self.fetchers[fetcher_type]()
-
- results = await asyncio.gather(*tasks.values(), return_exceptions=True)
-
- for key, result in zip(tasks, results):
- if isinstance(result, Exception):
- _LOGGER.error("Unable to get %s data: %s", key, result)
- self.data[key] = {}
- continue
-
- _LOGGER.debug("Loaded new %s data", key)
- self.data[key] = result
+ _LOGGER.debug("Received new data")
+ async_dispatcher_send(self._hass, TOPIC_DATA_UPDATE)
class IQVIAEntity(Entity):
@@ -245,13 +298,34 @@ class IQVIAEntity(Entity):
@callback
def update():
"""Update the state."""
- self.async_schedule_update_ha_state(True)
+ self.update_from_latest_data()
+ self.async_write_ha_state()
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, TOPIC_DATA_UPDATE, update
)
+ await self._iqvia.async_register_api_interest(self._type)
+ if self._type == TYPE_ALLERGY_FORECAST:
+ # Entities that express interest in allergy forecast data should also
+ # express interest in allergy outlook data:
+ await self._iqvia.async_register_api_interest(TYPE_ALLERGY_OUTLOOK)
+
+ self.update_from_latest_data()
+
async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
+ self._async_unsub_dispatcher_connect = None
+
+ self._iqvia.async_deregister_api_interest(self._type)
+ if self._type == TYPE_ALLERGY_FORECAST:
+ # Entities that lose interest in allergy forecast data should also lose
+ # interest in allergy outlook data:
+ self._iqvia.async_deregister_api_interest(TYPE_ALLERGY_OUTLOOK)
+
+ @callback
+ def update_from_latest_data(self):
+ """Update the entity's state."""
+ raise NotImplementedError()
diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py
index 52e657bc2c0..95b03485597 100644
--- a/homeassistant/components/iqvia/const.py
+++ b/homeassistant/components/iqvia/const.py
@@ -25,9 +25,9 @@ SENSORS = {
TYPE_ALLERGY_FORECAST: ("Allergy Index: Forecasted Average", "mdi:flower"),
TYPE_ALLERGY_TODAY: ("Allergy Index: Today", "mdi:flower"),
TYPE_ALLERGY_TOMORROW: ("Allergy Index: Tomorrow", "mdi:flower"),
+ TYPE_ASTHMA_FORECAST: ("Asthma Index: Forecasted Average", "mdi:flower"),
TYPE_ASTHMA_TODAY: ("Asthma Index: Today", "mdi:flower"),
TYPE_ASTHMA_TOMORROW: ("Asthma Index: Tomorrow", "mdi:flower"),
- TYPE_ASTHMA_FORECAST: ("Asthma Index: Forecasted Average", "mdi:flower"),
TYPE_DISEASE_FORECAST: ("Cold & Flu: Forecasted Average", "mdi:snowflake"),
TYPE_DISEASE_TODAY: ("Cold & Flu Index: Today", "mdi:pill"),
}
diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py
index 1aae63a4908..5db4456b3c6 100644
--- a/homeassistant/components/iqvia/sensor.py
+++ b/homeassistant/components/iqvia/sensor.py
@@ -7,7 +7,6 @@ import numpy as np
from homeassistant.components.iqvia import (
DATA_CLIENT,
DOMAIN,
- SENSORS,
TYPE_ALLERGY_FORECAST,
TYPE_ALLERGY_INDEX,
TYPE_ALLERGY_OUTLOOK,
@@ -23,6 +22,9 @@ from homeassistant.components.iqvia import (
IQVIAEntity,
)
from homeassistant.const import ATTR_STATE
+from homeassistant.core import callback
+
+from .const import SENSORS
_LOGGER = logging.getLogger(__name__)
@@ -65,13 +67,14 @@ async def async_setup_entry(hass, entry, async_add_entities):
TYPE_DISEASE_TODAY: IndexSensor,
}
- sensors = []
- for sensor_type in SENSORS:
- klass = sensor_class_mapping[sensor_type]
- name, icon = SENSORS[sensor_type]
- sensors.append(klass(iqvia, sensor_type, name, icon, iqvia.zip_code))
-
- async_add_entities(sensors, True)
+ async_add_entities(
+ [
+ sensor_class_mapping[sensor_type](
+ iqvia, sensor_type, name, icon, iqvia.zip_code
+ )
+ for sensor_type, (name, icon) in SENSORS.items()
+ ]
+ )
def calculate_trend(indices):
@@ -93,9 +96,10 @@ def calculate_trend(indices):
class ForecastSensor(IQVIAEntity):
"""Define sensor related to forecast data."""
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Update the sensor."""
- if not self._iqvia.data:
+ if not self._iqvia.data.get(self._type):
return
data = self._iqvia.data[self._type].get("Location")
@@ -131,12 +135,10 @@ class ForecastSensor(IQVIAEntity):
class IndexSensor(IQVIAEntity):
"""Define sensor related to indices."""
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Update the sensor."""
if not self._iqvia.data:
- _LOGGER.warning(
- "IQVIA didn't return data for %s; trying again later", self.name
- )
return
try:
@@ -147,9 +149,6 @@ class IndexSensor(IQVIAEntity):
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()
@@ -157,9 +156,6 @@ class IndexSensor(IQVIAEntity):
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] = [
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index ebd1b0dbbb2..f0766c4e4f9 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -143,6 +143,8 @@ NODE_FILTERS = {
"Siren",
"Siren_ADV",
"X10",
+ "KeypadRelay",
+ "KeypadRelay_ADV",
],
"insteon_type": ["2.", "9.10.", "9.11.", "113."],
},
diff --git a/homeassistant/components/izone/.translations/no.json b/homeassistant/components/izone/.translations/no.json
index 9068b18c82d..6af3c9b063b 100644
--- a/homeassistant/components/izone/.translations/no.json
+++ b/homeassistant/components/izone/.translations/no.json
@@ -7,9 +7,9 @@
"step": {
"confirm": {
"description": "Vil du konfigurere iZone?",
- "title": "iZone"
+ "title": ""
}
},
- "title": "iZone"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json
index 135f8e1cf54..4af0626ace9 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.7", "getmac==0.8.1"]
+ "requirements": ["aiokef==0.2.9", "getmac==0.8.1"]
}
diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py
index d4a1d7a4df3..2a227212006 100644
--- a/homeassistant/components/kef/media_player.py
+++ b/homeassistant/components/kef/media_player.py
@@ -1,11 +1,13 @@
"""Platform for the KEF Wireless Speakers."""
+import asyncio
from datetime import timedelta
from functools import partial
import ipaddress
import logging
from aiokef import AsyncKefSpeaker
+from aiokef.aiokef import DSP_OPTION_MAPPING
from getmac import get_mac_address
import voluptuous as vol
@@ -31,7 +33,8 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
+from homeassistant.helpers.event import async_track_time_interval
_LOGGER = logging.getLogger(__name__)
@@ -55,6 +58,17 @@ CONF_INVERSE_SPEAKER_MODE = "inverse_speaker_mode"
CONF_SUPPORTS_ON = "supports_on"
CONF_STANDBY_TIME = "standby_time"
+SERVICE_MODE = "set_mode"
+SERVICE_DESK_DB = "set_desk_db"
+SERVICE_WALL_DB = "set_wall_db"
+SERVICE_TREBLE_DB = "set_treble_db"
+SERVICE_HIGH_HZ = "set_high_hz"
+SERVICE_LOW_HZ = "set_low_hz"
+SERVICE_SUB_DB = "set_sub_db"
+SERVICE_UPDATE_DSP = "update_dsp"
+
+DSP_SCAN_INTERVAL = 3600
+
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
@@ -118,6 +132,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
inverse_speaker_mode,
supports_on,
sources,
+ speaker_type,
ioloop=hass.loop,
unique_id=unique_id,
)
@@ -128,6 +143,36 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
hass.data[DOMAIN][host] = media_player
async_add_entities([media_player], update_before_add=True)
+ platform = entity_platform.current_platform.get()
+
+ platform.async_register_entity_service(
+ SERVICE_MODE,
+ {
+ vol.Optional("desk_mode"): cv.boolean,
+ vol.Optional("wall_mode"): cv.boolean,
+ vol.Optional("phase_correction"): cv.boolean,
+ vol.Optional("high_pass"): cv.boolean,
+ vol.Optional("sub_polarity"): vol.In(["-", "+"]),
+ vol.Optional("bass_extension"): vol.In(["Less", "Standard", "Extra"]),
+ },
+ "set_mode",
+ )
+ platform.async_register_entity_service(SERVICE_UPDATE_DSP, {}, "update_dsp")
+
+ def add_service(name, which, option):
+ platform.async_register_entity_service(
+ name,
+ {vol.Required(option): vol.In(DSP_OPTION_MAPPING[which])},
+ f"set_{which}",
+ )
+
+ add_service(SERVICE_DESK_DB, "desk_db", "db_value")
+ add_service(SERVICE_WALL_DB, "wall_db", "db_value")
+ add_service(SERVICE_TREBLE_DB, "treble_db", "db_value")
+ add_service(SERVICE_HIGH_HZ, "high_hz", "hz_value")
+ add_service(SERVICE_LOW_HZ, "low_hz", "hz_value")
+ add_service(SERVICE_SUB_DB, "sub_db", "db_value")
+
class KefMediaPlayer(MediaPlayerDevice):
"""Kef Player Object."""
@@ -143,6 +188,7 @@ class KefMediaPlayer(MediaPlayerDevice):
inverse_speaker_mode,
supports_on,
sources,
+ speaker_type,
ioloop,
unique_id,
):
@@ -160,12 +206,15 @@ class KefMediaPlayer(MediaPlayerDevice):
)
self._unique_id = unique_id
self._supports_on = supports_on
+ self._speaker_type = speaker_type
self._state = None
self._muted = None
self._source = None
self._volume = None
self._is_online = None
+ self._dsp = None
+ self._update_dsp_task_remover = None
@property
def name(self):
@@ -190,6 +239,9 @@ class KefMediaPlayer(MediaPlayerDevice):
state = await self._speaker.get_state()
self._source = state.source
self._state = STATE_ON if state.is_on else STATE_OFF
+ if self._dsp is None:
+ # Only do this when necessary because it is a slow operation
+ await self.update_dsp()
else:
self._muted = None
self._source = None
@@ -291,11 +343,11 @@ class KefMediaPlayer(MediaPlayerDevice):
async def async_media_play(self):
"""Send play command."""
- await self._speaker.play_pause()
+ await self._speaker.set_play_pause()
async def async_media_pause(self):
"""Send pause command."""
- await self._speaker.play_pause()
+ await self._speaker.set_play_pause()
async def async_media_previous_track(self):
"""Send previous track command."""
@@ -304,3 +356,87 @@ class KefMediaPlayer(MediaPlayerDevice):
async def async_media_next_track(self):
"""Send next track command."""
await self._speaker.next_track()
+
+ async def update_dsp(self) -> None:
+ """Update the DSP settings."""
+ if self._speaker_type == "LS50" and self._state == STATE_OFF:
+ # The LSX is able to respond when off the LS50 has to be on.
+ return
+
+ (mode, *rest) = await asyncio.gather(
+ self._speaker.get_mode(),
+ self._speaker.get_desk_db(),
+ self._speaker.get_wall_db(),
+ self._speaker.get_treble_db(),
+ self._speaker.get_high_hz(),
+ self._speaker.get_low_hz(),
+ self._speaker.get_sub_db(),
+ )
+ keys = ["desk_db", "wall_db", "treble_db", "high_hz", "low_hz", "sub_db"]
+ self._dsp = dict(zip(keys, rest), **mode._asdict())
+
+ async def async_added_to_hass(self):
+ """Subscribe to DSP updates."""
+ self._update_dsp_task_remover = async_track_time_interval(
+ self.hass, self.update_dsp, DSP_SCAN_INTERVAL
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe to DSP updates."""
+ self._update_dsp_task_remover()
+ self._update_dsp_task_remover = None
+
+ @property
+ def device_state_attributes(self):
+ """Return the DSP settings of the KEF device."""
+ return self._dsp or {}
+
+ async def set_mode(
+ self,
+ desk_mode=None,
+ wall_mode=None,
+ phase_correction=None,
+ high_pass=None,
+ sub_polarity=None,
+ bass_extension=None,
+ ):
+ """Set the speaker mode."""
+ await self._speaker.set_mode(
+ desk_mode=desk_mode,
+ wall_mode=wall_mode,
+ phase_correction=phase_correction,
+ high_pass=high_pass,
+ sub_polarity=sub_polarity,
+ bass_extension=bass_extension,
+ )
+ self._dsp = None
+
+ async def set_desk_db(self, db_value):
+ """Set desk_db of the KEF speakers."""
+ await self._speaker.set_desk_db(db_value)
+ self._dsp = None
+
+ async def set_wall_db(self, db_value):
+ """Set wall_db of the KEF speakers."""
+ await self._speaker.set_wall_db(db_value)
+ self._dsp = None
+
+ async def set_treble_db(self, db_value):
+ """Set treble_db of the KEF speakers."""
+ await self._speaker.set_treble_db(db_value)
+ self._dsp = None
+
+ async def set_high_hz(self, hz_value):
+ """Set high_hz of the KEF speakers."""
+ await self._speaker.set_high_hz(hz_value)
+ self._dsp = None
+
+ async def set_low_hz(self, hz_value):
+ """Set low_hz of the KEF speakers."""
+ await self._speaker.set_low_hz(hz_value)
+ self._dsp = None
+
+ async def set_sub_db(self, db_value):
+ """Set sub_db of the KEF speakers."""
+ await self._speaker.set_sub_db(db_value)
+ self._dsp = None
diff --git a/homeassistant/components/kef/services.yaml b/homeassistant/components/kef/services.yaml
new file mode 100644
index 00000000000..2226d3b6c2d
--- /dev/null
+++ b/homeassistant/components/kef/services.yaml
@@ -0,0 +1,97 @@
+update_dsp:
+ description: Update all DSP settings.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+
+set_mode:
+ description: Set the mode of the speaker.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ desk_mode:
+ description: >
+ "Desk mode" (true or false)
+ example: true
+ wall_mode:
+ description: >
+ "Wall mode" (true or false)
+ example: true
+ phase_correction:
+ description: >
+ "Phase correction" (true or false)
+ example: true
+ high_pass:
+ description: >
+ "High-pass mode" (true or false)
+ example: true
+ sub_polarity:
+ description: >
+ "Sub polarity" ("-" or "+")
+ example: "+"
+ bass_extension:
+ description: >
+ "Bass extension" selector ("Less", "Standard", or "Extra")
+ example: "Extra"
+
+set_desk_db:
+ description: Set the "Desk mode" slider of the speaker in dB.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ db_value:
+ description: Value of the slider (-6 to 0 with steps of 0.5)
+ example: 0.0
+
+set_wall_db:
+ description: Set the "Wall mode" slider of the speaker in dB.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ db_value:
+ description: Value of the slider (-6 to 0 with steps of 0.5)
+ example: 0.0
+
+set_treble_db:
+ description: Set desk the "Treble trim" slider of the speaker in dB.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ db_value:
+ description: Value of the slider (-2 to 2 with steps of 0.5)
+ example: 0.0
+
+set_high_hz:
+ description: Set the "High-pass mode" slider of the speaker in Hz.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ hz_value:
+ description: Value of the slider (50 to 120 with steps of 5)
+ example: 95
+
+set_low_hz:
+ description: Set the "Sub out low-pass frequency" slider of the speaker in Hz.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ hz_value:
+ description: Value of the slider (40 to 250 with steps of 5)
+ example: 80
+
+set_sub_db:
+ description: Set the "Sub gain" slider of the speaker in dB.
+ fields:
+ entity_id:
+ description: The entity_id of the KEF speaker.
+ example: media_player.kef_lsx
+ db_value:
+ description: Value of the slider (-10 to 10 with steps of 1)
+ example: 0
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index edd42678a1f..c302188ff20 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -205,7 +205,7 @@ class KNXModule:
def connection_config_tunneling(self):
"""Return the connection_config if tunneling is configured."""
- gateway_ip = self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST)
+ gateway_ip = self.config[DOMAIN][CONF_KNX_TUNNELING][CONF_HOST]
gateway_port = self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT)
local_ip = self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP)
if gateway_port is None:
@@ -215,6 +215,7 @@ class KNXModule:
gateway_ip=gateway_ip,
gateway_port=gateway_port,
local_ip=local_ip,
+ auto_reconnect=True,
)
def connection_config_auto(self):
diff --git a/homeassistant/components/konnected/.translations/ca.json b/homeassistant/components/konnected/.translations/ca.json
index fbfa9183941..80e7208391b 100644
--- a/homeassistant/components/konnected/.translations/ca.json
+++ b/homeassistant/components/konnected/.translations/ca.json
@@ -91,11 +91,12 @@
"data": {
"activation": "Sortida quan estigui ON",
"momentary": "Durada del pols (ms) (opcional)",
+ "more_states": "Configura estats addicionals per a aquesta zona",
"name": "Nom (opcional)",
"pause": "Pausa entre polsos (ms) (opcional)",
"repeat": "Repeticions (-1 = infinit) (opcional)"
},
- "description": "Selecciona les opcions de sortida per a {zone}",
+ "description": "Selecciona les opcions de sortida per a {zone}: estat {state}",
"title": "Configuraci\u00f3 de sortida commutable"
}
},
diff --git a/homeassistant/components/konnected/.translations/de.json b/homeassistant/components/konnected/.translations/de.json
index fa5b1f53dfb..a0da84fd098 100644
--- a/homeassistant/components/konnected/.translations/de.json
+++ b/homeassistant/components/konnected/.translations/de.json
@@ -11,10 +11,11 @@
},
"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.",
+ "description": "Modell: {model}\nID: {id}\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": {
+ "description": "Ein Konnected Alarm Panel mit der ID {id} wurde in configuration.yaml entdeckt. Mit diesem Ablauf k\u00f6nnen Sie ihn in einen Konfigurationseintrag importieren.",
"title": "Importieren von Konnected Ger\u00e4t"
},
"user": {
@@ -32,6 +33,11 @@
"abort": {
"not_konn_panel": "Kein anerkanntes Konnected.io-Ger\u00e4t"
},
+ "error": {
+ "bad_host": "Ung\u00fcltige Override-API-Host-URL",
+ "one": "eins",
+ "other": "andere"
+ },
"step": {
"options_binary": {
"data": {
@@ -62,6 +68,7 @@
"7": "Zone 7",
"out": "OUT"
},
+ "description": "Es wurde ein {model} bei {host} entdeckt. W\u00e4hlen Sie unten die Basiskonfiguration der einzelnen E / A aus. Je nach E / A k\u00f6nnen bin\u00e4re Sensoren (Kontakte \u00f6ffnen / schlie\u00dfen), digitale Sensoren (dht und ds18b20) oder umschaltbare Ausg\u00e4nge verwendet werden. In den n\u00e4chsten Schritten k\u00f6nnen Sie detaillierte Optionen konfigurieren.",
"title": "Konfigurieren von I/O"
},
"options_io_ext": {
@@ -74,20 +81,27 @@
"alarm1": "ALARM1",
"alarm2_out2": "OUT2/ALARM2",
"out1": "OUT1"
- }
+ },
+ "description": "W\u00e4hlen Sie unten die Konfiguration der verbleibenden E / A. In den n\u00e4chsten Schritten k\u00f6nnen Sie detaillierte Optionen konfigurieren.",
+ "title": "Konfigurieren Sie Erweiterte I/O"
},
"options_misc": {
+ "data": {
+ "api_host": "API-Host-URL \u00fcberschreiben (optional)",
+ "override_api_host": "\u00dcberschreiben Sie die Standard-Host-Panel-URL der Home Assistant-API"
+ },
"description": "Bitte w\u00e4hlen Sie das gew\u00fcnschte Verhalten f\u00fcr Ihr Panel"
},
"options_switch": {
"data": {
"activation": "Ausgabe, wenn eingeschaltet",
"momentary": "Impulsdauer (ms) (optional)",
+ "more_states": "Konfigurieren Sie zus\u00e4tzliche Zust\u00e4nde f\u00fcr diese Zone",
"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}"
+ "description": "Bitte w\u00e4hlen Sie die Ausgabeoptionen f\u00fcr {zone} : Status {state}"
}
},
"title": "Konnected Alarm Panel-Optionen"
diff --git a/homeassistant/components/konnected/.translations/en.json b/homeassistant/components/konnected/.translations/en.json
index fd0a8e84e37..3ace7783f8b 100644
--- a/homeassistant/components/konnected/.translations/en.json
+++ b/homeassistant/components/konnected/.translations/en.json
@@ -33,6 +33,9 @@
"abort": {
"not_konn_panel": "Not a recognized Konnected.io device"
},
+ "error": {
+ "bad_host": "Invalid Override API host url"
+ },
"step": {
"options_binary": {
"data": {
@@ -82,7 +85,9 @@
},
"options_misc": {
"data": {
- "blink": "Blink panel LED on when sending state change"
+ "api_host": "Override API host URL (optional)",
+ "blink": "Blink panel LED on when sending state change",
+ "override_api_host": "Override default Home Assistant API host panel URL"
},
"description": "Please select the desired behavior for your panel",
"title": "Configure Misc"
@@ -91,11 +96,12 @@
"data": {
"activation": "Output when on",
"momentary": "Pulse duration (ms) (optional)",
+ "more_states": "Configure additional states for this zone",
"name": "Name (optional)",
"pause": "Pause between pulses (ms) (optional)",
"repeat": "Times to repeat (-1=infinite) (optional)"
},
- "description": "Please select the output options for {zone}",
+ "description": "Please select the output options for {zone}: state {state}",
"title": "Configure Switchable Output"
}
},
diff --git a/homeassistant/components/konnected/.translations/es.json b/homeassistant/components/konnected/.translations/es.json
index ed65b29a3b9..64069d4e756 100644
--- a/homeassistant/components/konnected/.translations/es.json
+++ b/homeassistant/components/konnected/.translations/es.json
@@ -11,7 +11,7 @@
},
"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.",
+ "description": "Modelo: {model}\nID: {id}\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"
},
"import_confirm": {
@@ -34,6 +34,7 @@
"not_konn_panel": "No es un dispositivo Konnected.io reconocido"
},
"error": {
+ "bad_host": "URL del host de la API de invalidaci\u00f3n no v\u00e1lida",
"one": "",
"other": "otros"
},
@@ -86,7 +87,9 @@
},
"options_misc": {
"data": {
- "blink": "Parpadea el LED del panel cuando se env\u00eda un cambio de estado"
+ "api_host": "Invalidar la direcci\u00f3n URL del host de la API (opcional)",
+ "blink": "Parpadea el LED del panel cuando se env\u00eda un cambio de estado",
+ "override_api_host": "Reemplazar la URL predeterminada del panel host de la API de Home Assistant"
},
"description": "Seleccione el comportamiento deseado para su panel",
"title": "Configurar miscel\u00e1neos"
@@ -95,6 +98,7 @@
"data": {
"activation": "Salida cuando est\u00e1 activada",
"momentary": "Duraci\u00f3n del pulso (ms) (opcional)",
+ "more_states": "Configurar estados adicionales para esta zona",
"name": "Nombre (opcional)",
"pause": "Pausa entre pulsos (ms) (opcional)",
"repeat": "Tiempos de repetici\u00f3n (-1 = infinito) (opcional)"
diff --git a/homeassistant/components/konnected/.translations/fr.json b/homeassistant/components/konnected/.translations/fr.json
index e6c0cded9fc..fecdd35b808 100644
--- a/homeassistant/components/konnected/.translations/fr.json
+++ b/homeassistant/components/konnected/.translations/fr.json
@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
+ "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.",
"not_konn_panel": "Non reconnu comme appareil Konnected.io",
"unknown": "Une erreur inconnue s'est produite"
},
@@ -9,6 +10,9 @@
"confirm": {
"title": "Appareil Konnected pr\u00eat"
},
+ "import_confirm": {
+ "title": "Importer un appareil connect\u00e9"
+ },
"user": {
"data": {
"host": "Adresse IP de l\u2019appareil Konnected"
diff --git a/homeassistant/components/konnected/.translations/it.json b/homeassistant/components/konnected/.translations/it.json
index 08b15e031a5..a79c2e0caf2 100644
--- a/homeassistant/components/konnected/.translations/it.json
+++ b/homeassistant/components/konnected/.translations/it.json
@@ -35,7 +35,7 @@
},
"error": {
"one": "uno",
- "other": "altro"
+ "other": "altri"
},
"step": {
"options_binary": {
diff --git a/homeassistant/components/konnected/.translations/ko.json b/homeassistant/components/konnected/.translations/ko.json
index fe196050766..34dd01d06b6 100644
--- a/homeassistant/components/konnected/.translations/ko.json
+++ b/homeassistant/components/konnected/.translations/ko.json
@@ -11,7 +11,7 @@
},
"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.",
+ "description": "\ubaa8\ub378: {model}\nID: {id}\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": {
@@ -33,6 +33,9 @@
"abort": {
"not_konn_panel": "\uc778\uc2dd\ub41c Konnected.io \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4"
},
+ "error": {
+ "bad_host": "API \ud638\uc2a4\ud2b8 URL \uc7ac\uc815\uc758\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
"step": {
"options_binary": {
"data": {
@@ -82,7 +85,9 @@
},
"options_misc": {
"data": {
- "blink": "\uc0c1\ud0dc \ubcc0\uacbd\uc744 \ubcf4\ub0bc \ub54c \uae5c\ubc15\uc784 \ud328\ub110 LED \ub97c \ucf2d\ub2c8\ub2e4"
+ "api_host": "API \ud638\uc2a4\ud2b8 URL \uc7ac\uc815\uc758 (\uc120\ud0dd \uc0ac\ud56d)",
+ "blink": "\uc0c1\ud0dc \ubcc0\uacbd\uc744 \ubcf4\ub0bc \ub54c \uae5c\ubc15\uc784 \ud328\ub110 LED \ub97c \ucf2d\ub2c8\ub2e4",
+ "override_api_host": "\uae30\ubcf8 Home Assistant API \ud638\uc2a4\ud2b8 \ud328\ub110 URL \uc7ac\uc815\uc758"
},
"description": "\ud328\ub110\uc5d0 \uc6d0\ud558\ub294 \ub3d9\uc791\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694",
"title": "\uae30\ud0c0 \uad6c\uc131"
@@ -91,11 +96,12 @@
"data": {
"activation": "\uc2a4\uc704\uce58\uac00 \ucf1c\uc9c8 \ub54c \ucd9c\ub825",
"momentary": "\ud384\uc2a4 \uc9c0\uc18d\uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)",
+ "more_states": "\uc774 \uad6c\uc5ed\uc5d0 \ub300\ud55c \ucd94\uac00 \uc0c1\ud0dc \uad6c\uc131",
"name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)",
"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",
+ "description": "{zone} \uad6c\uc5ed\uc5d0 \ub300\ud55c \ucd9c\ub825 \uc635\uc158\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694: \uc0c1\ud0dc {state}",
"title": "\uc2a4\uc704\uce58 \ucd9c\ub825 \uad6c\uc131"
}
},
diff --git a/homeassistant/components/konnected/.translations/lb.json b/homeassistant/components/konnected/.translations/lb.json
index 12493169691..6ad04254611 100644
--- a/homeassistant/components/konnected/.translations/lb.json
+++ b/homeassistant/components/konnected/.translations/lb.json
@@ -11,7 +11,7 @@
},
"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.",
+ "description": "Modell: {model}\nID: {id}\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"
},
"import_confirm": {
@@ -34,6 +34,7 @@
"not_konn_panel": "Keen erkannten Konnected.io Apparat"
},
"error": {
+ "bad_host": "Iwwerschriwwen API Host URL ong\u00eblteg",
"one": "Ee",
"other": "M\u00e9i"
},
@@ -86,7 +87,9 @@
},
"options_misc": {
"data": {
- "blink": "Blink panel LED un wann Status \u00c4nnerung gesch\u00e9ckt g\u00ebtt"
+ "api_host": "API Host URL iwwerschr\u00e9iwen (optionell)",
+ "blink": "Blink panel LED un wann Status \u00c4nnerung gesch\u00e9ckt g\u00ebtt",
+ "override_api_host": "Standard Home Assistant API Host Tableau URL iwwerschr\u00e9iwen"
},
"description": "Wielt w.e.g. dat gew\u00ebnschte Verhalen fir \u00c4re Panel aus",
"title": "Divers Optioune astellen"
@@ -95,6 +98,7 @@
"data": {
"activation": "Ausgang wann un",
"momentary": "Pulsatiounsdauer (ms) (optional)",
+ "more_states": "Zous\u00e4tzlesch Zoust\u00e4nn fir d\u00ebs Zon konfigur\u00e9ieren",
"name": "Numm (optional)",
"pause": "Pausen zw\u00ebscht den Impulser (ms) (optional)",
"repeat": "Unzuel vu Widderhuelungen (-1= onendlech) (optional)"
diff --git a/homeassistant/components/konnected/.translations/no.json b/homeassistant/components/konnected/.translations/no.json
index 72cd2911bbc..86d9fe877af 100644
--- a/homeassistant/components/konnected/.translations/no.json
+++ b/homeassistant/components/konnected/.translations/no.json
@@ -27,12 +27,15 @@
"title": "Oppdag Konnected Enheten"
}
},
- "title": "Konnected.io"
+ "title": ""
},
"options": {
"abort": {
"not_konn_panel": "Ikke en anerkjent Konnected.io-enhet"
},
+ "error": {
+ "bad_host": "Ugyldig overstyr API-vertsadresse"
+ },
"step": {
"options_binary": {
"data": {
@@ -82,7 +85,9 @@
},
"options_misc": {
"data": {
- "blink": "Blink p\u00e5 LED-lampen n\u00e5r du sender statusendring"
+ "api_host": "Overstyre API-vert-URL (valgfritt)",
+ "blink": "Blink p\u00e5 LED-lampen n\u00e5r du sender statusendring",
+ "override_api_host": "Overstyre standard Home Assistant API-vertspanel-URL"
},
"description": "Vennligst velg \u00f8nsket atferd for din panel",
"title": "Konfigurere Diverse"
@@ -91,11 +96,12 @@
"data": {
"activation": "Utgang n\u00e5r den er p\u00e5",
"momentary": "Pulsvarighet (ms) (valgfritt)",
+ "more_states": "Konfigurere flere tilstander for denne sonen",
"name": "Navn (valgfritt)",
"pause": "Pause mellom pulser (ms) (valgfritt)",
"repeat": "Tider \u00e5 gjenta (-1 = uendelig) (valgfritt)"
},
- "description": "Velg outputalternativer for {zone}",
+ "description": "Velg outputalternativer for {zone} : state {state}",
"title": "Konfigurere Valgbare Utgang"
}
},
diff --git a/homeassistant/components/konnected/.translations/ru.json b/homeassistant/components/konnected/.translations/ru.json
index ba1b3c6abc9..f3b7f4d6d24 100644
--- a/homeassistant/components/konnected/.translations/ru.json
+++ b/homeassistant/components/konnected/.translations/ru.json
@@ -33,6 +33,9 @@
"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."
},
+ "error": {
+ "bad_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0445\u043e\u0441\u0442\u0430 API."
+ },
"step": {
"options_binary": {
"data": {
@@ -82,7 +85,9 @@
},
"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"
+ "api_host": "\u041f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c URL \u0445\u043e\u0441\u0442\u0430 API (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)",
+ "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",
+ "override_api_host": "\u041f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c URL-\u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442-\u043f\u0430\u043d\u0435\u043b\u0438 Home Assistant API"
},
"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"
@@ -91,11 +96,12 @@
"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)",
+ "more_states": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0437\u043e\u043d\u044b",
"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}.",
+ "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}: \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 {state}.",
"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"
}
},
diff --git a/homeassistant/components/konnected/.translations/sl.json b/homeassistant/components/konnected/.translations/sl.json
index 38396d0832d..2b2269bee5f 100644
--- a/homeassistant/components/konnected/.translations/sl.json
+++ b/homeassistant/components/konnected/.translations/sl.json
@@ -11,9 +11,13 @@
},
"step": {
"confirm": {
- "description": "Model: {model}\nGostitelj: {host}\nVrata: {port}\n\nV nastavitvah lahko nastavite vedenje I / O in plo\u0161\u010de Konnected alarma. ",
+ "description": "Model: {model}\nID: {id}\nGostitelj: {host}\nVrata: {port}\n\nV nastavitvah lahko nastavite vedenje I/O in plo\u0161\u010de Konnected alarma. ",
"title": "Konnected naprava pripravljena"
},
+ "import_confirm": {
+ "description": "Konnected alarm panel z ID {id} je bil odkrit v konfiguraciji. YAML. To tok vam bo omogo\u010dil, da ga uvozite v va\u0161o konfiguracijo.",
+ "title": "Uvoz Konnected Naprave"
+ },
"user": {
"data": {
"host": "IP-naslov Konnected naprave",
diff --git a/homeassistant/components/konnected/.translations/zh-Hant.json b/homeassistant/components/konnected/.translations/zh-Hant.json
index 4c1bec691db..9c3e818e692 100644
--- a/homeassistant/components/konnected/.translations/zh-Hant.json
+++ b/homeassistant/components/konnected/.translations/zh-Hant.json
@@ -33,6 +33,9 @@
"abort": {
"not_konn_panel": "\u4e26\u975e\u53ef\u8b58\u5225 Konnected.io \u8a2d\u5099"
},
+ "error": {
+ "bad_host": "\u7121\u6548\u7684\u8986\u5beb API \u4e3b\u6a5f\u7aef URL"
+ },
"step": {
"options_binary": {
"data": {
@@ -82,7 +85,9 @@
},
"options_misc": {
"data": {
- "blink": "\u7576\u50b3\u9001\u72c0\u614b\u8b8a\u66f4\u6642\u3001\u9583\u720d\u9762\u677f LED"
+ "api_host": "\u8986\u5beb API \u4e3b\u6a5f\u7aef URL\uff08\u9078\u9805\uff09",
+ "blink": "\u7576\u50b3\u9001\u72c0\u614b\u8b8a\u66f4\u6642\u3001\u9583\u720d\u9762\u677f LED",
+ "override_api_host": "\u8986\u5beb\u9810\u8a2d Home Assistant API \u4e3b\u6a5f\u7aef\u9762\u677f URL"
},
"description": "\u8acb\u9078\u64c7\u9762\u677f\u671f\u671b\u884c\u70ba",
"title": "\u5176\u4ed6\u8a2d\u5b9a"
@@ -91,11 +96,12 @@
"data": {
"activation": "\u958b\u555f\u6642\u8f38\u51fa",
"momentary": "\u6301\u7e8c\u6642\u9593\uff08ms\uff09\uff08\u9078\u9805\uff09",
+ "more_states": "\u8a2d\u5b9a\u6b64\u5340\u57df\u7684\u9644\u52a0\u72c0\u614b",
"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",
+ "description": "\u8acb\u9078\u64c7 {zone}\u8f38\u51fa\u9078\u9805\uff1a\u72c0\u614b {state}",
"title": "\u8a2d\u5b9a Switchable \u8f38\u51fa"
}
},
diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py
index 72d82fd31be..e5185ff03bc 100644
--- a/homeassistant/components/konnected/__init__.py
+++ b/homeassistant/components/konnected/__init__.py
@@ -91,7 +91,7 @@ def ensure_zone(value):
return str(value)
-def import_validator(config):
+def import_device_validator(config):
"""Validate zones and reformat for import."""
config = copy.deepcopy(config)
io_cfgs = {}
@@ -117,10 +117,22 @@ def import_validator(config):
config.pop(CONF_SWITCHES, None)
config.pop(CONF_BLINK, None)
config.pop(CONF_DISCOVERY, None)
+ config.pop(CONF_API_HOST, None)
config.pop(CONF_IO, None)
return config
+def import_validator(config):
+ """Reformat for import."""
+ config = copy.deepcopy(config)
+
+ # push api_host into device configs
+ for device in config.get(CONF_DEVICES, []):
+ device[CONF_API_HOST] = config.get(CONF_API_HOST, "")
+
+ return config
+
+
# configuration.yaml schemas (legacy)
BINARY_SENSOR_SCHEMA_YAML = vol.All(
vol.Schema(
@@ -179,23 +191,27 @@ DEVICE_SCHEMA_YAML = vol.All(
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_API_HOST, default=""): vol.Any("", cv.url),
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
}
),
- import_validator,
+ import_device_validator,
)
# pylint: disable=no-value-for-parameter
CONFIG_SCHEMA = vol.Schema(
{
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_ACCESS_TOKEN): cv.string,
- vol.Optional(CONF_API_HOST): vol.Url(),
- vol.Optional(CONF_DEVICES): vol.All(
- cv.ensure_list, [DEVICE_SCHEMA_YAML]
- ),
- }
+ DOMAIN: vol.All(
+ import_validator,
+ vol.Schema(
+ {
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_API_HOST): vol.Url(),
+ vol.Optional(CONF_DEVICES): vol.All(
+ cv.ensure_list, [DEVICE_SCHEMA_YAML]
+ ),
+ }
+ ),
)
},
extra=vol.ALLOW_EXTRA,
diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py
index cb9004c9efe..6a3631a8c0d 100644
--- a/homeassistant/components/konnected/config_flow.py
+++ b/homeassistant/components/konnected/config_flow.py
@@ -31,6 +31,7 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ACTIVATION,
+ CONF_API_HOST,
CONF_BLINK,
CONF_DEFAULT_OPTIONS,
CONF_DISCOVERY,
@@ -57,6 +58,12 @@ CONF_IO_BIN = "Binary Sensor"
CONF_IO_DIG = "Digital Sensor"
CONF_IO_SWI = "Switchable Output"
+CONF_MORE_STATES = "more_states"
+CONF_YES = "Yes"
+CONF_NO = "No"
+
+CONF_OVERRIDE_API_HOST = "override_api_host"
+
KONN_MANUFACTURER = "konnected.io"
KONN_PANEL_MODEL_NAMES = {
KONN_MODEL: "Konnected Alarm Panel",
@@ -117,7 +124,7 @@ 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.Lower, vol.In([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)),
@@ -134,6 +141,7 @@ OPTIONS_SCHEMA = vol.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_API_HOST, default=""): vol.Any("", cv.url),
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
},
extra=vol.REMOVE_EXTRA,
@@ -361,6 +369,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self.new_opt = {CONF_IO: {}}
self.active_cfg = None
self.io_cfg = {}
+ self.current_states = []
+ self.current_state = 1
@callback
def get_current_cfg(self, io_type, zone):
@@ -666,12 +676,21 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if user_input is not None:
zone = {"zone": self.active_cfg}
zone.update(user_input)
+ del zone[CONF_MORE_STATES]
self.new_opt[CONF_SWITCHES] = self.new_opt.get(CONF_SWITCHES, []) + [zone]
- self.io_cfg.pop(self.active_cfg)
- self.active_cfg = None
+
+ # iterate through multiple switch states
+ if self.current_states:
+ self.current_states.pop(0)
+
+ # only go to next zone if all states are entered
+ self.current_state += 1
+ if user_input[CONF_MORE_STATES] == CONF_NO:
+ 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)
+ current_cfg = next(iter(self.current_states), {})
return self.async_show_form(
step_id="options_switch",
data_schema=vol.Schema(
@@ -682,7 +701,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
vol.Optional(
CONF_ACTIVATION,
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
- ): vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)),
+ ): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])),
vol.Optional(
CONF_MOMENTARY,
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
@@ -695,12 +714,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_REPEAT,
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
+ vol.Required(
+ CONF_MORE_STATES,
+ default=CONF_YES
+ if len(self.current_states) > 1
+ else CONF_NO,
+ ): vol.In([CONF_YES, CONF_NO]),
}
),
description_placeholders={
"zone": f"Zone {self.active_cfg}"
if len(self.active_cfg) < 3
- else self.active_cfg.upper()
+ else self.active_cfg.upper(),
+ "state": str(self.current_state),
},
errors=errors,
)
@@ -709,7 +735,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
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)
+ self.current_states = [
+ cfg
+ for cfg in self.current_opt.get(CONF_SWITCHES, [])
+ if cfg[CONF_ZONE] == self.active_cfg
+ ]
+ current_cfg = next(iter(self.current_states), {})
+ self.current_state = 1
return self.async_show_form(
step_id="options_switch",
data_schema=vol.Schema(
@@ -720,7 +752,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
): str,
vol.Optional(
CONF_ACTIVATION,
- default=current_cfg.get(CONF_ACTIVATION, "high"),
+ default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
): vol.In(["low", "high"]),
vol.Optional(
CONF_MOMENTARY,
@@ -734,12 +766,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_REPEAT,
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
+ vol.Required(
+ CONF_MORE_STATES,
+ default=CONF_YES
+ if len(self.current_states) > 1
+ else CONF_NO,
+ ): vol.In([CONF_YES, CONF_NO]),
}
),
description_placeholders={
"zone": f"Zone {self.active_cfg}"
if len(self.active_cfg) < 3
- else self.active_cfg.upper()
+ else self.active_cfg.upper(),
+ "state": str(self.current_state),
},
errors=errors,
)
@@ -750,8 +789,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
"""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)
+ # config schema only does basic schema val so check url here
+ try:
+ if user_input[CONF_OVERRIDE_API_HOST]:
+ cv.url(user_input.get(CONF_API_HOST, ""))
+ else:
+ user_input[CONF_API_HOST] = ""
+ except vol.Invalid:
+ errors["base"] = "bad_host"
+ else:
+ # no need to store the override - can infer
+ del user_input[CONF_OVERRIDE_API_HOST]
+ self.new_opt.update(user_input)
+ return self.async_create_entry(title="", data=self.new_opt)
return self.async_show_form(
step_id="options_misc",
@@ -760,6 +810,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
vol.Required(
CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True)
): bool,
+ vol.Required(
+ CONF_OVERRIDE_API_HOST,
+ default=bool(self.current_opt.get(CONF_API_HOST)),
+ ): bool,
+ vol.Optional(
+ CONF_API_HOST, default=self.current_opt.get(CONF_API_HOST, "")
+ ): str,
}
),
errors=errors,
diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py
index 783aa78b8b1..efb1e83a728 100644
--- a/homeassistant/components/konnected/panel.py
+++ b/homeassistant/components/konnected/panel.py
@@ -294,7 +294,9 @@ class AlarmPanel:
@callback
def async_desired_settings_payload(self):
"""Return a dict representing the desired device configuration."""
- desired_api_host = (
+ # keeping self.hass.data check for backwards compatibility
+ # newly configured integrations store this in the config entry
+ desired_api_host = self.options.get(CONF_API_HOST) or (
self.hass.data[DOMAIN].get(CONF_API_HOST) or self.hass.config.api.base_url
)
desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json
index 4d923238df4..0ea8a40bc0a 100644
--- a/homeassistant/components/konnected/strings.json
+++ b/homeassistant/components/konnected/strings.json
@@ -80,24 +80,29 @@
},
"options_switch": {
"title": "Configure Switchable Output",
- "description": "Please select the output options for {zone}",
+ "description": "Please select the output options for {zone}: state {state}",
"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)"
+ "repeat": "Times to repeat (-1=infinite) (optional)",
+ "more_states": "Configure additional states for this zone"
}
},
"options_misc": {
"title": "Configure Misc",
"description": "Please select the desired behavior for your panel",
"data": {
- "blink": "Blink panel LED on when sending state change"
+ "blink": "Blink panel LED on when sending state change",
+ "override_api_host": "Override default Home Assistant API host panel URL",
+ "api_host": "Override API host URL (optional)"
}
}
},
- "error": {},
+ "error": {
+ "bad_host": "Invalid Override API host url"
+ },
"abort": {
"not_konn_panel": "Not a recognized Konnected.io device"
}
diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json
index d00d7d352f2..681047a2431 100644
--- a/homeassistant/components/lastfm/manifest.json
+++ b/homeassistant/components/lastfm/manifest.json
@@ -2,7 +2,7 @@
"domain": "lastfm",
"name": "Last.fm",
"documentation": "https://www.home-assistant.io/integrations/lastfm",
- "requirements": ["pylast==3.2.0"],
+ "requirements": ["pylast==3.2.1"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
index 80a15ef6bd6..58353697d18 100644
--- a/homeassistant/components/lcn/manifest.json
+++ b/homeassistant/components/lcn/manifest.json
@@ -2,7 +2,7 @@
"domain": "lcn",
"name": "LCN",
"documentation": "https://www.home-assistant.io/integrations/lcn",
- "requirements": ["pypck==0.6.3"],
+ "requirements": ["pypck==0.6.4"],
"dependencies": [],
"codeowners": ["@alengwenus"]
}
diff --git a/homeassistant/components/light/.translations/da.json b/homeassistant/components/light/.translations/da.json
index eefa1e8bb6e..8115a3bfba9 100644
--- a/homeassistant/components/light/.translations/da.json
+++ b/homeassistant/components/light/.translations/da.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Formindsk lysstyrken p\u00e5 {entity_name}",
+ "brightness_increase": "For\u00f8g lysstyrken p\u00e5 {entity_name}",
"toggle": "Skift {entity_name}",
"turn_off": "Sluk {entity_name}",
"turn_on": "T\u00e6nd for {entity_name}"
diff --git a/homeassistant/components/light/.translations/de.json b/homeassistant/components/light/.translations/de.json
index be8966d9556..1984cf31d79 100644
--- a/homeassistant/components/light/.translations/de.json
+++ b/homeassistant/components/light/.translations/de.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Helligkeit von {entity_name} verringern",
+ "brightness_increase": "Helligkeit von {entity_name} erh\u00f6hen",
"toggle": "Schalte {entity_name} um.",
"turn_off": "Schalte {entity_name} aus.",
"turn_on": "Schalte {entity_name} ein."
diff --git a/homeassistant/components/light/.translations/hu.json b/homeassistant/components/light/.translations/hu.json
index 7d7e158f3cb..5192a8c7df2 100644
--- a/homeassistant/components/light/.translations/hu.json
+++ b/homeassistant/components/light/.translations/hu.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "{entity_name} f\u00e9nyerej\u00e9nek cs\u00f6kkent\u00e9se",
+ "brightness_increase": "{entity_name} f\u00e9nyerej\u00e9nek n\u00f6vel\u00e9se",
"toggle": "{entity_name} fel/lekapcsol\u00e1sa",
"turn_off": "{entity_name} lekapcsol\u00e1sa",
"turn_on": "{entity_name} felkapcsol\u00e1sa"
diff --git a/homeassistant/components/light/.translations/ko.json b/homeassistant/components/light/.translations/ko.json
index b923fdb210e..c0c47dddfbb 100644
--- a/homeassistant/components/light/.translations/ko.json
+++ b/homeassistant/components/light/.translations/ko.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "{entity_name} \uc744(\ub97c) \uc5b4\ub461\uac8c \ud558\uae30",
+ "brightness_increase": "{entity_name} \uc744(\ub97c) \ubc1d\uac8c \ud558\uae30",
"toggle": "{entity_name} \ud1a0\uae00",
"turn_off": "{entity_name} \ub044\uae30",
"turn_on": "{entity_name} \ucf1c\uae30"
diff --git a/homeassistant/components/light/.translations/lb.json b/homeassistant/components/light/.translations/lb.json
index a7f807e8dcd..8ffa33a6a3b 100644
--- a/homeassistant/components/light/.translations/lb.json
+++ b/homeassistant/components/light/.translations/lb.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "{entity_name} Hellegkeet reduz\u00e9ieren",
+ "brightness_increase": "{entity_name} Hellegkeet erh\u00e9ijen",
"toggle": "{entity_name} \u00ebmschalten",
"turn_off": "{entity_name} ausschalten",
"turn_on": "{entity_name} uschalten"
diff --git a/homeassistant/components/light/.translations/pl.json b/homeassistant/components/light/.translations/pl.json
index 05589210dba..1f2ff19f9c3 100644
--- a/homeassistant/components/light/.translations/pl.json
+++ b/homeassistant/components/light/.translations/pl.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "zmniejsz jasno\u015b\u0107 {entity_name}",
+ "brightness_increase": "zwi\u0119ksz jasno\u015b\u0107 {entity_name}",
"toggle": "prze\u0142\u0105cz {entity_name}",
"turn_off": "wy\u0142\u0105cz {entity_name}",
"turn_on": "w\u0142\u0105cz {entity_name}"
diff --git a/homeassistant/components/light/.translations/sl.json b/homeassistant/components/light/.translations/sl.json
index bef4f1583b6..5704ebb6826 100644
--- a/homeassistant/components/light/.translations/sl.json
+++ b/homeassistant/components/light/.translations/sl.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Zmanj\u0161ajte svetlost {entity_name}",
+ "brightness_increase": "Pove\u010dajte svetlost {entity_name}",
"toggle": "Preklopite {entity_name}",
"turn_off": "Izklopite {entity_name}",
"turn_on": "Vklopite {entity_name}"
diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py
index 59a4b0306d0..9a6b22b51a2 100644
--- a/homeassistant/components/light/reproduce_state.py
+++ b/homeassistant/components/light/reproduce_state.py
@@ -64,7 +64,10 @@ DEPRECATED_GROUP = [
ATTR_TRANSITION,
]
-DEPRECATION_WARNING = "The use of other attributes than device state attributes is deprecated and will be removed in a future release. Read the logs for further details: https://www.home-assistant.io/integrations/scene/"
+DEPRECATION_WARNING = (
+ "The use of other attributes than device state attributes is deprecated and will be removed in a future release. "
+ "Invalid attributes are %s. Read the logs for further details: https://www.home-assistant.io/integrations/scene/"
+)
async def _async_reproduce_state(
@@ -84,8 +87,9 @@ async def _async_reproduce_state(
return
# Warn if deprecated attributes are used
- if any(attr in DEPRECATED_GROUP for attr in state.attributes):
- _LOGGER.warning(DEPRECATION_WARNING)
+ deprecated_attrs = [attr for attr in state.attributes if attr in DEPRECATED_GROUP]
+ if deprecated_attrs:
+ _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs)
# Return if we are already at the right state.
if cur_state.state == state.state and all(
diff --git a/homeassistant/components/locative/.translations/no.json b/homeassistant/components/locative/.translations/no.json
index c5ad3043004..123b03d95a8 100644
--- a/homeassistant/components/locative/.translations/no.json
+++ b/homeassistant/components/locative/.translations/no.json
@@ -13,6 +13,6 @@
"title": "Sett opp Locative Webhook"
}
},
- "title": "Locative Webhook"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/no.json b/homeassistant/components/logi_circle/.translations/no.json
index 23b951bfa62..9f676e2acc7 100644
--- a/homeassistant/components/logi_circle/.translations/no.json
+++ b/homeassistant/components/logi_circle/.translations/no.json
@@ -27,6 +27,6 @@
"title": "Autentiseringsleverand\u00f8r"
}
},
- "title": "Logi Circle"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py
index 95508c2f8f3..9b944be556b 100644
--- a/homeassistant/components/lovelace/__init__.py
+++ b/homeassistant/components/lovelace/__init__.py
@@ -234,7 +234,10 @@ async def create_yaml_resource_col(hass, yaml_resources):
async def system_health_info(hass):
"""Get info for the info page."""
- return await hass.data[DOMAIN]["dashboards"][None].async_get_info()
+ health_info = {"dashboards": len(hass.data[DOMAIN]["dashboards"])}
+ health_info.update(await hass.data[DOMAIN]["dashboards"][None].async_get_info())
+ health_info.update(await hass.data[DOMAIN]["resources"].async_get_info())
+ return health_info
@callback
diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py
index cdb104a150b..2d3196054e3 100644
--- a/homeassistant/components/lovelace/dashboard.py
+++ b/homeassistant/components/lovelace/dashboard.py
@@ -101,7 +101,7 @@ class LovelaceStorage(LovelaceConfig):
return MODE_STORAGE
async def async_get_info(self):
- """Return the YAML storage mode."""
+ """Return the Lovelace storage info."""
if self._data is None:
await self._load()
@@ -213,7 +213,6 @@ 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
index 57acaa487bd..78a23540ed4 100644
--- a/homeassistant/components/lovelace/resources.py
+++ b/homeassistant/components/lovelace/resources.py
@@ -34,6 +34,10 @@ class ResourceYAMLCollection:
"""Initialize a resource YAML collection."""
self.data = data
+ async def async_get_info(self):
+ """Return the resources info for YAML mode."""
+ return {"resources": len(self.async_items() or [])}
+
@callback
def async_items(self) -> List[dict]:
"""Return list of items in collection."""
@@ -55,6 +59,14 @@ class ResourceStorageCollection(collection.StorageCollection):
)
self.ll_config = ll_config
+ async def async_get_info(self):
+ """Return the resources info for YAML mode."""
+ if not self.loaded:
+ await self.async_load()
+ self.loaded = True
+
+ return {"resources": len(self.async_items() or [])}
+
async def _async_load_data(self) -> Optional[dict]:
"""Load the data."""
data = await self.store.async_load()
diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py
index a4e67fda929..45a042c1f2e 100644
--- a/homeassistant/components/lovelace/websocket.py
+++ b/homeassistant/components/lovelace/websocket.py
@@ -72,6 +72,7 @@ async def websocket_lovelace_config(hass, connection, msg, config):
return await config.async_load(msg["force"])
+@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
@@ -86,6 +87,7 @@ async def websocket_lovelace_save_config(hass, connection, msg, config):
await config.async_save(msg["config"])
+@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json
index 13fa67a8b6b..e6e9110b33a 100644
--- a/homeassistant/components/luftdaten/manifest.json
+++ b/homeassistant/components/luftdaten/manifest.json
@@ -3,7 +3,7 @@
"name": "Luftdaten",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/luftdaten",
- "requirements": ["luftdaten==0.6.3"],
+ "requirements": ["luftdaten==0.6.4"],
"dependencies": [],
"codeowners": ["@fabaff"],
"quality_scale": "gold"
diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py
index aaac06a6bd5..47df6a221dd 100644
--- a/homeassistant/components/lutron_caseta/__init__.py
+++ b/homeassistant/components/lutron_caseta/__init__.py
@@ -33,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-LUTRON_CASETA_COMPONENTS = ["light", "switch", "cover", "scene", "fan"]
+LUTRON_CASETA_COMPONENTS = ["light", "switch", "cover", "scene", "fan", "binary_sensor"]
async def async_setup(hass, base_config):
@@ -81,9 +81,7 @@ class LutronCasetaDevice(Entity):
async def async_added_to_hass(self):
"""Register callbacks."""
- self._smartbridge.add_subscriber(
- self.device_id, self.async_schedule_update_ha_state
- )
+ self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state)
@property
def device_id(self):
@@ -108,7 +106,7 @@ class LutronCasetaDevice(Entity):
@property
def device_state_attributes(self):
"""Return the state attributes."""
- attr = {"Device ID": self.device_id, "Zone ID": self._device["zone"]}
+ attr = {"device_id": self.device_id, "zone_id": self._device["zone"]}
return attr
@property
diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py
new file mode 100644
index 00000000000..871f3c28664
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/binary_sensor.py
@@ -0,0 +1,56 @@
+"""Support for Lutron Caseta Occupancy/Vacancy Sensors."""
+from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_OCCUPANCY,
+ BinarySensorDevice,
+)
+
+from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the Lutron Caseta lights."""
+ entities = []
+ bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
+ occupancy_groups = bridge.occupancy_groups
+ for occupancy_group in occupancy_groups.values():
+ entity = LutronOccupancySensor(occupancy_group, bridge)
+ entities.append(entity)
+
+ async_add_entities(entities, True)
+
+
+class LutronOccupancySensor(LutronCasetaDevice, BinarySensorDevice):
+ """Representation of a Lutron occupancy group."""
+
+ @property
+ def device_class(self):
+ """Flag supported features."""
+ return DEVICE_CLASS_OCCUPANCY
+
+ @property
+ def is_on(self):
+ """Return the brightness of the light."""
+ return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._smartbridge.add_occupancy_subscriber(
+ self.device_id, self.async_write_ha_state
+ )
+
+ @property
+ def device_id(self):
+ """Return the device ID used for calling pylutron_caseta."""
+ return self._device["occupancy_group_id"]
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier."""
+ return f"occupancygroup_{self.device_id}"
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {"device_id": self.device_id}
diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py
index afd669153e0..60c723b7b42 100644
--- a/homeassistant/components/lutron_caseta/cover.py
+++ b/homeassistant/components/lutron_caseta/cover.py
@@ -17,14 +17,14 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta shades as a cover device."""
- devs = []
+ entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
cover_devices = bridge.get_devices_by_domain(DOMAIN)
for cover_device in cover_devices:
- dev = LutronCasetaCover(cover_device, bridge)
- devs.append(dev)
+ entity = LutronCasetaCover(cover_device, bridge)
+ entities.append(entity)
- async_add_entities(devs, True)
+ async_add_entities(entities, True)
class LutronCasetaCover(LutronCasetaDevice, CoverDevice):
diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py
index af225d2939d..ba4342ecfce 100644
--- a/homeassistant/components/lutron_caseta/light.py
+++ b/homeassistant/components/lutron_caseta/light.py
@@ -7,23 +7,32 @@ from homeassistant.components.light import (
SUPPORT_BRIGHTNESS,
Light,
)
-from homeassistant.components.lutron.light import to_hass_level, to_lutron_level
from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
_LOGGER = logging.getLogger(__name__)
+def to_lutron_level(level):
+ """Convert the given Home Assistant light level (0-255) to Lutron (0-100)."""
+ return int((level * 100) // 255)
+
+
+def to_hass_level(level):
+ """Convert the given Lutron (0-100) light level to Home Assistant (0-255)."""
+ return int((level * 255) // 100)
+
+
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta lights."""
- devs = []
+ entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
light_devices = bridge.get_devices_by_domain(DOMAIN)
for light_device in light_devices:
- dev = LutronCasetaLight(light_device, bridge)
- devs.append(dev)
+ entity = LutronCasetaLight(light_device, bridge)
+ entities.append(entity)
- async_add_entities(devs, True)
+ async_add_entities(entities, True)
class LutronCasetaLight(LutronCasetaDevice, Light):
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index 3dd8c8fac2e..856bf285a16 100644
--- a/homeassistant/components/lutron_caseta/manifest.json
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -2,7 +2,7 @@
"domain": "lutron_caseta",
"name": "Lutron Caseta",
"documentation": "https://www.home-assistant.io/integrations/lutron_caseta",
- "requirements": ["pylutron-caseta==0.5.1"],
+ "requirements": ["pylutron-caseta==0.6.0"],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@swails"]
}
diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py
index abdbcaa03cd..593f58f5274 100644
--- a/homeassistant/components/lutron_caseta/scene.py
+++ b/homeassistant/components/lutron_caseta/scene.py
@@ -10,14 +10,14 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta lights."""
- devs = []
+ entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
scenes = bridge.get_scenes()
for scene in scenes:
- dev = LutronCasetaScene(scenes[scene], bridge)
- devs.append(dev)
+ entity = LutronCasetaScene(scenes[scene], bridge)
+ entities.append(entity)
- async_add_entities(devs, True)
+ async_add_entities(entities, True)
class LutronCasetaScene(Scene):
diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py
index f6eb846ecfb..23cd1db8f79 100644
--- a/homeassistant/components/lutron_caseta/switch.py
+++ b/homeassistant/components/lutron_caseta/switch.py
@@ -10,15 +10,15 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up Lutron switch."""
- devs = []
+ entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
switch_devices = bridge.get_devices_by_domain(DOMAIN)
for switch_device in switch_devices:
- dev = LutronCasetaLight(switch_device, bridge)
- devs.append(dev)
+ entity = LutronCasetaLight(switch_device, bridge)
+ entities.append(entity)
- async_add_entities(devs, True)
+ async_add_entities(entities, True)
return True
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index 631dc7675ca..fd1d2172873 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.03.08"],
+ "requirements": ["youtube_dl==2020.03.24"],
"dependencies": ["media_player"],
"codeowners": [],
"quality_scale": "internal"
diff --git a/homeassistant/components/media_player/.translations/ko.json b/homeassistant/components/media_player/.translations/ko.json
index 49367eaf617..b7ebc93099d 100644
--- a/homeassistant/components/media_player/.translations/ko.json
+++ b/homeassistant/components/media_player/.translations/ko.json
@@ -1,7 +1,7 @@
{
"device_automation": {
"condition_type": {
- "is_idle": "{entity_name} \uc774(\uac00) \uc720\ud734\uc0c1\ud0dc\uc774\uba74",
+ "is_idle": "{entity_name} \uc774(\uac00) \uc720\ud734 \uc0c1\ud0dc\uc774\uba74",
"is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74",
"is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74",
"is_paused": "{entity_name} \uc774(\uac00) \uc77c\uc2dc\uc911\uc9c0\ub418\uc5b4 \uc788\uc73c\uba74",
diff --git a/homeassistant/components/melcloud/.translations/fr.json b/homeassistant/components/melcloud/.translations/fr.json
index e442325d9dc..00661d3f0af 100644
--- a/homeassistant/components/melcloud/.translations/fr.json
+++ b/homeassistant/components/melcloud/.translations/fr.json
@@ -8,6 +8,7 @@
"step": {
"user": {
"data": {
+ "password": "Mot de passe MELCloud.",
"username": "E-mail utilis\u00e9e pour vous connecter \u00e0 MELCloud."
},
"description": "Se connecter en utilisant votre MELCloud compte.",
diff --git a/homeassistant/components/melcloud/.translations/ko.json b/homeassistant/components/melcloud/.translations/ko.json
index 1557abf5a32..428e2b1f994 100644
--- a/homeassistant/components/melcloud/.translations/ko.json
+++ b/homeassistant/components/melcloud/.translations/ko.json
@@ -15,7 +15,7 @@
"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 \uc5d0 \uc5f0\uacb0\ud558\uae30"
}
},
"title": "MELCloud"
diff --git a/homeassistant/components/melcloud/.translations/no.json b/homeassistant/components/melcloud/.translations/no.json
index a464bbfda19..cdcc7087d06 100644
--- a/homeassistant/components/melcloud/.translations/no.json
+++ b/homeassistant/components/melcloud/.translations/no.json
@@ -18,6 +18,6 @@
"title": "Koble til MELCloud"
}
},
- "title": "MELCloud"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/melcloud/.translations/pl.json b/homeassistant/components/melcloud/.translations/pl.json
index 9abb68ca85a..60cc9843607 100644
--- a/homeassistant/components/melcloud/.translations/pl.json
+++ b/homeassistant/components/melcloud/.translations/pl.json
@@ -15,7 +15,7 @@
"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": "Po\u0142\u0105czenie z MELCloud"
}
},
"title": "MELCloud"
diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py
index 13150098452..6523efa0eb7 100644
--- a/homeassistant/components/met/weather.py
+++ b/homeassistant/components/met/weather.py
@@ -12,13 +12,20 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_NAME,
EVENT_CORE_CONFIG_UPDATE,
+ LENGTH_FEET,
+ LENGTH_METERS,
+ LENGTH_MILES,
+ PRESSURE_HPA,
+ PRESSURE_INHG,
TEMP_CELSIUS,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
+from homeassistant.util.distance import convert as convert_distance
import homeassistant.util.dt as dt_util
+from homeassistant.util.pressure import convert as convert_pressure
from .const import CONF_TRACK_HOME
@@ -56,20 +63,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
if config.get(CONF_LATITUDE) is None:
config[CONF_TRACK_HOME] = True
- async_add_entities([MetWeather(config)])
+ async_add_entities([MetWeather(config, hass.config.units.is_metric)])
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add a weather entity from a config_entry."""
- async_add_entities([MetWeather(config_entry.data)])
+ async_add_entities([MetWeather(config_entry.data, hass.config.units.is_metric)])
class MetWeather(WeatherEntity):
"""Implementation of a Met.no weather condition."""
- def __init__(self, config):
+ def __init__(self, config, is_metric):
"""Initialise the platform with a data instance and site."""
self._config = config
+ self._is_metric = is_metric
self._unsub_track_home = None
self._unsub_fetch_data = None
self._weather_data = None
@@ -99,6 +107,10 @@ class MetWeather(WeatherEntity):
longitude = conf[CONF_LONGITUDE]
elevation = conf[CONF_ELEVATION]
+ if not self._is_metric:
+ elevation = int(
+ round(convert_distance(elevation, LENGTH_FEET, LENGTH_METERS))
+ )
coordinates = {
"lat": str(latitude),
"lon": str(longitude),
@@ -201,7 +213,11 @@ class MetWeather(WeatherEntity):
@property
def pressure(self):
"""Return the pressure."""
- return self._current_weather_data.get("pressure")
+ pressure_hpa = self._current_weather_data.get("pressure")
+ if self._is_metric or pressure_hpa is None:
+ return pressure_hpa
+
+ return round(convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_INHG), 2)
@property
def humidity(self):
@@ -211,7 +227,13 @@ class MetWeather(WeatherEntity):
@property
def wind_speed(self):
"""Return the wind speed."""
- return self._current_weather_data.get("wind_speed")
+ speed_m_s = self._current_weather_data.get("wind_speed")
+ if self._is_metric or speed_m_s is None:
+ return speed_m_s
+
+ speed_mi_s = convert_distance(speed_m_s, LENGTH_METERS, LENGTH_MILES)
+ speed_mi_h = speed_mi_s / 3600.0
+ return int(round(speed_mi_h))
@property
def wind_bearing(self):
diff --git a/homeassistant/components/meteo_france/.translations/no.json b/homeassistant/components/meteo_france/.translations/no.json
index 1de1094f0a5..dc10ffd6a0f 100644
--- a/homeassistant/components/meteo_france/.translations/no.json
+++ b/homeassistant/components/meteo_france/.translations/no.json
@@ -10,9 +10,9 @@
"city": "By"
},
"description": "Skriv inn postnummeret (bare for Frankrike, anbefalt) eller bynavn",
- "title": "M\u00e9t\u00e9o-France"
+ "title": ""
}
},
- "title": "M\u00e9t\u00e9o-France"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py
index b7647f5d97b..2edbf980f36 100644
--- a/homeassistant/components/meteo_france/const.py
+++ b/homeassistant/components/meteo_france/const.py
@@ -83,9 +83,14 @@ SENSOR_TYPES = {
}
CONDITION_CLASSES = {
- "clear-night": ["Nuit Claire"],
+ "clear-night": ["Nuit Claire", "Nuit claire"],
"cloudy": ["Très nuageux"],
- "fog": ["Brume ou bancs de brouillard", "Brouillard", "Brouillard givrant"],
+ "fog": [
+ "Brume ou bancs de brouillard",
+ "Brume",
+ "Brouillard",
+ "Brouillard givrant",
+ ],
"hail": ["Risque de grêle"],
"lightning": ["Risque d'orages", "Orages"],
"lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"],
diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py
index 780f4d6dd48..1bc9c116cda 100644
--- a/homeassistant/components/microsoft_face/__init__.py
+++ b/homeassistant/components/microsoft_face/__init__.py
@@ -113,7 +113,7 @@ async def async_setup(hass, config):
face.store.pop(g_id)
entity = entities.pop(g_id)
- hass.states.async_remove(entity.entity_id)
+ hass.states.async_remove(entity.entity_id, service.context)
except HomeAssistantError as err:
_LOGGER.error("Can't delete group '%s' with error: %s", g_id, err)
diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py
index bd551517562..9d564c6536a 100644
--- a/homeassistant/components/miflora/sensor.py
+++ b/homeassistant/components/miflora/sensor.py
@@ -1,4 +1,5 @@
"""Support for Xiaomi Mi Flora BLE plant sensor."""
+
from datetime import timedelta
import logging
@@ -15,11 +16,14 @@ from homeassistant.const import (
CONF_NAME,
CONF_SCAN_INTERVAL,
EVENT_HOMEASSISTANT_START,
+ TEMP_FAHRENHEIT,
UNIT_PERCENTAGE,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
+import homeassistant.util.dt as dt_util
+from homeassistant.util.temperature import celsius_to_fahrenheit
try:
import bluepy.btle # noqa: F401 pylint: disable=unused-import
@@ -32,14 +36,18 @@ _LOGGER = logging.getLogger(__name__)
CONF_ADAPTER = "adapter"
CONF_MEDIAN = "median"
+CONF_GO_UNAVAILABLE_TIMEOUT = "go_unavailable_timeout"
DEFAULT_ADAPTER = "hci0"
DEFAULT_FORCE_UPDATE = False
DEFAULT_MEDIAN = 3
DEFAULT_NAME = "Mi Flora"
+DEFAULT_GO_UNAVAILABLE_TIMEOUT = timedelta(seconds=7200)
SCAN_INTERVAL = timedelta(seconds=1200)
+ATTR_LAST_SUCCESSFUL_UPDATE = "last_successful_update"
+
# Sensor types are defined like: Name, units, icon
SENSOR_TYPES = {
"temperature": ["Temperature", "°C", "mdi:thermometer"],
@@ -59,6 +67,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int,
vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
vol.Optional(CONF_ADAPTER, default=DEFAULT_ADAPTER): cv.string,
+ vol.Optional(
+ CONF_GO_UNAVAILABLE_TIMEOUT, default=DEFAULT_GO_UNAVAILABLE_TIMEOUT
+ ): cv.time_period,
}
)
@@ -78,11 +89,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
force_update = config.get(CONF_FORCE_UPDATE)
median = config.get(CONF_MEDIAN)
+ go_unavailable_timeout = config.get(CONF_GO_UNAVAILABLE_TIMEOUT)
+
devs = []
for parameter in config[CONF_MONITORED_CONDITIONS]:
name = SENSOR_TYPES[parameter][0]
- unit = SENSOR_TYPES[parameter][1]
+ unit = (
+ hass.config.units.temperature_unit
+ if parameter == "temperature"
+ else SENSOR_TYPES[parameter][1]
+ )
icon = SENSOR_TYPES[parameter][2]
prefix = config.get(CONF_NAME)
@@ -90,7 +107,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
name = f"{prefix} {name}"
devs.append(
- MiFloraSensor(poller, parameter, name, unit, icon, force_update, median)
+ MiFloraSensor(
+ poller,
+ parameter,
+ name,
+ unit,
+ icon,
+ force_update,
+ median,
+ go_unavailable_timeout,
+ )
)
async_add_entities(devs)
@@ -99,7 +125,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
class MiFloraSensor(Entity):
"""Implementing the MiFlora sensor."""
- def __init__(self, poller, parameter, name, unit, icon, force_update, median):
+ def __init__(
+ self,
+ poller,
+ parameter,
+ name,
+ unit,
+ icon,
+ force_update,
+ median,
+ go_unavailable_timeout,
+ ):
"""Initialize the sensor."""
self.poller = poller
self.parameter = parameter
@@ -107,9 +143,10 @@ class MiFloraSensor(Entity):
self._icon = icon
self._name = name
self._state = None
- self._available = False
self.data = []
self._force_update = force_update
+ self.go_unavailable_timeout = go_unavailable_timeout
+ self.last_successful_update = dt_util.utc_from_timestamp(0)
# Median is used to filter out outliers. median of 3 will filter
# single outliers, while median of 5 will filter double outliers
# Use median_count = 1 if no filtering is required.
@@ -136,8 +173,16 @@ class MiFloraSensor(Entity):
@property
def available(self):
- """Return True if entity is available."""
- return self._available
+ """Return True if did update since 2h."""
+ return self.last_successful_update > (
+ dt_util.utcnow() - self.go_unavailable_timeout
+ )
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {ATTR_LAST_SUCCESSFUL_UPDATE: self.last_successful_update}
+ return attr
@property
def unit_of_measurement(self):
@@ -165,13 +210,14 @@ class MiFloraSensor(Entity):
data = self.poller.parameter_value(self.parameter)
except (OSError, BluetoothBackendException) as err:
_LOGGER.info("Polling error %s: %s", type(err).__name__, err)
- self._available = False
return
if data is not None:
_LOGGER.debug("%s = %s", self.name, data)
- self._available = True
+ if self._unit == TEMP_FAHRENHEIT:
+ data = celsius_to_fahrenheit(data)
self.data.append(data)
+ self.last_successful_update = dt_util.utcnow()
else:
_LOGGER.info("Did not receive any data from Mi Flora sensor %s", self.name)
# Remove old data from median list or set sensor value to None
diff --git a/homeassistant/components/mikrotik/.translations/no.json b/homeassistant/components/mikrotik/.translations/no.json
index f842dd148ec..8e18b27f0de 100644
--- a/homeassistant/components/mikrotik/.translations/no.json
+++ b/homeassistant/components/mikrotik/.translations/no.json
@@ -14,14 +14,14 @@
"host": "Vert",
"name": "Navn",
"password": "Passord",
- "port": "Port",
+ "port": "",
"username": "Brukernavn",
"verify_ssl": "Bruk ssl"
},
"title": "Konfigurere Mikrotik-ruter"
}
},
- "title": "Mikrotik"
+ "title": ""
},
"options": {
"step": {
diff --git a/homeassistant/components/minecraft_server/.translations/ca.json b/homeassistant/components/minecraft_server/.translations/ca.json
index 86856ac2d11..e205090d0cd 100644
--- a/homeassistant/components/minecraft_server/.translations/ca.json
+++ b/homeassistant/components/minecraft_server/.translations/ca.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Amfitri\u00f3",
- "name": "Nom",
- "port": "Port"
+ "name": "Nom"
},
"description": "Configuraci\u00f3 d'una inst\u00e0ncia de servidor de Minecraft per poder monitoritzar-lo.",
"title": "Enlla\u00e7 del servidor de Minecraft"
diff --git a/homeassistant/components/minecraft_server/.translations/da.json b/homeassistant/components/minecraft_server/.translations/da.json
index bf930f2f277..e536234ffdb 100644
--- a/homeassistant/components/minecraft_server/.translations/da.json
+++ b/homeassistant/components/minecraft_server/.translations/da.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "V\u00e6rt",
- "name": "Navn",
- "port": "Port"
+ "name": "Navn"
},
"description": "Konfigurer din Minecraft-server-instans for at tillade overv\u00e5gning.",
"title": "Forbind din Minecraft-server"
diff --git a/homeassistant/components/minecraft_server/.translations/de.json b/homeassistant/components/minecraft_server/.translations/de.json
index 00426308239..31f0fe2c0f0 100644
--- a/homeassistant/components/minecraft_server/.translations/de.json
+++ b/homeassistant/components/minecraft_server/.translations/de.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Name",
- "port": "Port"
+ "name": "Name"
},
"description": "Richte deine Minecraft Server-Instanz ein, um es \u00fcberwachen zu k\u00f6nnen.",
"title": "Verkn\u00fcpfe deinen Minecraft Server"
diff --git a/homeassistant/components/minecraft_server/.translations/en.json b/homeassistant/components/minecraft_server/.translations/en.json
index d0f7a5d6300..fa04208cac9 100644
--- a/homeassistant/components/minecraft_server/.translations/en.json
+++ b/homeassistant/components/minecraft_server/.translations/en.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Name",
- "port": "Port"
+ "name": "Name"
},
"description": "Set up your Minecraft Server instance to allow monitoring.",
"title": "Link your Minecraft Server"
diff --git a/homeassistant/components/minecraft_server/.translations/es.json b/homeassistant/components/minecraft_server/.translations/es.json
index 14831ef45e1..a4509ba68d4 100644
--- a/homeassistant/components/minecraft_server/.translations/es.json
+++ b/homeassistant/components/minecraft_server/.translations/es.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Nombre",
- "port": "Puerto"
+ "name": "Nombre"
},
"description": "Configura tu instancia de Minecraft Server para permitir la supervisi\u00f3n.",
"title": "Enlace su servidor Minecraft"
diff --git a/homeassistant/components/minecraft_server/.translations/fr.json b/homeassistant/components/minecraft_server/.translations/fr.json
index bf87c6f3d73..c52021806d8 100644
--- a/homeassistant/components/minecraft_server/.translations/fr.json
+++ b/homeassistant/components/minecraft_server/.translations/fr.json
@@ -7,8 +7,7 @@
"user": {
"data": {
"host": "H\u00f4te",
- "name": "Nom",
- "port": "Port"
+ "name": "Nom"
},
"title": "Reliez votre serveur Minecraft"
}
diff --git a/homeassistant/components/minecraft_server/.translations/hu.json b/homeassistant/components/minecraft_server/.translations/hu.json
index 9341bdbe4d1..4cf4a7a72fb 100644
--- a/homeassistant/components/minecraft_server/.translations/hu.json
+++ b/homeassistant/components/minecraft_server/.translations/hu.json
@@ -7,10 +7,9 @@
"user": {
"data": {
"host": "Kiszolg\u00e1l\u00f3",
- "name": "N\u00e9v",
- "port": "Port"
+ "name": "N\u00e9v"
},
- "title": "Kapcsolja \u00f6ssze a Minecraft szervert"
+ "title": "Kapcsold \u00f6ssze a Minecraft szervered"
}
},
"title": "Minecraft szerver"
diff --git a/homeassistant/components/minecraft_server/.translations/it.json b/homeassistant/components/minecraft_server/.translations/it.json
index 5861eebcc9a..a17ed15a546 100644
--- a/homeassistant/components/minecraft_server/.translations/it.json
+++ b/homeassistant/components/minecraft_server/.translations/it.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Nome",
- "port": "Porta"
+ "name": "Nome"
},
"description": "Configurare l'istanza del Server Minecraft per consentire il monitoraggio.",
"title": "Collega il tuo Server Minecraft"
diff --git a/homeassistant/components/minecraft_server/.translations/ko.json b/homeassistant/components/minecraft_server/.translations/ko.json
index 66b281cc5d9..ee3ee24db70 100644
--- a/homeassistant/components/minecraft_server/.translations/ko.json
+++ b/homeassistant/components/minecraft_server/.translations/ko.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "\ud638\uc2a4\ud2b8",
- "name": "\uc774\ub984",
- "port": "\ud3ec\ud2b8"
+ "name": "\uc774\ub984"
},
"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"
diff --git a/homeassistant/components/minecraft_server/.translations/lb.json b/homeassistant/components/minecraft_server/.translations/lb.json
index f95dd062005..23157202469 100644
--- a/homeassistant/components/minecraft_server/.translations/lb.json
+++ b/homeassistant/components/minecraft_server/.translations/lb.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Apparat",
- "name": "Numm",
- "port": "Port"
+ "name": "Numm"
},
"description": "Riicht deng Minecraft Server Instanz a fir d'Iwwerwaachung z'erlaben",
"title": "Verbann d\u00e4in Minecraft Server"
diff --git a/homeassistant/components/minecraft_server/.translations/lv.json b/homeassistant/components/minecraft_server/.translations/lv.json
index 7de2aaadfc8..a46db9e75e5 100644
--- a/homeassistant/components/minecraft_server/.translations/lv.json
+++ b/homeassistant/components/minecraft_server/.translations/lv.json
@@ -3,8 +3,7 @@
"step": {
"user": {
"data": {
- "name": "Nosaukums",
- "port": "Ports"
+ "name": "Nosaukums"
}
}
}
diff --git a/homeassistant/components/minecraft_server/.translations/nl.json b/homeassistant/components/minecraft_server/.translations/nl.json
index 75e19bc2550..4f42a16362b 100644
--- a/homeassistant/components/minecraft_server/.translations/nl.json
+++ b/homeassistant/components/minecraft_server/.translations/nl.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Naam",
- "port": "Poort"
+ "name": "Naam"
},
"description": "Stel uw Minecraft server in om monitoring toe te staan.",
"title": "Koppel uw Minecraft server"
diff --git a/homeassistant/components/minecraft_server/.translations/no.json b/homeassistant/components/minecraft_server/.translations/no.json
index f7be289d48c..cd627cbe4ba 100644
--- a/homeassistant/components/minecraft_server/.translations/no.json
+++ b/homeassistant/components/minecraft_server/.translations/no.json
@@ -12,13 +12,12 @@
"user": {
"data": {
"host": "Vert",
- "name": "Navn",
- "port": "Port"
+ "name": "Navn"
},
"description": "Konfigurer Minecraft Server-forekomsten slik at den kan overv\u00e5kes.",
"title": "Link din Minecraft Server"
}
},
- "title": "Minecraft Server"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/minecraft_server/.translations/pl.json b/homeassistant/components/minecraft_server/.translations/pl.json
index f9c4a515566..e277579ea23 100644
--- a/homeassistant/components/minecraft_server/.translations/pl.json
+++ b/homeassistant/components/minecraft_server/.translations/pl.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Nazwa",
- "port": "Port"
+ "name": "Nazwa"
},
"description": "Skonfiguruj instancj\u0119 serwera Minecraft, aby umo\u017cliwi\u0107 monitorowanie.",
"title": "Po\u0142\u0105cz sw\u00f3j serwer Minecraft"
diff --git a/homeassistant/components/minecraft_server/.translations/ru.json b/homeassistant/components/minecraft_server/.translations/ru.json
index 916b342ee4a..a07b84077a9 100644
--- a/homeassistant/components/minecraft_server/.translations/ru.json
+++ b/homeassistant/components/minecraft_server/.translations/ru.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "\u0425\u043e\u0441\u0442",
- "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
- "port": "\u041f\u043e\u0440\u0442"
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435"
},
"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"
diff --git a/homeassistant/components/minecraft_server/.translations/sl.json b/homeassistant/components/minecraft_server/.translations/sl.json
index cf8a8af54ee..d1ed6a36c35 100644
--- a/homeassistant/components/minecraft_server/.translations/sl.json
+++ b/homeassistant/components/minecraft_server/.translations/sl.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Gostitelj",
- "name": "Ime",
- "port": "Vrata"
+ "name": "Ime"
},
"description": "Nastavite svoj Minecraft stre\u017enik, da omogo\u010dite spremljanje.",
"title": "Pove\u017eite svoj Minecraft stre\u017enik"
diff --git a/homeassistant/components/minecraft_server/.translations/sv.json b/homeassistant/components/minecraft_server/.translations/sv.json
index acf941878dd..e95938f1590 100644
--- a/homeassistant/components/minecraft_server/.translations/sv.json
+++ b/homeassistant/components/minecraft_server/.translations/sv.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "V\u00e4rd",
- "name": "Namn",
- "port": "Port"
+ "name": "Namn"
},
"description": "St\u00e4ll in din Minecraft Server-instans f\u00f6r att till\u00e5ta \u00f6vervakning.",
"title": "L\u00e4nka din Minecraft-server"
diff --git a/homeassistant/components/minecraft_server/.translations/tr.json b/homeassistant/components/minecraft_server/.translations/tr.json
index 595c1686982..fb76f697cd5 100644
--- a/homeassistant/components/minecraft_server/.translations/tr.json
+++ b/homeassistant/components/minecraft_server/.translations/tr.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "Host",
- "name": "Ad",
- "port": "Port"
+ "name": "Ad"
},
"description": "G\u00f6zetmeye izin vermek i\u00e7in Minecraft server nesnesini ayarla.",
"title": "Minecraft Servern\u0131 ba\u011fla"
diff --git a/homeassistant/components/minecraft_server/.translations/zh-Hant.json b/homeassistant/components/minecraft_server/.translations/zh-Hant.json
index c451ad71065..fbcde2a6be1 100644
--- a/homeassistant/components/minecraft_server/.translations/zh-Hant.json
+++ b/homeassistant/components/minecraft_server/.translations/zh-Hant.json
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "\u4e3b\u6a5f\u7aef",
- "name": "\u540d\u7a31",
- "port": "\u901a\u8a0a\u57e0"
+ "name": "\u540d\u7a31"
},
"description": "\u8a2d\u5b9a Minecraft \u4f3a\u670d\u5668\u4ee5\u9032\u884c\u76e3\u63a7\u3002",
"title": "\u9023\u7d50 Minecraft \u4f3a\u670d\u5668"
diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py
index a025c44e33c..3a8598d3fac 100644
--- a/homeassistant/components/minecraft_server/__init__.py
+++ b/homeassistant/components/minecraft_server/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from . import helpers
from .const import DOMAIN, MANUFACTURER, SCAN_INTERVAL, SIGNAL_NAME_PREFIX
PLATFORMS = ["binary_sensor", "sensor"]
@@ -37,10 +38,9 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
# Create and store server instance.
unique_id = config_entry.unique_id
_LOGGER.debug(
- "Creating server instance for '%s' (host='%s', port=%s)",
+ "Creating server instance for '%s' (%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
@@ -82,7 +82,6 @@ class MinecraftServer:
"""Representation of a Minecraft server."""
# Private constants
- _MAX_RETRIES_PING = 3
_MAX_RETRIES_STATUS = 3
def __init__(
@@ -98,6 +97,7 @@ class MinecraftServer:
self.port = config_data[CONF_PORT]
self.online = False
self._last_status_request_failed = False
+ self.srv_record_checked = False
# 3rd party library instance
self._mc_status = MCStatus(self.host, self.port)
@@ -127,15 +127,36 @@ class MinecraftServer:
self._stop_periodic_update()
async def async_check_connection(self) -> None:
- """Check server connection using a 'ping' request and store result."""
+ """Check server connection using a 'status' request and store connection status."""
+ # Check if host is a valid SRV record, if not already done.
+ if not self.srv_record_checked:
+ self.srv_record_checked = True
+ srv_record = await helpers.async_check_srv_record(self._hass, self.host)
+ if srv_record is not None:
+ _LOGGER.debug(
+ "'%s' is a valid Minecraft SRV record ('%s:%s')",
+ self.host,
+ srv_record[CONF_HOST],
+ srv_record[CONF_PORT],
+ )
+ # Overwrite host, port and 3rd party library instance
+ # with data extracted out of SRV record.
+ self.host = srv_record[CONF_HOST]
+ self.port = srv_record[CONF_PORT]
+ self._mc_status = MCStatus(self.host, self.port)
+
+ # Ping the server with a status request.
try:
await self._hass.async_add_executor_job(
- self._mc_status.ping, self._MAX_RETRIES_PING
+ self._mc_status.status, self._MAX_RETRIES_STATUS
)
self.online = True
except OSError as error:
_LOGGER.debug(
- "Error occurred while trying to ping the server - OSError: %s", error
+ "Error occurred while trying to check the connection to '%s:%s' - OSError: %s",
+ self.host,
+ self.port,
+ error,
)
self.online = False
@@ -148,9 +169,9 @@ class MinecraftServer:
# Inform user once about connection state changes if necessary.
if server_online_old and not server_online:
- _LOGGER.warning("Connection to server lost")
+ _LOGGER.warning("Connection to '%s:%s' lost", self.host, self.port)
elif not server_online_old and server_online:
- _LOGGER.info("Connection to server (re-)established")
+ _LOGGER.info("Connection to '%s:%s' (re-)established", self.host, self.port)
# Update the server properties if server is online.
if server_online:
@@ -179,7 +200,11 @@ class MinecraftServer:
# Inform user once about successful update if necessary.
if self._last_status_request_failed:
- _LOGGER.info("Updating the server properties succeeded again")
+ _LOGGER.info(
+ "Updating the properties of '%s:%s' succeeded again",
+ self.host,
+ self.port,
+ )
self._last_status_request_failed = False
except OSError as error:
# No answer to request, set all properties to unknown.
@@ -193,7 +218,10 @@ class MinecraftServer:
# 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,
+ "Updating the properties of '%s:%s' failed - OSError: %s",
+ self.host,
+ self.port,
+ error,
)
self._last_status_request_failed = True
diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py
index 8c6049a2c1b..a7cb0371f67 100644
--- a/homeassistant/components/minecraft_server/config_flow.py
+++ b/homeassistant/components/minecraft_server/config_flow.py
@@ -9,7 +9,7 @@ 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 . import MinecraftServer, helpers
from .const import ( # pylint: disable=unused-import
DEFAULT_HOST,
DEFAULT_NAME,
@@ -29,11 +29,24 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
- # User inputs.
- host = user_input[CONF_HOST]
- port = user_input[CONF_PORT]
+ host = None
+ port = DEFAULT_PORT
+ # Split address at last occurrence of ':'.
+ address_left, separator, address_right = user_input[CONF_HOST].rpartition(
+ ":"
+ )
+ # If no separator is found, 'rpartition' return ('', '', original_string).
+ if separator == "":
+ host = address_right
+ else:
+ host = address_left
+ try:
+ port = int(address_right)
+ except ValueError:
+ pass # 'port' is already set to default value.
- unique_id = ""
+ # Remove '[' and ']' in case of an IPv6 address.
+ host = host.strip("[]")
# Check if 'host' is a valid IP address and if so, get the MAC address.
ip_address = None
@@ -42,6 +55,7 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
ip_address = ipaddress.ip_address(host)
except ValueError:
# Host is not a valid IP address.
+ # Continue with host and port.
pass
else:
# Host is a valid IP address.
@@ -55,38 +69,56 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
partial(getmac.get_mac_address, **params)
)
- # Validate IP address via valid MAC address.
+ # Validate IP address (MAC address must be available).
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.
+ # Validate host and port by checking the server connection.
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)
+ # Create server instance with configuration data and ping the server.
+ config_data = {
+ CONF_NAME: user_input[CONF_NAME],
+ CONF_HOST: host,
+ CONF_PORT: port,
+ }
+ server = MinecraftServer(self.hass, "dummy_unique_id", config_data)
await server.async_check_connection()
if not server.online:
# Host or port invalid or server not reachable.
errors["base"] = "cannot_connect"
else:
+ # Build unique_id and config entry title.
+ unique_id = ""
+ title = f"{host}:{port}"
+ 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 and port (to support
+ # servers with same MAC address but different ports).
+ unique_id = f"{mac_address}-{port}"
+ if ip_address.version == 6:
+ title = f"[{host}]:{port}"
+ else:
+ # Check if 'host' is a valid SRV record.
+ srv_record = await helpers.async_check_srv_record(
+ self.hass, host
+ )
+ if srv_record is not None:
+ # Use only SRV host name in unique_id (does not change).
+ unique_id = f"{host}-srv"
+ title = host
+ else:
+ # Use host name and port in unique_id (to support servers with
+ # same host name but different ports).
+ 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()
+
# Configuration data are available and no error was detected, create configuration entry.
- return self.async_create_entry(
- title=f"{host}:{port}", data=user_input
- )
+ return self.async_create_entry(title=title, data=config_data)
# Show configuration form (default form in case of no user_input,
# form filled with user_input and eventually with errors otherwise).
@@ -107,9 +139,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
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
index d86faf23a81..52e6ae8fd5e 100644
--- a/homeassistant/components/minecraft_server/const.py
+++ b/homeassistant/components/minecraft_server/const.py
@@ -2,7 +2,7 @@
ATTR_PLAYERS_LIST = "players_list"
-DEFAULT_HOST = "localhost"
+DEFAULT_HOST = "localhost:25565"
DEFAULT_NAME = "Minecraft Server"
DEFAULT_PORT = 25565
@@ -30,6 +30,8 @@ SCAN_INTERVAL = 60
SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}"
+SRV_RECORD_PREFIX = "_minecraft._tcp"
+
UNIT_PLAYERS_MAX = "players"
UNIT_PLAYERS_ONLINE = "players"
UNIT_PROTOCOL_VERSION = None
diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py
new file mode 100644
index 00000000000..7f9380cdec2
--- /dev/null
+++ b/homeassistant/components/minecraft_server/helpers.py
@@ -0,0 +1,32 @@
+"""Helper functions for the Minecraft Server integration."""
+
+from typing import Any, Dict
+
+import aiodns
+
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import SRV_RECORD_PREFIX
+
+
+async def async_check_srv_record(hass: HomeAssistantType, host: str) -> Dict[str, Any]:
+ """Check if the given host is a valid Minecraft SRV record."""
+ # Check if 'host' is a valid SRV record.
+ return_value = None
+ srv_records = None
+ try:
+ srv_records = await aiodns.DNSResolver().query(
+ host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV"
+ )
+ except (aiodns.error.DNSError):
+ # 'host' is not a SRV record.
+ pass
+ else:
+ # 'host' is a valid SRV record, extract the data.
+ return_value = {
+ CONF_HOST: srv_records[0].host,
+ CONF_PORT: srv_records[0].port,
+ }
+
+ return return_value
diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json
index 1dda76dee77..0811c168f9f 100644
--- a/homeassistant/components/minecraft_server/manifest.json
+++ b/homeassistant/components/minecraft_server/manifest.json
@@ -3,7 +3,7 @@
"name": "Minecraft Server",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
- "requirements": ["getmac==0.8.1", "mcstatus==2.3.0"],
+ "requirements": ["aiodns==2.0.0", "getmac==0.8.1", "mcstatus==2.3.0"],
"dependencies": [],
"codeowners": ["@elmurato"],
"quality_scale": "silver"
diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json
index 7743d940be6..3a2408694ad 100644
--- a/homeassistant/components/minecraft_server/strings.json
+++ b/homeassistant/components/minecraft_server/strings.json
@@ -7,8 +7,7 @@
"description": "Set up your Minecraft Server instance to allow monitoring.",
"data": {
"name": "Name",
- "host": "Host",
- "port": "Port"
+ "host": "Host"
}
}
},
diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py
index ab0409694d1..c42901cd6c5 100644
--- a/homeassistant/components/mjpeg/camera.py
+++ b/homeassistant/components/mjpeg/camera.py
@@ -122,10 +122,10 @@ class MjpegCamera(Camera):
return image
except asyncio.TimeoutError:
- _LOGGER.error("Timeout getting camera image")
+ _LOGGER.error("Timeout getting camera image from %s", self._name)
except aiohttp.ClientError as err:
- _LOGGER.error("Error getting new camera image: %s", err)
+ _LOGGER.error("Error getting new camera image from %s: %s", self._name, err)
def camera_image(self):
"""Return a still image response from the camera."""
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
index 218d3d3baa9..869d9f7ac67 100644
--- a/homeassistant/components/modbus/__init__.py
+++ b/homeassistant/components/modbus/__init__.py
@@ -1,13 +1,19 @@
"""Support for Modbus."""
+import asyncio
import logging
-import threading
-from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient
+from pymodbus.client.asynchronous import schedulers
+from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient as ClientSerial
+from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient as ClientTCP
+from pymodbus.client.asynchronous.udp import AsyncModbusUDPClient as ClientUDP
+from pymodbus.exceptions import ModbusException
+from pymodbus.pdu import ExceptionResponse
from pymodbus.transaction import ModbusRtuFramer
import voluptuous as vol
from homeassistant.const import (
ATTR_STATE,
+ CONF_DELAY,
CONF_HOST,
CONF_METHOD,
CONF_NAME,
@@ -19,25 +25,23 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
+from .const import (
+ ATTR_ADDRESS,
+ ATTR_HUB,
+ ATTR_UNIT,
+ ATTR_VALUE,
+ CONF_BAUDRATE,
+ CONF_BYTESIZE,
+ CONF_PARITY,
+ CONF_STOPBITS,
+ DEFAULT_HUB,
+ MODBUS_DOMAIN,
+ SERVICE_WRITE_COIL,
+ SERVICE_WRITE_REGISTER,
+)
+
_LOGGER = logging.getLogger(__name__)
-ATTR_ADDRESS = "address"
-ATTR_HUB = "hub"
-ATTR_UNIT = "unit"
-ATTR_VALUE = "value"
-
-CONF_BAUDRATE = "baudrate"
-CONF_BYTESIZE = "bytesize"
-CONF_HUB = "hub"
-CONF_PARITY = "parity"
-CONF_STOPBITS = "stopbits"
-
-DEFAULT_HUB = "default"
-DOMAIN = "modbus"
-
-SERVICE_WRITE_COIL = "write_coil"
-SERVICE_WRITE_REGISTER = "write_register"
-
BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string})
SERIAL_SCHEMA = BASE_SCHEMA.extend(
@@ -59,11 +63,12 @@ ETHERNET_SCHEMA = BASE_SCHEMA.extend(
vol.Required(CONF_PORT): cv.port,
vol.Required(CONF_TYPE): vol.Any("tcp", "udp", "rtuovertcp"),
vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout,
+ vol.Optional(CONF_DELAY, default=0): cv.positive_int,
}
)
CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])},
+ {MODBUS_DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])},
extra=vol.ALLOW_EXTRA,
)
@@ -88,97 +93,65 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema(
)
-def setup_client(client_config):
- """Set up pymodbus client."""
- client_type = client_config[CONF_TYPE]
-
- if client_type == "serial":
- return ModbusSerialClient(
- method=client_config[CONF_METHOD],
- port=client_config[CONF_PORT],
- baudrate=client_config[CONF_BAUDRATE],
- stopbits=client_config[CONF_STOPBITS],
- bytesize=client_config[CONF_BYTESIZE],
- parity=client_config[CONF_PARITY],
- timeout=client_config[CONF_TIMEOUT],
- )
- if client_type == "rtuovertcp":
- return ModbusTcpClient(
- host=client_config[CONF_HOST],
- port=client_config[CONF_PORT],
- framer=ModbusRtuFramer,
- timeout=client_config[CONF_TIMEOUT],
- )
- if client_type == "tcp":
- return ModbusTcpClient(
- host=client_config[CONF_HOST],
- port=client_config[CONF_PORT],
- timeout=client_config[CONF_TIMEOUT],
- )
- if client_type == "udp":
- return ModbusUdpClient(
- host=client_config[CONF_HOST],
- port=client_config[CONF_PORT],
- timeout=client_config[CONF_TIMEOUT],
- )
- assert False
-
-
-def setup(hass, config):
+async def async_setup(hass, config):
"""Set up Modbus component."""
- hass.data[DOMAIN] = hub_collect = {}
+ hass.data[MODBUS_DOMAIN] = hub_collect = {}
- for client_config in config[DOMAIN]:
- client = setup_client(client_config)
- name = client_config[CONF_NAME]
- hub_collect[name] = ModbusHub(client, name)
- _LOGGER.debug("Setting up hub: %s", client_config)
+ _LOGGER.debug("registering hubs")
+ for client_config in config[MODBUS_DOMAIN]:
+ hub_collect[client_config[CONF_NAME]] = ModbusHub(client_config, hass.loop)
def stop_modbus(event):
"""Stop Modbus service."""
for client in hub_collect.values():
- client.close()
+ del client
def start_modbus(event):
"""Start Modbus service."""
for client in hub_collect.values():
- client.connect()
+ _LOGGER.debug("setup hub %s", client.name)
+ client.setup()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus)
# Register services for modbus
- hass.services.register(
- DOMAIN,
+ hass.services.async_register(
+ MODBUS_DOMAIN,
SERVICE_WRITE_REGISTER,
write_register,
schema=SERVICE_WRITE_REGISTER_SCHEMA,
)
- hass.services.register(
- DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA
+ hass.services.async_register(
+ MODBUS_DOMAIN,
+ SERVICE_WRITE_COIL,
+ write_coil,
+ schema=SERVICE_WRITE_COIL_SCHEMA,
)
- def write_register(service):
+ async def write_register(service):
"""Write Modbus registers."""
unit = int(float(service.data[ATTR_UNIT]))
address = int(float(service.data[ATTR_ADDRESS]))
value = service.data[ATTR_VALUE]
client_name = service.data[ATTR_HUB]
if isinstance(value, list):
- hub_collect[client_name].write_registers(
+ await hub_collect[client_name].write_registers(
unit, address, [int(float(i)) for i in value]
)
else:
- hub_collect[client_name].write_register(unit, address, int(float(value)))
+ await hub_collect[client_name].write_register(
+ unit, address, int(float(value))
+ )
- def write_coil(service):
+ async def write_coil(service):
"""Write Modbus coil."""
unit = service.data[ATTR_UNIT]
address = service.data[ATTR_ADDRESS]
state = service.data[ATTR_STATE]
client_name = service.data[ATTR_HUB]
- hub_collect[client_name].write_coil(unit, address, state)
+ await hub_collect[client_name].write_coil(unit, address, state)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_modbus)
return True
@@ -186,65 +159,153 @@ def setup(hass, config):
class ModbusHub:
"""Thread safe wrapper class for pymodbus."""
- def __init__(self, modbus_client, name):
+ def __init__(self, client_config, main_loop):
"""Initialize the Modbus hub."""
- self._client = modbus_client
- self._lock = threading.Lock()
- self._name = name
+ _LOGGER.debug("Preparing setup: %s", client_config)
+
+ # generic configuration
+ self._loop = main_loop
+ self._client = None
+ self._lock = asyncio.Lock()
+ self._config_name = client_config[CONF_NAME]
+ self._config_type = client_config[CONF_TYPE]
+ self._config_port = client_config[CONF_PORT]
+ self._config_timeout = client_config[CONF_TIMEOUT]
+ self._config_delay = client_config[CONF_DELAY]
+
+ if self._config_type == "serial":
+ # serial configuration
+ self._config_method = client_config[CONF_METHOD]
+ self._config_baudrate = client_config[CONF_BAUDRATE]
+ self._config_stopbits = client_config[CONF_STOPBITS]
+ self._config_bytesize = client_config[CONF_BYTESIZE]
+ self._config_parity = client_config[CONF_PARITY]
+ else:
+ # network configuration
+ self._config_host = client_config[CONF_HOST]
@property
def name(self):
"""Return the name of this hub."""
- return self._name
+ return self._config_name
- def close(self):
- """Disconnect client."""
- with self._lock:
- self._client.close()
+ async def _connect_delay(self):
+ if self._config_delay > 0:
+ await asyncio.sleep(self._config_delay)
+ self._config_delay = 0
- def connect(self):
- """Connect client."""
- with self._lock:
- self._client.connect()
+ def setup(self):
+ """Set up pymodbus client."""
+ # pylint: disable = E0633
+ # Client* do deliver loop, client as result but
+ # pylint does not accept that fact
- def read_coils(self, unit, address, count):
+ _LOGGER.debug("doing setup")
+ if self._config_type == "serial":
+ _, self._client = ClientSerial(
+ schedulers.ASYNC_IO,
+ method=self._config_method,
+ port=self._config_port,
+ baudrate=self._config_baudrate,
+ stopbits=self._config_stopbits,
+ bytesize=self._config_bytesize,
+ parity=self._config_parity,
+ timeout=self._config_timeout,
+ loop=self._loop,
+ )
+ elif self._config_type == "rtuovertcp":
+ _, self._client = ClientTCP(
+ schedulers.ASYNC_IO,
+ host=self._config_host,
+ port=self._config_port,
+ framer=ModbusRtuFramer,
+ timeout=self._config_timeout,
+ loop=self._loop,
+ )
+ elif self._config_type == "tcp":
+ _, self._client = ClientTCP(
+ schedulers.ASYNC_IO,
+ host=self._config_host,
+ port=self._config_port,
+ timeout=self._config_timeout,
+ loop=self._loop,
+ )
+ elif self._config_type == "udp":
+ _, self._client = ClientUDP(
+ schedulers.ASYNC_IO,
+ host=self._config_host,
+ port=self._config_port,
+ timeout=self._config_timeout,
+ loop=self._loop,
+ )
+ else:
+ assert False
+
+ async def _read(self, unit, address, count, func):
+ """Read generic with error handling."""
+ await self._connect_delay()
+ async with self._lock:
+ kwargs = {"unit": unit} if unit else {}
+ result = await func(address, count, **kwargs)
+ if isinstance(result, (ModbusException, ExceptionResponse)):
+ _LOGGER.error("Hub %s Exception (%s)", self._config_name, result)
+ return result
+
+ async def _write(self, unit, address, value, func):
+ """Read generic with error handling."""
+ await self._connect_delay()
+ async with self._lock:
+ kwargs = {"unit": unit} if unit else {}
+ await func(address, value, **kwargs)
+
+ async def read_coils(self, unit, address, count):
"""Read coils."""
- with self._lock:
- kwargs = {"unit": unit} if unit else {}
- return self._client.read_coils(address, count, **kwargs)
+ if self._client.protocol is None:
+ return None
+ return await self._read(unit, address, count, self._client.protocol.read_coils)
- def read_discrete_inputs(self, unit, address, count):
+ async 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)
+ if self._client.protocol is None:
+ return None
+ return await self._read(
+ unit, address, count, self._client.protocol.read_discrete_inputs
+ )
- def read_input_registers(self, unit, address, count):
+ async def read_input_registers(self, unit, address, count):
"""Read input registers."""
- with self._lock:
- kwargs = {"unit": unit} if unit else {}
- return self._client.read_input_registers(address, count, **kwargs)
+ if self._client.protocol is None:
+ return None
+ return await self._read(
+ unit, address, count, self._client.protocol.read_input_registers
+ )
- def read_holding_registers(self, unit, address, count):
+ async def read_holding_registers(self, unit, address, count):
"""Read holding registers."""
- with self._lock:
- kwargs = {"unit": unit} if unit else {}
- return self._client.read_holding_registers(address, count, **kwargs)
+ if self._client.protocol is None:
+ return None
+ return await self._read(
+ unit, address, count, self._client.protocol.read_holding_registers
+ )
- def write_coil(self, unit, address, value):
+ async def write_coil(self, unit, address, value):
"""Write coil."""
- with self._lock:
- kwargs = {"unit": unit} if unit else {}
- self._client.write_coil(address, value, **kwargs)
+ if self._client.protocol is None:
+ return None
+ return await self._write(unit, address, value, self._client.protocol.write_coil)
- def write_register(self, unit, address, value):
+ async def write_register(self, unit, address, value):
"""Write register."""
- with self._lock:
- kwargs = {"unit": unit} if unit else {}
- self._client.write_register(address, value, **kwargs)
+ if self._client.protocol is None:
+ return None
+ return await self._write(
+ unit, address, value, self._client.protocol.write_register
+ )
- def write_registers(self, unit, address, values):
+ async def write_registers(self, unit, address, values):
"""Write registers."""
- with self._lock:
- kwargs = {"unit": unit} if unit else {}
- self._client.write_registers(address, values, **kwargs)
+ if self._client.protocol is None:
+ return None
+ return await self._write(
+ unit, address, values, self._client.protocol.write_registers
+ )
diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py
index 8ea6e2dbfa6..51dfb7c5795 100644
--- a/homeassistant/components/modbus/binary_sensor.py
+++ b/homeassistant/components/modbus/binary_sensor.py
@@ -2,7 +2,7 @@
import logging
from typing import Optional
-from pymodbus.exceptions import ConnectionException, ModbusException
+from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@@ -14,27 +14,27 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE
from homeassistant.helpers import config_validation as cv
-from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+from .const import (
+ CALL_TYPE_COIL,
+ CALL_TYPE_DISCRETE,
+ CONF_ADDRESS,
+ CONF_COILS,
+ CONF_HUB,
+ CONF_INPUT_TYPE,
+ CONF_INPUTS,
+ DEFAULT_HUB,
+ MODBUS_DOMAIN,
+)
_LOGGER = logging.getLogger(__name__)
-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),
+ cv.deprecated(CONF_COILS, CONF_INPUTS),
PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_INPUTS): [
vol.All(
- cv.deprecated(CONF_DEPRECATED_COIL, CONF_ADDRESS),
+ cv.deprecated(CALL_TYPE_COIL, CONF_ADDRESS),
vol.Schema(
{
vol.Required(CONF_ADDRESS): cv.positive_int,
@@ -43,10 +43,8 @@ PLATFORM_SCHEMA = vol.All(
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]
- ),
+ CONF_INPUT_TYPE, default=CALL_TYPE_COIL
+ ): vol.In([CALL_TYPE_COIL, CALL_TYPE_DISCRETE]),
}
),
)
@@ -56,7 +54,7 @@ PLATFORM_SCHEMA = vol.All(
)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus binary sensors."""
sensors = []
for entry in config[CONF_INPUTS]:
@@ -109,33 +107,18 @@ class ModbusBinarySensor(BinarySensorDevice):
"""Return True if entity is available."""
return self._available
- def update(self):
+ async def async_update(self):
"""Update the state of the sensor."""
- try:
- 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()
+ if self._input_type == CALL_TYPE_COIL:
+ result = await self._hub.read_coils(self._slave, self._address, 1)
+ else:
+ result = await self._hub.read_discrete_inputs(self._slave, self._address, 1)
+ if result is None:
+ self._available = False
return
-
if isinstance(result, (ModbusException, ExceptionResponse)):
- self._set_unavailable()
+ self._available = False
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 f83b7d7b901..182dfeef2de 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -3,7 +3,7 @@ import logging
import struct
from typing import Optional
-from pymodbus.exceptions import ConnectionException, ModbusException
+from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@@ -21,30 +21,31 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
-from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+from .const import (
+ CALL_TYPE_REGISTER_HOLDING,
+ CALL_TYPE_REGISTER_INPUT,
+ CONF_CURRENT_TEMP,
+ CONF_CURRENT_TEMP_REGISTER_TYPE,
+ CONF_DATA_COUNT,
+ CONF_DATA_TYPE,
+ CONF_HUB,
+ CONF_MAX_TEMP,
+ CONF_MIN_TEMP,
+ CONF_OFFSET,
+ CONF_PRECISION,
+ CONF_SCALE,
+ CONF_STEP,
+ CONF_TARGET_TEMP,
+ CONF_UNIT,
+ DATA_TYPE_FLOAT,
+ DATA_TYPE_INT,
+ DATA_TYPE_UINT,
+ DEFAULT_HUB,
+ MODBUS_DOMAIN,
+)
_LOGGER = logging.getLogger(__name__)
-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"
-CONF_SCALE = "scale"
-CONF_OFFSET = "offset"
-CONF_UNIT = "temperature_unit"
-DATA_TYPE_INT = "int"
-DATA_TYPE_UINT = "uint"
-DATA_TYPE_FLOAT = "float"
-CONF_MAX_TEMP = "max_temp"
-CONF_MIN_TEMP = "min_temp"
-CONF_STEP = "temp_step"
-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(
{
@@ -52,10 +53,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_NAME): cv.string,
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_DATA_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]),
+ CONF_CURRENT_TEMP_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING
+ ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]),
vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In(
[DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]
),
@@ -71,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus Thermostat Platform."""
name = config[CONF_NAME]
modbus_slave = config[CONF_SLAVE]
@@ -79,7 +80,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
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]
+ count = config[CONF_DATA_COUNT]
precision = config[CONF_PRECISION]
scale = config[CONF_SCALE]
offset = config[CONF_OFFSET]
@@ -167,14 +168,14 @@ class ModbusThermostat(ClimateDevice):
@property
def supported_features(self):
"""Return the list of supported features."""
- return SUPPORT_FLAGS
+ return SUPPORT_TARGET_TEMPERATURE
- def update(self):
+ async def async_update(self):
"""Update Target & Current Temperature."""
- self._target_temperature = self._read_register(
- DEFAULT_REGISTER_TYPE_HOLDING, self._target_temperature_register
+ self._target_temperature = await self._read_register(
+ CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register
)
- self._current_temperature = self._read_register(
+ self._current_temperature = await self._read_register(
self._current_temperature_register_type, self._current_temperature_register
)
@@ -186,7 +187,7 @@ class ModbusThermostat(ClimateDevice):
@property
def hvac_modes(self):
"""Return the possible HVAC modes."""
- return HVAC_MODES
+ return [HVAC_MODE_AUTO]
@property
def name(self):
@@ -223,7 +224,7 @@ class ModbusThermostat(ClimateDevice):
"""Return the supported step of target temperature."""
return self._temp_step
- def set_temperature(self, **kwargs):
+ async def set_temperature(self, **kwargs):
"""Set new target temperature."""
target_temperature = int(
(kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale
@@ -232,30 +233,28 @@ class ModbusThermostat(ClimateDevice):
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)
+ await self._write_register(self._target_temperature_register, register_value)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
- def _read_register(self, register_type, register) -> Optional[float]:
+ async def _read_register(self, register_type, register) -> Optional[float]:
"""Read register using the Modbus hub slave."""
- try:
- 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)
+ if register_type == CALL_TYPE_REGISTER_INPUT:
+ result = await self._hub.read_input_registers(
+ self._slave, register, self._count
+ )
+ else:
+ result = await self._hub.read_holding_registers(
+ self._slave, register, self._count
+ )
+ if result is None:
+ self._available = False
return
-
if isinstance(result, (ModbusException, ExceptionResponse)):
- self._set_unavailable(register)
+ self._available = False
return
byte_string = b"".join(
@@ -270,25 +269,7 @@ class ModbusThermostat(ClimateDevice):
return register_value
- def _write_register(self, register, value):
+ async 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
-
+ await self._hub.write_registers(self._slave, register, [value, 0])
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/const.py b/homeassistant/components/modbus/const.py
new file mode 100644
index 00000000000..e507717b22c
--- /dev/null
+++ b/homeassistant/components/modbus/const.py
@@ -0,0 +1,72 @@
+"""Constants used in modbus integration."""
+
+# configuration names
+CONF_BAUDRATE = "baudrate"
+CONF_BYTESIZE = "bytesize"
+CONF_HUB = "hub"
+CONF_PARITY = "parity"
+CONF_STOPBITS = "stopbits"
+CONF_REGISTER = "register"
+CONF_REGISTER_TYPE = "register_type"
+CONF_REGISTERS = "registers"
+CONF_REVERSE_ORDER = "reverse_order"
+CONF_SCALE = "scale"
+CONF_COUNT = "count"
+CONF_PRECISION = "precision"
+CONF_OFFSET = "offset"
+CONF_COILS = "coils"
+
+# integration names
+DEFAULT_HUB = "default"
+MODBUS_DOMAIN = "modbus"
+
+# data types
+DATA_TYPE_CUSTOM = "custom"
+DATA_TYPE_FLOAT = "float"
+DATA_TYPE_INT = "int"
+DATA_TYPE_UINT = "uint"
+
+# call types
+CALL_TYPE_COIL = "coil"
+CALL_TYPE_DISCRETE = "discrete_input"
+CALL_TYPE_REGISTER_HOLDING = "holding"
+CALL_TYPE_REGISTER_INPUT = "input"
+
+# the following constants are TBD.
+# changing those in general causes a breaking change, because
+# the contents of configuration.yaml needs to be updated,
+# therefore they are left to a later date.
+# but kept here, with a reference to the file using them.
+
+# __init.py
+ATTR_ADDRESS = "address"
+ATTR_HUB = "hub"
+ATTR_UNIT = "unit"
+ATTR_VALUE = "value"
+SERVICE_WRITE_COIL = "write_coil"
+SERVICE_WRITE_REGISTER = "write_register"
+
+# binary_sensor.py
+CONF_INPUTS = "inputs"
+CONF_INPUT_TYPE = "input_type"
+CONF_ADDRESS = "address"
+
+# sensor.py
+# CONF_DATA_TYPE = "data_type"
+
+# switch.py
+CONF_STATE_OFF = "state_off"
+CONF_STATE_ON = "state_on"
+CONF_VERIFY_REGISTER = "verify_register"
+CONF_VERIFY_STATE = "verify_state"
+
+# climate.py
+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_DATA_COUNT = "data_count"
+CONF_UNIT = "temperature_unit"
+CONF_MAX_TEMP = "max_temp"
+CONF_MIN_TEMP = "min_temp"
+CONF_STEP = "temp_step"
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
index 92ebd5b8686..d1d2a9db550 100644
--- a/homeassistant/components/modbus/manifest.json
+++ b/homeassistant/components/modbus/manifest.json
@@ -2,7 +2,7 @@
"domain": "modbus",
"name": "Modbus",
"documentation": "https://www.home-assistant.io/integrations/modbus",
- "requirements": ["pymodbus==1.5.2"],
+ "requirements": ["pymodbus==2.3.0"],
"dependencies": [],
- "codeowners": ["@adamchengtkc"]
+ "codeowners": ["@adamchengtkc", "@janiversen"]
}
diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py
index b586ad852df..8c2b950648b 100644
--- a/homeassistant/components/modbus/sensor.py
+++ b/homeassistant/components/modbus/sensor.py
@@ -3,7 +3,7 @@ import logging
import struct
from typing import Any, Optional, Union
-from pymodbus.exceptions import ConnectionException, ModbusException
+from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@@ -19,27 +19,28 @@ from homeassistant.const import (
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
-from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+from .const import (
+ CALL_TYPE_REGISTER_HOLDING,
+ CALL_TYPE_REGISTER_INPUT,
+ CONF_COUNT,
+ CONF_DATA_TYPE,
+ CONF_HUB,
+ CONF_PRECISION,
+ CONF_REGISTER,
+ CONF_REGISTER_TYPE,
+ CONF_REGISTERS,
+ CONF_REVERSE_ORDER,
+ CONF_SCALE,
+ DATA_TYPE_CUSTOM,
+ DATA_TYPE_FLOAT,
+ DATA_TYPE_INT,
+ DATA_TYPE_UINT,
+ DEFAULT_HUB,
+ MODBUS_DOMAIN,
+)
_LOGGER = logging.getLogger(__name__)
-CONF_COUNT = "count"
-CONF_DATA_TYPE = "data_type"
-CONF_PRECISION = "precision"
-CONF_REGISTER = "register"
-CONF_REGISTER_TYPE = "register_type"
-CONF_REGISTERS = "registers"
-CONF_REVERSE_ORDER = "reverse_order"
-CONF_SCALE = "scale"
-
-DATA_TYPE_CUSTOM = "custom"
-DATA_TYPE_FLOAT = "float"
-DATA_TYPE_INT = "int"
-DATA_TYPE_UINT = "uint"
-
-DEFAULT_REGISTER_TYPE_HOLDING = "holding"
-DEFAULT_REGISTER_TYPE_INPUT = "input"
-
def number(value: Any) -> Union[int, float]:
"""Coerce a value to number without losing precision."""
@@ -75,8 +76,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_OFFSET, default=0): number,
vol.Optional(CONF_PRECISION, default=0): cv.positive_int,
vol.Optional(
- CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING
- ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]),
+ CONF_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING
+ ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]),
vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean,
vol.Optional(CONF_SCALE, default=1): number,
vol.Optional(CONF_SLAVE): cv.positive_int,
@@ -88,7 +89,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus sensors."""
sensors = []
data_types = {DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}}
@@ -218,23 +219,21 @@ class ModbusRegisterSensor(RestoreEntity):
"""Return True if entity is available."""
return self._available
- def update(self):
+ async def async_update(self):
"""Update the state of the sensor."""
- try:
- 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()
+ if self._register_type == CALL_TYPE_REGISTER_INPUT:
+ result = await self._hub.read_input_registers(
+ self._slave, self._register, self._count
+ )
+ else:
+ result = await self._hub.read_holding_registers(
+ self._slave, self._register, self._count
+ )
+ if result is None:
+ self._available = False
return
-
if isinstance(result, (ModbusException, ExceptionResponse)):
- self._set_unavailable()
+ self._available = False
return
registers = result.registers
@@ -252,16 +251,3 @@ class ModbusRegisterSensor(RestoreEntity):
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/switch.py b/homeassistant/components/modbus/switch.py
index d4f52622538..d7d6f121874 100644
--- a/homeassistant/components/modbus/switch.py
+++ b/homeassistant/components/modbus/switch.py
@@ -2,7 +2,7 @@
import logging
from typing import Optional
-from pymodbus.exceptions import ConnectionException, ModbusException
+from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@@ -18,22 +18,25 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.restore_state import RestoreEntity
-from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+from .const import (
+ CALL_TYPE_COIL,
+ CALL_TYPE_REGISTER_HOLDING,
+ CALL_TYPE_REGISTER_INPUT,
+ CONF_COILS,
+ CONF_HUB,
+ CONF_REGISTER,
+ CONF_REGISTER_TYPE,
+ CONF_REGISTERS,
+ CONF_STATE_OFF,
+ CONF_STATE_ON,
+ CONF_VERIFY_REGISTER,
+ CONF_VERIFY_STATE,
+ DEFAULT_HUB,
+ MODBUS_DOMAIN,
+)
_LOGGER = logging.getLogger(__name__)
-CONF_COIL = "coil"
-CONF_COILS = "coils"
-CONF_REGISTER = "register"
-CONF_REGISTER_TYPE = "register_type"
-CONF_REGISTERS = "registers"
-CONF_STATE_OFF = "state_off"
-CONF_STATE_ON = "state_on"
-CONF_VERIFY_REGISTER = "verify_register"
-CONF_VERIFY_STATE = "verify_state"
-
-DEFAULT_REGISTER_TYPE_HOLDING = "holding"
-DEFAULT_REGISTER_TYPE_INPUT = "input"
REGISTERS_SCHEMA = vol.Schema(
{
@@ -42,8 +45,8 @@ REGISTERS_SCHEMA = vol.Schema(
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=DEFAULT_REGISTER_TYPE_HOLDING): vol.In(
- [DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]
+ vol.Optional(CONF_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING): vol.In(
+ [CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]
),
vol.Optional(CONF_SLAVE): cv.positive_int,
vol.Optional(CONF_STATE_OFF): cv.positive_int,
@@ -55,7 +58,7 @@ REGISTERS_SCHEMA = vol.Schema(
COILS_SCHEMA = vol.Schema(
{
- vol.Required(CONF_COIL): cv.positive_int,
+ vol.Required(CALL_TYPE_COIL): cv.positive_int,
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_SLAVE): cv.positive_int,
vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
@@ -73,7 +76,7 @@ PLATFORM_SCHEMA = vol.All(
)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Read configuration and create Modbus devices."""
switches = []
if CONF_COILS in config:
@@ -82,7 +85,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
hub = hass.data[MODBUS_DOMAIN][hub_name]
switches.append(
ModbusCoilSwitch(
- hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CONF_COIL]
+ hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CALL_TYPE_COIL]
)
)
if CONF_REGISTERS in config:
@@ -143,28 +146,26 @@ class ModbusCoilSwitch(ToggleEntity, RestoreEntity):
"""Return True if entity is available."""
return self._available
- def turn_on(self, **kwargs):
+ async def turn_on(self, **kwargs):
"""Set switch on."""
- self._write_coil(self._coil, True)
+ await self._write_coil(self._coil, True)
- def turn_off(self, **kwargs):
+ async def turn_off(self, **kwargs):
"""Set switch off."""
- self._write_coil(self._coil, False)
+ await self._write_coil(self._coil, False)
- def update(self):
+ async def async_update(self):
"""Update the state of the switch."""
- self._is_on = self._read_coil(self._coil)
+ self._is_on = await self._read_coil(self._coil)
- def _read_coil(self, coil) -> Optional[bool]:
+ async def _read_coil(self, coil) -> Optional[bool]:
"""Read coil using the Modbus hub slave."""
- try:
- result = self._hub.read_coils(self._slave, coil, 1)
- except ConnectionException:
- self._set_unavailable()
+ result = await self._hub.read_coils(self._slave, coil, 1)
+ if result is None:
+ self._available = False
return
-
if isinstance(result, (ModbusException, ExceptionResponse)):
- self._set_unavailable()
+ self._available = False
return
value = bool(result.bits[0])
@@ -172,29 +173,11 @@ class ModbusCoilSwitch(ToggleEntity, RestoreEntity):
return value
- def _write_coil(self, coil, value):
+ async 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
-
+ await self._hub.write_coil(self._slave, coil, value)
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):
"""Representation of a Modbus register switch."""
@@ -238,21 +221,21 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
self._is_on = None
- def turn_on(self, **kwargs):
+ async def turn_on(self, **kwargs):
"""Set switch on."""
# Only holding register is writable
- if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING:
- self._write_register(self._command_on)
+ if self._register_type == CALL_TYPE_REGISTER_HOLDING:
+ await self._write_register(self._command_on)
if not self._verify_state:
self._is_on = True
- def turn_off(self, **kwargs):
+ async def turn_off(self, **kwargs):
"""Set switch off."""
# Only holding register is writable
- if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING:
- self._write_register(self._command_off)
+ if self._register_type == CALL_TYPE_REGISTER_HOLDING:
+ await self._write_register(self._command_off)
if not self._verify_state:
self._is_on = False
@@ -261,12 +244,12 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
"""Return True if entity is available."""
return self._available
- def update(self):
+ async def async_update(self):
"""Update the state of the switch."""
if not self._verify_state:
return
- value = self._read_register()
+ value = await self._read_register()
if value == self._state_on:
self._is_on = True
elif value == self._state_off:
@@ -280,20 +263,20 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
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()
+ async def _read_register(self) -> Optional[int]:
+ if self._register_type == CALL_TYPE_REGISTER_INPUT:
+ result = await self._hub.read_input_registers(
+ self._slave, self._register, 1
+ )
+ else:
+ result = await self._hub.read_holding_registers(
+ self._slave, self._register, 1
+ )
+ if result is None:
+ self._available = False
return
-
if isinstance(result, (ModbusException, ExceptionResponse)):
- self._set_unavailable()
+ self._available = False
return
value = int(result.registers[0])
@@ -301,25 +284,7 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
return value
- def _write_register(self, value):
+ async 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
-
+ await self._hub.write_register(self._slave, self._register, value)
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/monoprice/.translations/ca.json b/homeassistant/components/monoprice/.translations/ca.json
new file mode 100644
index 00000000000..ce766671763
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/ca.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Port s\u00e8rie",
+ "source_1": "Nom de la font #1",
+ "source_2": "Nom de la font #2",
+ "source_3": "Nom de la font #3",
+ "source_4": "Nom de la font #4",
+ "source_5": "Nom de la font #5",
+ "source_6": "Nom de la font #6"
+ },
+ "title": "Connexi\u00f3 amb el dispositiu"
+ }
+ },
+ "title": "Amplificador Monoprice de 6 zones"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Nom de la font #1",
+ "source_2": "Nom de la font #2",
+ "source_3": "Nom de la font #3",
+ "source_4": "Nom de la font #4",
+ "source_5": "Nom de la font #5",
+ "source_6": "Nom de la font #6"
+ },
+ "title": "Configuraci\u00f3 de les fonts"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/de.json b/homeassistant/components/monoprice/.translations/de.json
new file mode 100644
index 00000000000..ea2b8cdc6c4
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/de.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Serielle Schnittstelle",
+ "source_1": "Name der Quelle #1",
+ "source_2": "Name der Quelle #2",
+ "source_3": "Name der Quelle #3",
+ "source_4": "Name der Quelle #4",
+ "source_5": "Name der Quelle #5",
+ "source_6": "Name der Quelle #6"
+ },
+ "title": "Stellen Sie eine Verbindung zum Ger\u00e4t her"
+ }
+ },
+ "title": "Monoprice 6-Zonen-Verst\u00e4rker"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Name der Quelle #1",
+ "source_2": "Name der Quelle #2",
+ "source_3": "Name der Quelle #3",
+ "source_4": "Name der Quelle #4",
+ "source_5": "Name der Quelle #5",
+ "source_6": "Name der Quelle #6"
+ },
+ "title": "Quellen konfigurieren"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/en.json b/homeassistant/components/monoprice/.translations/en.json
new file mode 100644
index 00000000000..4ff655856f9
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/en.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Serial port",
+ "source_1": "Name of source #1",
+ "source_2": "Name of source #2",
+ "source_3": "Name of source #3",
+ "source_4": "Name of source #4",
+ "source_5": "Name of source #5",
+ "source_6": "Name of source #6"
+ },
+ "title": "Connect to the device"
+ }
+ },
+ "title": "Monoprice 6-Zone Amplifier"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Name of source #1",
+ "source_2": "Name of source #2",
+ "source_3": "Name of source #3",
+ "source_4": "Name of source #4",
+ "source_5": "Name of source #5",
+ "source_6": "Name of source #6"
+ },
+ "title": "Configure sources"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/es.json b/homeassistant/components/monoprice/.translations/es.json
new file mode 100644
index 00000000000..31a72fc0b9f
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/es.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar, por favor int\u00e9ntalo de nuevo",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Puerto serie",
+ "source_1": "Nombre de la fuente #1",
+ "source_2": "Nombre de la fuente #2",
+ "source_3": "Nombre de la fuente #3",
+ "source_4": "Nombre de la fuente #4",
+ "source_5": "Nombre de la fuente #5",
+ "source_6": "Nombre de la fuente #6"
+ },
+ "title": "Conectarse al dispositivo"
+ }
+ },
+ "title": "Amplificador Monoprice de 6 zonas"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Nombre de la fuente #1",
+ "source_2": "Nombre de la fuente #2",
+ "source_3": "Nombre de la fuente #3",
+ "source_4": "Nombre de la fuente #4",
+ "source_5": "Nombre de la fuente #5",
+ "source_6": "Nombre de la fuente #6"
+ },
+ "title": "Configurar fuentes"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/fr.json b/homeassistant/components/monoprice/.translations/fr.json
new file mode 100644
index 00000000000..f93fb82d444
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/fr.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Port s\u00e9rie",
+ "source_1": "Nom de la source #1",
+ "source_2": "Nom de la source #2",
+ "source_3": "Nom de la source #3",
+ "source_4": "Nom de la source #4",
+ "source_5": "Nom de la source #5",
+ "source_6": "Nom de la source #6"
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Nom de la source #1",
+ "source_2": "Nom de la source #2",
+ "source_3": "Nom de la source #3",
+ "source_4": "Nom de la source #4",
+ "source_5": "Nom de la source #5",
+ "source_6": "Nom de la source #6"
+ },
+ "title": "Configurer les sources"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/it.json b/homeassistant/components/monoprice/.translations/it.json
new file mode 100644
index 00000000000..c3c8770d2ad
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/it.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Porta seriale",
+ "source_1": "Nome della fonte n. 1",
+ "source_2": "Nome della fonte n. 2",
+ "source_3": "Nome della fonte n. 3",
+ "source_4": "Nome della fonte n. 4",
+ "source_5": "Nome della fonte n. 5",
+ "source_6": "Nome della fonte n. 6"
+ },
+ "title": "Connettersi al dispositivo"
+ }
+ },
+ "title": "Amplificatore a 6 zone Monoprice"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Nome della fonte n. 1",
+ "source_2": "Nome della fonte n. 2",
+ "source_3": "Nome della fonte n. 3",
+ "source_4": "Nome della fonte n. 4",
+ "source_5": "Nome della fonte n. 5",
+ "source_6": "Nome della fonte n. 6"
+ },
+ "title": "Configurare le sorgenti"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/ko.json b/homeassistant/components/monoprice/.translations/ko.json
new file mode 100644
index 00000000000..dd5b44bf035
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/ko.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \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": {
+ "port": "\uc2dc\ub9ac\uc5bc \ud3ec\ud2b8",
+ "source_1": "\uc785\ub825 \uc18c\uc2a4 1 \uc774\ub984",
+ "source_2": "\uc785\ub825 \uc18c\uc2a4 2 \uc774\ub984",
+ "source_3": "\uc785\ub825 \uc18c\uc2a4 3 \uc774\ub984",
+ "source_4": "\uc785\ub825 \uc18c\uc2a4 4 \uc774\ub984",
+ "source_5": "\uc785\ub825 \uc18c\uc2a4 5 \uc774\ub984",
+ "source_6": "\uc785\ub825 \uc18c\uc2a4 6 \uc774\ub984"
+ },
+ "title": "\uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "Monoprice 6-Zone \uc570\ud504"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "\uc785\ub825 \uc18c\uc2a4 1 \uc774\ub984",
+ "source_2": "\uc785\ub825 \uc18c\uc2a4 2 \uc774\ub984",
+ "source_3": "\uc785\ub825 \uc18c\uc2a4 3 \uc774\ub984",
+ "source_4": "\uc785\ub825 \uc18c\uc2a4 4 \uc774\ub984",
+ "source_5": "\uc785\ub825 \uc18c\uc2a4 5 \uc774\ub984",
+ "source_6": "\uc785\ub825 \uc18c\uc2a4 6 \uc774\ub984"
+ },
+ "title": "\uc785\ub825 \uc18c\uc2a4 \uad6c\uc131"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/lb.json b/homeassistant/components/monoprice/.translations/lb.json
new file mode 100644
index 00000000000..abb7caf4183
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/lb.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Serielle Port",
+ "source_1": "Numm vun der Quell #1",
+ "source_2": "Numm vun der Quell #2",
+ "source_3": "Numm vun der Quell #3",
+ "source_4": "Numm vun der Quell #4",
+ "source_5": "Numm vun der Quell #5",
+ "source_6": "Numm vun der Quell #6"
+ },
+ "title": "Mam Apparat verbannen"
+ }
+ },
+ "title": "Monoprice 6-Zone Verst\u00e4rker"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Numm vun der Quell #1",
+ "source_2": "Numm vun der Quell #2",
+ "source_3": "Numm vun der Quell #3",
+ "source_4": "Numm vun der Quell #4",
+ "source_5": "Numm vun der Quell #5",
+ "source_6": "Numm vun der Quell #6"
+ },
+ "title": "Quelle konfigur\u00e9ieren"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/no.json b/homeassistant/components/monoprice/.translations/no.json
new file mode 100644
index 00000000000..f17e48c2a78
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/no.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "Serial port",
+ "source_1": "Navn p\u00e5 kilden #1",
+ "source_2": "Navn p\u00e5 kilden #2",
+ "source_3": "Navn p\u00e5 kilden #3",
+ "source_4": "Navn p\u00e5 kilde #4",
+ "source_5": "Navn p\u00e5 kilde #5",
+ "source_6": "Navn p\u00e5 kilde #6"
+ },
+ "title": "Koble til enheten"
+ }
+ },
+ "title": "Monoprice 6-Zone Forsterker"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "Navn p\u00e5 kilde #1",
+ "source_2": "Navn p\u00e5 kilde #2",
+ "source_3": "Navn p\u00e5 kilde #3",
+ "source_4": "Navn p\u00e5 kilde #4",
+ "source_5": "Navn p\u00e5 kilde #5",
+ "source_6": "Navn p\u00e5 kilde #6"
+ },
+ "title": "Konfigurer kilder"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/ru.json b/homeassistant/components/monoprice/.translations/ru.json
new file mode 100644
index 00000000000..25fa4ef7e64
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/ru.json
@@ -0,0 +1,41 @@
+{
+ "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\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "\u041f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442",
+ "source_1": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #1",
+ "source_2": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #2",
+ "source_3": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #3",
+ "source_4": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #4",
+ "source_5": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #5",
+ "source_6": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #6"
+ },
+ "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443"
+ }
+ },
+ "title": "Monoprice 6-Zone Amplifier"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #1",
+ "source_2": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #2",
+ "source_3": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #3",
+ "source_4": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #4",
+ "source_5": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #5",
+ "source_6": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430 #6"
+ },
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/.translations/zh-Hant.json b/homeassistant/components/monoprice/.translations/zh-Hant.json
new file mode 100644
index 00000000000..81230eee728
--- /dev/null
+++ b/homeassistant/components/monoprice/.translations/zh-Hant.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "port": "\u5e8f\u5217\u57e0",
+ "source_1": "\u4f86\u6e90 #1 \u540d\u7a31",
+ "source_2": "\u4f86\u6e90 #2 \u540d\u7a31",
+ "source_3": "\u4f86\u6e90 #3 \u540d\u7a31",
+ "source_4": "\u4f86\u6e90 #4 \u540d\u7a31",
+ "source_5": "\u4f86\u6e90 #5 \u540d\u7a31",
+ "source_6": "\u4f86\u6e90 #6 \u540d\u7a31"
+ },
+ "title": "\u9023\u7dda\u81f3\u8a2d\u5099"
+ }
+ },
+ "title": "Monoprice 6-Zone \u653e\u5927\u5668"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "source_1": "\u4f86\u6e90 #1 \u540d\u7a31",
+ "source_2": "\u4f86\u6e90 #2 \u540d\u7a31",
+ "source_3": "\u4f86\u6e90 #3 \u540d\u7a31",
+ "source_4": "\u4f86\u6e90 #4 \u540d\u7a31",
+ "source_5": "\u4f86\u6e90 #5 \u540d\u7a31",
+ "source_6": "\u4f86\u6e90 #6 \u540d\u7a31"
+ },
+ "title": "\u8a2d\u5b9a\u4f86\u6e90"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py
index d968e270390..37593f6828e 100644
--- a/homeassistant/components/monoprice/__init__.py
+++ b/homeassistant/components/monoprice/__init__.py
@@ -1 +1,62 @@
-"""The monoprice component."""
+"""The Monoprice 6-Zone Amplifier integration."""
+import asyncio
+import logging
+
+from pymonoprice import get_monoprice
+from serial import SerialException
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_PORT
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+from .const import DOMAIN
+
+PLATFORMS = ["media_player"]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Monoprice 6-Zone Amplifier component."""
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Monoprice 6-Zone Amplifier from a config entry."""
+ port = entry.data[CONF_PORT]
+
+ try:
+ monoprice = await hass.async_add_executor_job(get_monoprice, port)
+ hass.data.setdefault(DOMAIN, {})[entry.entry_id] = monoprice
+ except SerialException:
+ _LOGGER.error("Error connecting to Monoprice controller at %s", port)
+ raise ConfigEntryNotReady
+
+ entry.add_update_listener(_update_listener)
+
+ 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
+ ]
+ )
+ )
+
+ return unload_ok
+
+
+async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py
new file mode 100644
index 00000000000..cbabc65a54b
--- /dev/null
+++ b/homeassistant/components/monoprice/config_flow.py
@@ -0,0 +1,143 @@
+"""Config flow for Monoprice 6-Zone Amplifier integration."""
+import logging
+
+from pymonoprice import get_async_monoprice
+from serial import SerialException
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PORT
+
+from .const import (
+ CONF_SOURCE_1,
+ CONF_SOURCE_2,
+ CONF_SOURCE_3,
+ CONF_SOURCE_4,
+ CONF_SOURCE_5,
+ CONF_SOURCE_6,
+ CONF_SOURCES,
+)
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+SOURCES = [
+ CONF_SOURCE_1,
+ CONF_SOURCE_2,
+ CONF_SOURCE_3,
+ CONF_SOURCE_4,
+ CONF_SOURCE_5,
+ CONF_SOURCE_6,
+]
+
+OPTIONS_FOR_DATA = {vol.Optional(source): str for source in SOURCES}
+
+DATA_SCHEMA = vol.Schema({vol.Required(CONF_PORT): str, **OPTIONS_FOR_DATA})
+
+
+@core.callback
+def _sources_from_config(data):
+ sources_config = {
+ str(idx + 1): data.get(source) for idx, source in enumerate(SOURCES)
+ }
+
+ return {
+ index: name.strip()
+ for index, name in sources_config.items()
+ if (name is not None and name.strip() != "")
+ }
+
+
+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.
+ """
+ try:
+ await get_async_monoprice(data[CONF_PORT], hass.loop)
+ except SerialException:
+ _LOGGER.error("Error connecting to Monoprice controller")
+ raise CannotConnect
+
+ sources = _sources_from_config(data)
+
+ # Return info that you want to store in the config entry.
+ return {CONF_PORT: data[CONF_PORT], CONF_SOURCES: sources}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Monoprice 6-Zone Amplifier."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+
+ return self.async_create_entry(title=user_input[CONF_PORT], data=info)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ @staticmethod
+ @core.callback
+ def async_get_options_flow(config_entry):
+ """Define the config flow to handle options."""
+ return MonopriceOptionsFlowHandler(config_entry)
+
+
+@core.callback
+def _key_for_source(index, source, previous_sources):
+ if str(index) in previous_sources:
+ key = vol.Optional(source, default=previous_sources[str(index)])
+ else:
+ key = vol.Optional(source)
+
+ return key
+
+
+class MonopriceOptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a Monoprice options flow."""
+
+ def __init__(self, config_entry):
+ """Initialize."""
+ self.config_entry = config_entry
+
+ @core.callback
+ def _previous_sources(self):
+ if CONF_SOURCES in self.config_entry.options:
+ previous = self.config_entry.options[CONF_SOURCES]
+ else:
+ previous = self.config_entry.data[CONF_SOURCES]
+
+ return previous
+
+ async def async_step_init(self, user_input=None):
+ """Manage the options."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title="", data={CONF_SOURCES: _sources_from_config(user_input)}
+ )
+
+ previous_sources = self._previous_sources()
+
+ options = {
+ _key_for_source(idx + 1, source, previous_sources): str
+ for idx, source in enumerate(SOURCES)
+ }
+
+ return self.async_show_form(step_id="init", data_schema=vol.Schema(options),)
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
diff --git a/homeassistant/components/monoprice/const.py b/homeassistant/components/monoprice/const.py
index e8d813d2529..ea4667a77ff 100644
--- a/homeassistant/components/monoprice/const.py
+++ b/homeassistant/components/monoprice/const.py
@@ -1,5 +1,15 @@
"""Constants for the Monoprice 6-Zone Amplifier Media Player component."""
DOMAIN = "monoprice"
+
+CONF_SOURCES = "sources"
+
+CONF_SOURCE_1 = "source_1"
+CONF_SOURCE_2 = "source_2"
+CONF_SOURCE_3 = "source_3"
+CONF_SOURCE_4 = "source_4"
+CONF_SOURCE_5 = "source_5"
+CONF_SOURCE_6 = "source_6"
+
SERVICE_SNAPSHOT = "snapshot"
SERVICE_RESTORE = "restore"
diff --git a/homeassistant/components/monoprice/manifest.json b/homeassistant/components/monoprice/manifest.json
index d071276bcec..d9497c1c29c 100644
--- a/homeassistant/components/monoprice/manifest.json
+++ b/homeassistant/components/monoprice/manifest.json
@@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/monoprice",
"requirements": ["pymonoprice==0.3"],
"dependencies": [],
- "codeowners": ["@etsinko"]
+ "codeowners": ["@etsinko"],
+ "config_flow": true
}
diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py
index 20b2ecebcf4..d85c219691e 100644
--- a/homeassistant/components/monoprice/media_player.py
+++ b/homeassistant/components/monoprice/media_player.py
@@ -1,11 +1,8 @@
"""Support for interfacing with Monoprice 6 zone home audio controller."""
import logging
-from pymonoprice import get_monoprice
-from serial import SerialException
-import voluptuous as vol
-
-from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
+from homeassistant import core
+from homeassistant.components.media_player import MediaPlayerDevice
from homeassistant.components.media_player.const import (
SUPPORT_SELECT_SOURCE,
SUPPORT_TURN_OFF,
@@ -14,16 +11,10 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_SET,
SUPPORT_VOLUME_STEP,
)
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- CONF_NAME,
- CONF_PORT,
- STATE_OFF,
- STATE_ON,
-)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_PORT, STATE_OFF, STATE_ON
+from homeassistant.helpers import config_validation as cv, entity_platform, service
-from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT
+from .const import CONF_SOURCES, DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT
_LOGGER = logging.getLogger(__name__)
@@ -36,104 +27,97 @@ SUPPORT_MONOPRICE = (
| SUPPORT_SELECT_SOURCE
)
-ZONE_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
-SOURCE_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
+@core.callback
+def _get_sources_from_dict(data):
+ sources_config = data[CONF_SOURCES]
-CONF_ZONES = "zones"
-CONF_SOURCES = "sources"
+ source_id_name = {int(index): name for index, name in sources_config.items()}
-DATA_MONOPRICE = "monoprice"
+ source_name_id = {v: k for k, v in source_id_name.items()}
-# Valid zone ids: 11-16 or 21-26 or 31-36
-ZONE_IDS = vol.All(
- vol.Coerce(int),
- vol.Any(
- vol.Range(min=11, max=16), vol.Range(min=21, max=26), vol.Range(min=31, max=36)
- ),
-)
+ source_names = sorted(source_name_id.keys(), key=lambda v: source_name_id[v])
-# Valid source ids: 1-6
-SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=6))
-
-MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_PORT): cv.string,
- vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}),
- vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}),
- }
-)
+ return [source_id_name, source_name_id, source_names]
-def setup_platform(hass, config, add_entities, discovery_info=None):
+@core.callback
+def _get_sources(config_entry):
+ if CONF_SOURCES in config_entry.options:
+ data = config_entry.options
+ else:
+ data = config_entry.data
+ return _get_sources_from_dict(data)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Monoprice 6-zone amplifier platform."""
- port = config.get(CONF_PORT)
+ port = config_entry.data[CONF_PORT]
- try:
- monoprice = get_monoprice(port)
- except SerialException:
- _LOGGER.error("Error connecting to Monoprice controller")
- return
+ monoprice = hass.data[DOMAIN][config_entry.entry_id]
- sources = {
- source_id: extra[CONF_NAME] for source_id, extra in config[CONF_SOURCES].items()
- }
+ sources = _get_sources(config_entry)
- hass.data[DATA_MONOPRICE] = []
- for zone_id, extra in config[CONF_ZONES].items():
- _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME])
- hass.data[DATA_MONOPRICE].append(
- MonopriceZone(monoprice, sources, zone_id, extra[CONF_NAME])
- )
+ entities = []
+ for i in range(1, 4):
+ for j in range(1, 7):
+ zone_id = (i * 10) + j
+ _LOGGER.info("Adding zone %d for port %s", zone_id, port)
+ entities.append(
+ MonopriceZone(monoprice, sources, config_entry.entry_id, zone_id)
+ )
- add_entities(hass.data[DATA_MONOPRICE], True)
+ async_add_entities(entities, True)
- def service_handle(service):
+ platform = entity_platform.current_platform.get()
+
+ def _call_service(entities, service_call):
+ for entity in entities:
+ if service_call.service == SERVICE_SNAPSHOT:
+ entity.snapshot()
+ elif service_call.service == SERVICE_RESTORE:
+ entity.restore()
+
+ @service.verify_domain_control(hass, DOMAIN)
+ async def async_service_handle(service_call):
"""Handle for services."""
- entity_ids = service.data.get(ATTR_ENTITY_ID)
+ entities = await platform.async_extract_from_service(service_call)
- if entity_ids:
- devices = [
- device
- for device in hass.data[DATA_MONOPRICE]
- if device.entity_id in entity_ids
- ]
- else:
- devices = hass.data[DATA_MONOPRICE]
+ if not entities:
+ return
- for device in devices:
- if service.service == SERVICE_SNAPSHOT:
- device.snapshot()
- elif service.service == SERVICE_RESTORE:
- device.restore()
+ hass.async_add_executor_job(_call_service, entities, service_call)
- hass.services.register(
- DOMAIN, SERVICE_SNAPSHOT, service_handle, schema=MEDIA_PLAYER_SCHEMA
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SNAPSHOT,
+ async_service_handle,
+ schema=cv.make_entity_service_schema({}),
)
- hass.services.register(
- DOMAIN, SERVICE_RESTORE, service_handle, schema=MEDIA_PLAYER_SCHEMA
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_RESTORE,
+ async_service_handle,
+ schema=cv.make_entity_service_schema({}),
)
class MonopriceZone(MediaPlayerDevice):
"""Representation of a Monoprice amplifier zone."""
- def __init__(self, monoprice, sources, zone_id, zone_name):
+ def __init__(self, monoprice, sources, namespace, zone_id):
"""Initialize new zone."""
self._monoprice = monoprice
# dict source_id -> source name
- self._source_id_name = sources
+ self._source_id_name = sources[0]
# dict source name -> source_id
- self._source_name_id = {v: k for k, v in sources.items()}
+ self._source_name_id = sources[1]
# ordered list of all source names
- self._source_names = sorted(
- self._source_name_id.keys(), key=lambda v: self._source_name_id[v]
- )
+ self._source_names = sources[2]
self._zone_id = zone_id
- self._name = zone_name
+ self._unique_id = f"{namespace}_{self._zone_id}"
+ self._name = f"Zone {self._zone_id}"
self._snapshot = None
self._state = None
@@ -156,6 +140,26 @@ class MonopriceZone(MediaPlayerDevice):
self._source = None
return True
+ @property
+ def entity_registry_enabled_default(self):
+ """Return if the entity should be enabled when first added to the entity registry."""
+ return self._zone_id < 20
+
+ @property
+ def device_info(self):
+ """Return device info for this device."""
+ return {
+ "identifiers": {(DOMAIN, self.unique_id)},
+ "name": self.name,
+ "manufacturer": "Monoprice",
+ "model": "6-Zone Amplifier",
+ }
+
+ @property
+ def unique_id(self):
+ """Return unique ID for this device."""
+ return self._unique_id
+
@property
def name(self):
"""Return the name of the zone."""
diff --git a/homeassistant/components/monoprice/strings.json b/homeassistant/components/monoprice/strings.json
new file mode 100644
index 00000000000..32332c7369a
--- /dev/null
+++ b/homeassistant/components/monoprice/strings.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "title": "Monoprice 6-Zone Amplifier",
+ "step": {
+ "user": {
+ "title": "Connect to the device",
+ "data": {
+ "port": "Serial port",
+ "source_1": "Name of source #1",
+ "source_2": "Name of source #2",
+ "source_3": "Name of source #3",
+ "source_4": "Name of source #4",
+ "source_5": "Name of source #5",
+ "source_6": "Name of source #6"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "Device is already configured"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Configure sources",
+ "data": {
+ "source_1": "Name of source #1",
+ "source_2": "Name of source #2",
+ "source_3": "Name of source #3",
+ "source_4": "Name of source #4",
+ "source_5": "Name of source #5",
+ "source_6": "Name of source #6"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mopar/__init__.py b/homeassistant/components/mopar/__init__.py
deleted file mode 100644
index 4801a7c43d6..00000000000
--- a/homeassistant/components/mopar/__init__.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""Support for Mopar vehicles."""
-from datetime import timedelta
-import logging
-
-import motorparts
-import voluptuous as vol
-
-from homeassistant.components.lock import DOMAIN as LOCK
-from homeassistant.components.sensor import DOMAIN as SENSOR
-from homeassistant.components.switch import DOMAIN as SWITCH
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_PIN,
- CONF_SCAN_INTERVAL,
- CONF_USERNAME,
-)
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.discovery import load_platform
-from homeassistant.helpers.dispatcher import dispatcher_send
-from homeassistant.helpers.event import track_time_interval
-
-DOMAIN = "mopar"
-DATA_UPDATED = f"{DOMAIN}_data_updated"
-
-_LOGGER = logging.getLogger(__name__)
-
-COOKIE_FILE = "mopar_cookies.pickle"
-SUCCESS_RESPONSE = "completed"
-
-SUPPORTED_PLATFORMS = [LOCK, SENSOR, SWITCH]
-
-DEFAULT_INTERVAL = timedelta(days=7)
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_PIN): cv.positive_int,
- vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All(
- cv.time_period, cv.positive_timedelta
- ),
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-SERVICE_HORN = "sound_horn"
-ATTR_VEHICLE_INDEX = "vehicle_index"
-SERVICE_HORN_SCHEMA = vol.Schema({vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int})
-
-
-def setup(hass, config):
- """Set up the Mopar component."""
- conf = config[DOMAIN]
- cookie = hass.config.path(COOKIE_FILE)
- try:
- session = motorparts.get_session(
- conf[CONF_USERNAME], conf[CONF_PASSWORD], conf[CONF_PIN], cookie_path=cookie
- )
- except motorparts.MoparError:
- _LOGGER.error("Failed to login")
- return False
-
- data = hass.data[DOMAIN] = MoparData(hass, session)
- data.update(now=None)
-
- track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL])
-
- def handle_horn(call):
- """Enable the horn on the Mopar vehicle."""
- data.actuate("horn", call.data[ATTR_VEHICLE_INDEX])
-
- hass.services.register(
- DOMAIN, SERVICE_HORN, handle_horn, schema=SERVICE_HORN_SCHEMA
- )
-
- for platform in SUPPORTED_PLATFORMS:
- load_platform(hass, platform, DOMAIN, {}, config)
-
- return True
-
-
-class MoparData:
- """
- Container for Mopar vehicle data.
-
- Prevents session expiry re-login race condition.
- """
-
- def __init__(self, hass, session):
- """Initialize data."""
- self._hass = hass
- self._session = session
- self.vehicles = []
- self.vhrs = {}
- self.tow_guides = {}
-
- def update(self, now, **kwargs):
- """Update data."""
- _LOGGER.debug("Updating vehicle data")
- try:
- self.vehicles = motorparts.get_summary(self._session)["vehicles"]
- except motorparts.MoparError:
- _LOGGER.exception("Failed to get summary")
- return
-
- for index, _ in enumerate(self.vehicles):
- try:
- self.vhrs[index] = motorparts.get_report(self._session, index)
- self.tow_guides[index] = motorparts.get_tow_guide(self._session, index)
- except motorparts.MoparError:
- _LOGGER.warning("Failed to update for vehicle index %s", index)
- return
-
- dispatcher_send(self._hass, DATA_UPDATED)
-
- @property
- def attribution(self):
- """Get the attribution string from Mopar."""
- return motorparts.ATTRIBUTION
-
- def get_vehicle_name(self, index):
- """Get the name corresponding with this vehicle."""
- vehicle = self.vehicles[index]
- if not vehicle:
- return None
- return f"{vehicle['year']} {vehicle['make']} {vehicle['model']}"
-
- def actuate(self, command, index):
- """Run a command on the specified Mopar vehicle."""
- try:
- response = getattr(motorparts, command)(self._session, index)
- except motorparts.MoparError as error:
- _LOGGER.error(error)
- return False
-
- return response == SUCCESS_RESPONSE
diff --git a/homeassistant/components/mopar/lock.py b/homeassistant/components/mopar/lock.py
deleted file mode 100644
index 3933e567723..00000000000
--- a/homeassistant/components/mopar/lock.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""Support for the Mopar vehicle lock."""
-import logging
-
-from homeassistant.components.lock import LockDevice
-from homeassistant.components.mopar import DOMAIN as MOPAR_DOMAIN
-from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Set up the Mopar lock platform."""
- data = hass.data[MOPAR_DOMAIN]
- add_entities(
- [MoparLock(data, index) for index, _ in enumerate(data.vehicles)], True
- )
-
-
-class MoparLock(LockDevice):
- """Representation of a Mopar vehicle lock."""
-
- def __init__(self, data, index):
- """Initialize the Mopar lock."""
- self._index = index
- self._name = f"{data.get_vehicle_name(self._index)} Lock"
- self._actuate = data.actuate
- self._state = None
-
- @property
- def name(self):
- """Return the name of the lock."""
- return self._name
-
- @property
- def is_locked(self):
- """Return true if vehicle is locked."""
- return self._state == STATE_LOCKED
-
- @property
- def should_poll(self):
- """Return the polling requirement for this lock."""
- return False
-
- def lock(self, **kwargs):
- """Lock the vehicle."""
- if self._actuate("lock", self._index):
- self._state = STATE_LOCKED
-
- def unlock(self, **kwargs):
- """Unlock the vehicle."""
- if self._actuate("unlock", self._index):
- self._state = STATE_UNLOCKED
diff --git a/homeassistant/components/mopar/manifest.json b/homeassistant/components/mopar/manifest.json
deleted file mode 100644
index e8fae4fb069..00000000000
--- a/homeassistant/components/mopar/manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "domain": "mopar",
- "name": "Mopar",
- "documentation": "https://www.home-assistant.io/integrations/mopar",
- "requirements": ["motorparts==1.1.0"],
- "dependencies": [],
- "codeowners": []
-}
diff --git a/homeassistant/components/mopar/sensor.py b/homeassistant/components/mopar/sensor.py
deleted file mode 100644
index 2243fcdaa22..00000000000
--- a/homeassistant/components/mopar/sensor.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Support for the Mopar vehicle sensor platform."""
-from homeassistant.components.mopar import (
- ATTR_VEHICLE_INDEX,
- DATA_UPDATED,
- DOMAIN as MOPAR_DOMAIN,
-)
-from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_KILOMETERS
-from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity import Entity
-
-ICON = "mdi:car"
-
-
-async def async_setup_platform(hass, config, add_entities, discovery_info=None):
- """Set up the Mopar platform."""
- data = hass.data[MOPAR_DOMAIN]
- add_entities(
- [MoparSensor(data, index) for index, _ in enumerate(data.vehicles)], True
- )
-
-
-class MoparSensor(Entity):
- """Mopar vehicle sensor."""
-
- def __init__(self, data, index):
- """Initialize the sensor."""
- self._index = index
- self._vehicle = {}
- self._vhr = {}
- self._tow_guide = {}
- self._odometer = None
- self._data = data
- self._name = self._data.get_vehicle_name(self._index)
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the sensor."""
- return self._odometer
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- attributes = {
- ATTR_VEHICLE_INDEX: self._index,
- ATTR_ATTRIBUTION: self._data.attribution,
- }
- attributes.update(self._vehicle)
- attributes.update(self._vhr)
- attributes.update(self._tow_guide)
- return attributes
-
- @property
- def unit_of_measurement(self):
- """Return the unit of measurement."""
- return self.hass.config.units.length_unit
-
- @property
- def icon(self):
- """Return the icon."""
- return ICON
-
- @property
- def should_poll(self):
- """Return the polling requirement for this sensor."""
- return False
-
- async def async_added_to_hass(self):
- """Handle entity which will be added."""
- async_dispatcher_connect(
- self.hass, DATA_UPDATED, self._schedule_immediate_update
- )
-
- def update(self):
- """Update device state."""
- self._vehicle = self._data.vehicles[self._index]
- self._vhr = self._data.vhrs.get(self._index, {})
- self._tow_guide = self._data.tow_guides.get(self._index, {})
- if "odometer" in self._vhr:
- odo = float(self._vhr["odometer"])
- self._odometer = int(self.hass.config.units.length(odo, LENGTH_KILOMETERS))
-
- @callback
- def _schedule_immediate_update(self):
- self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/mopar/services.yaml b/homeassistant/components/mopar/services.yaml
deleted file mode 100644
index 7915aefcb0f..00000000000
--- a/homeassistant/components/mopar/services.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-sound_horn:
- description: Trigger the vehicle's horn
- fields:
- vehicle_index:
- description: The index of the vehicle to trigger. This is exposed in the sensor's device attributes.
- example: 1
\ No newline at end of file
diff --git a/homeassistant/components/mopar/switch.py b/homeassistant/components/mopar/switch.py
deleted file mode 100644
index c7a8c762fbc..00000000000
--- a/homeassistant/components/mopar/switch.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""Support for the Mopar vehicle switch."""
-import logging
-
-from homeassistant.components.mopar import DOMAIN as MOPAR_DOMAIN
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import STATE_OFF, STATE_ON
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Set up the Mopar Switch platform."""
- data = hass.data[MOPAR_DOMAIN]
- add_entities(
- [MoparSwitch(data, index) for index, _ in enumerate(data.vehicles)], True
- )
-
-
-class MoparSwitch(SwitchDevice):
- """Representation of a Mopar switch."""
-
- def __init__(self, data, index):
- """Initialize the Switch."""
- self._index = index
- self._name = f"{data.get_vehicle_name(self._index)} Switch"
- self._actuate = data.actuate
- self._state = None
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return True if the entity is on."""
- return self._state == STATE_ON
-
- @property
- def should_poll(self):
- """Return the polling requirement for this switch."""
- return False
-
- def turn_on(self, **kwargs):
- """Turn on the Mopar Vehicle."""
- if self._actuate("engine_on", self._index):
- self._state = STATE_ON
-
- def turn_off(self, **kwargs):
- """Turn off the Mopar Vehicle."""
- if self._actuate("engine_off", self._index):
- self._state = STATE_OFF
diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py
index 4b6d63b4240..bec61b10a9f 100644
--- a/homeassistant/components/mpd/media_player.py
+++ b/homeassistant/components/mpd/media_player.py
@@ -249,7 +249,7 @@ class MpdDevice(MediaPlayerDevice):
def supported_features(self):
"""Flag media player features that are supported."""
if self._status is None:
- return None
+ return 0
supported = SUPPORT_MPD
if "volume" in self._status:
diff --git a/homeassistant/components/mqtt/.translations/fr.json b/homeassistant/components/mqtt/.translations/fr.json
index 648c2f972d7..eaf930b9a2d 100644
--- a/homeassistant/components/mqtt/.translations/fr.json
+++ b/homeassistant/components/mqtt/.translations/fr.json
@@ -27,5 +27,20 @@
}
},
"title": "MQTT"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Premier bouton",
+ "button_2": "Deuxi\u00e8me bouton",
+ "button_3": "Troisi\u00e8me bouton",
+ "button_4": "Quatri\u00e8me bouton",
+ "button_5": "Cinqui\u00e8me bouton",
+ "button_6": "Sixi\u00e8me bouton",
+ "turn_off": "\u00c9teindre",
+ "turn_on": "Allumer"
+ },
+ "trigger_type": {
+ "button_short_press": "\" {subtype} \" press\u00e9"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/hu.json b/homeassistant/components/mqtt/.translations/hu.json
index 26361b0e363..e45c287f44f 100644
--- a/homeassistant/components/mqtt/.translations/hu.json
+++ b/homeassistant/components/mqtt/.translations/hu.json
@@ -22,10 +22,22 @@
"data": {
"discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se"
},
- "description": "Szeretn\u00e9d, hogy a Home Assistant csatlakozzon a hass.io addon {addon} \u00e1ltal biztos\u00edtott MQTT br\u00f3kerhez?",
+ "description": "Be szeretn\u00e9d konfigru\u00e1lni, hogy a Home Assistant a(z) {addon} Hass.io add-on \u00e1ltal biztos\u00edtott MQTT br\u00f3kerhez csatlakozzon?",
"title": "MQTT Broker a Hass.io b\u0151v\u00edtm\u00e9nyen kereszt\u00fcl"
}
},
"title": "MQTT"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Els\u0151 gomb",
+ "button_2": "M\u00e1sodik gomb",
+ "button_3": "Harmadik gomb",
+ "button_4": "Negyedik gomb",
+ "button_5": "\u00d6t\u00f6dik gomb",
+ "button_6": "Hatodik gomb",
+ "turn_off": "Kikapcsol\u00e1s",
+ "turn_on": "Bekapcsol\u00e1s"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/no.json b/homeassistant/components/mqtt/.translations/no.json
index 27a77a25226..8416f74e086 100644
--- a/homeassistant/components/mqtt/.translations/no.json
+++ b/homeassistant/components/mqtt/.translations/no.json
@@ -12,7 +12,7 @@
"broker": "Megler",
"discovery": "Aktiver oppdagelse",
"password": "Passord",
- "port": "Port",
+ "port": "",
"username": "Brukernavn"
},
"description": "Vennligst oppgi tilkoblingsinformasjonen for din MQTT megler.",
@@ -22,7 +22,7 @@
"data": {
"discovery": "Aktiver oppdagelse"
},
- "description": "Vil du konfigurere Home Assistant til \u00e5 koble til MQTT broker som er levert av Hass.io-tillegget (addon)?",
+ "description": "Vil du konfigurere Home Assistant til \u00e5 koble til en MQTT megler som er levert av Hass.io-tillegget {addon}?",
"title": "MQTT megler via Hass.io tillegg"
}
},
diff --git a/homeassistant/components/mqtt/.translations/sl.json b/homeassistant/components/mqtt/.translations/sl.json
index 84553cc536a..86b72665b71 100644
--- a/homeassistant/components/mqtt/.translations/sl.json
+++ b/homeassistant/components/mqtt/.translations/sl.json
@@ -27,5 +27,27 @@
}
},
"title": "MQTT"
+ },
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Prvi gumb",
+ "button_2": "Drugi gumb",
+ "button_3": "Tretji gumb",
+ "button_4": "\u010cetrti gumb",
+ "button_5": "Peti gumb",
+ "button_6": "\u0160esti gumb",
+ "turn_off": "Ugasni",
+ "turn_on": "Pri\u017egi"
+ },
+ "trigger_type": {
+ "button_double_press": "\"{subtype}\" gumb dvakrat kliknjen",
+ "button_long_press": "\"{subtype}\" gumb neprekinjeno pritisnjen",
+ "button_long_release": "\"{subtype}\" gumb spro\u0161\u010den po dolgem pritisku",
+ "button_quadruple_press": "\"{subtype}\" gumb \u0161tirikrat kliknjen",
+ "button_quintuple_press": "\"{subtype}\" gumb petkrat kliknjen",
+ "button_short_press": "Pritisnjen \"{subtype}\" gumb",
+ "button_short_release": "Gumb \"{subtype}\" spro\u0161\u010den",
+ "button_triple_press": "Gumb \"{subtype}\" trikrat kliknjen"
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index fbbf4f42d7a..c51f94992f5 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -37,7 +37,6 @@ from homeassistant.exceptions import (
Unauthorized,
)
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
@@ -46,7 +45,8 @@ from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.logging import catch_log_exception
# Loading the config flow file will register the flow
-from . import config_flow, discovery, server # noqa: F401 pylint: disable=unused-import
+from . import config_flow # noqa: F401 pylint: disable=unused-import
+from . import debug_info, discovery, server
from .const import (
ATTR_DISCOVERY_HASH,
ATTR_DISCOVERY_TOPIC,
@@ -57,7 +57,8 @@ from .const import (
DEFAULT_QOS,
PROTOCOL_311,
)
-from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash
+from .debug_info import log_messages
+from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash, set_discovery_hash
from .models import Message, MessageCallbackType, PublishPayloadType
from .subscription import async_subscribe_topics, async_unsubscribe_topics
@@ -388,7 +389,7 @@ def wrap_msg_callback(msg_callback: MessageCallbackType) -> MessageCallbackType:
@wraps(msg_callback)
async def async_wrapper(msg: Any) -> None:
- """Catch and log exception."""
+ """Call with deprecated signature."""
await msg_callback(msg.topic, msg.payload, msg.qos)
wrapper_func = async_wrapper
@@ -396,7 +397,7 @@ def wrap_msg_callback(msg_callback: MessageCallbackType) -> MessageCallbackType:
@wraps(msg_callback)
def wrapper(msg: Any) -> None:
- """Catch and log exception."""
+ """Call with deprecated signature."""
msg_callback(msg.topic, msg.payload, msg.qos)
wrapper_func = wrapper
@@ -514,6 +515,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
websocket_api.async_register_command(hass, websocket_subscribe)
websocket_api.async_register_command(hass, websocket_remove_device)
+ websocket_api.async_register_command(hass, websocket_mqtt_info)
if conf is None:
# If we have a config entry, setup is done by that config entry.
@@ -807,7 +809,10 @@ class MQTT:
if will_message is not None:
self._mqttc.will_set( # pylint: disable=no-value-for-parameter
- *attr.astuple(will_message)
+ *attr.astuple(
+ will_message,
+ filter=lambda attr, value: attr.name != "subscribed_topic",
+ )
)
async def async_publish(
@@ -939,7 +944,10 @@ class MQTT:
if self.birth_message:
self.hass.add_job(
self.async_publish( # pylint: disable=no-value-for-parameter
- *attr.astuple(self.birth_message)
+ *attr.astuple(
+ self.birth_message,
+ filter=lambda attr, value: attr.name != "subscribed_topic",
+ )
)
)
@@ -975,7 +983,8 @@ class MQTT:
continue
self.hass.async_run_job(
- subscription.callback, Message(msg.topic, payload, msg.qos, msg.retain)
+ subscription.callback,
+ Message(msg.topic, payload, msg.qos, msg.retain, subscription.topic),
)
def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None:
@@ -1059,6 +1068,7 @@ class MqttAttributes(Entity):
attr_tpl.hass = self.hass
@callback
+ @log_messages(self.hass, self.entity_id)
def attributes_message_received(msg: Message) -> None:
try:
payload = msg.payload
@@ -1123,6 +1133,7 @@ class MqttAvailability(Entity):
"""(Re)Subscribe to topics."""
@callback
+ @log_messages(self.hass, self.entity_id)
def availability_message_received(msg: Message) -> None:
"""Handle a new received MQTT availability message."""
if msg.payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]:
@@ -1157,6 +1168,23 @@ class MqttAvailability(Entity):
return availability_topic is None or self._available
+async def cleanup_device_registry(hass, device_id):
+ """Remove device registry entry if there are no remaining entities or triggers."""
+ # Local import to avoid circular dependencies
+ from . import device_trigger
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ if (
+ device_id
+ and not hass.helpers.entity_registry.async_entries_for_device(
+ entity_registry, device_id
+ )
+ and not await device_trigger.async_get_triggers(hass, device_id)
+ ):
+ device_registry.async_remove_device(device_id)
+
+
class MqttDiscoveryUpdate(Entity):
"""Mixin used to handle updated discovery message."""
@@ -1165,32 +1193,55 @@ class MqttDiscoveryUpdate(Entity):
self._discovery_data = discovery_data
self._discovery_update = discovery_update
self._remove_signal = None
+ self._removed_from_hass = False
async def async_added_to_hass(self) -> None:
"""Subscribe to discovery updates."""
await super().async_added_to_hass()
+ self._removed_from_hass = False
discovery_hash = (
self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None
)
+ async def _async_remove_state_and_registry_entry(self) -> None:
+ """Remove entity's state and entity registry entry.
+
+ Remove entity from entity registry if it is registered, this also removes the state.
+ If the entity is not in the entity registry, just remove the state.
+ """
+ entity_registry = (
+ await self.hass.helpers.entity_registry.async_get_registry()
+ )
+ if entity_registry.async_is_registered(self.entity_id):
+ entity_entry = entity_registry.async_get(self.entity_id)
+ entity_registry.async_remove(self.entity_id)
+ await cleanup_device_registry(self.hass, entity_entry.device_id)
+ else:
+ await self.async_remove()
+
@callback
- def discovery_callback(payload):
+ async def discovery_callback(payload):
"""Handle discovery update."""
_LOGGER.info(
"Got update for entity with hash: %s '%s'", discovery_hash, payload,
)
+ debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id)
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, discovery_hash)
- self._remove_signal()
+ self._cleanup_discovery_on_remove()
+ await _async_remove_state_and_registry_entry(self)
elif self._discovery_update:
# Non-empty payload: Notify component
_LOGGER.info("Updating component: %s", self.entity_id)
- self.hass.async_create_task(self._discovery_update(payload))
+ await self._discovery_update(payload)
if discovery_hash:
+ debug_info.add_entity_discovery_data(
+ self.hass, self._discovery_data, self.entity_id
+ )
+ # Set in case the entity has been removed and is re-added
+ set_discovery_hash(self.hass, discovery_hash)
self._remove_signal = async_dispatcher_connect(
self.hass,
MQTT_DISCOVERY_UPDATED.format(discovery_hash),
@@ -1199,15 +1250,26 @@ class MqttDiscoveryUpdate(Entity):
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,
- )
+ if not self._removed_from_hass:
+ 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."""
+ """Stop listening to signal and cleanup discovery data.."""
+ self._cleanup_discovery_on_remove()
+
+ def _cleanup_discovery_on_remove(self) -> None:
+ """Stop listening to signal and cleanup discovery data."""
+ if self._discovery_data and not self._removed_from_hass:
+ debug_info.remove_entity_data(self.hass, self.entity_id)
+ clear_discovery_hash(self.hass, self._discovery_data[ATTR_DISCOVERY_HASH])
+ self._removed_from_hass = True
+
if self._remove_signal:
self._remove_signal()
+ self._remove_signal = None
def device_info_from_config(config):
@@ -1263,6 +1325,18 @@ class MqttEntityDeviceInfo(Entity):
return device_info_from_config(self._device_config)
+@websocket_api.websocket_command(
+ {vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str}
+)
+@websocket_api.async_response
+async def websocket_mqtt_info(hass, connection, msg):
+ """Get MQTT debug info for device."""
+ device_id = msg["device_id"]
+ mqtt_info = await debug_info.info_for_device(hass, device_id)
+
+ connection.send_result(msg["id"], mqtt_info)
+
+
@websocket_api.websocket_command(
{vol.Required("type"): "mqtt/device/remove", vol.Required("device_id"): str}
)
@@ -1270,7 +1344,7 @@ class MqttEntityDeviceInfo(Entity):
async def websocket_remove_device(hass, connection, msg):
"""Delete device."""
device_id = msg["device_id"]
- dev_registry = await get_dev_reg(hass)
+ dev_registry = await hass.helpers.device_registry.async_get_registry()
device = dev_registry.async_get(device_id)
if not device:
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
index 9bbb1503196..a75ae33f861 100644
--- a/homeassistant/components/mqtt/camera.py
+++ b/homeassistant/components/mqtt/camera.py
@@ -4,7 +4,7 @@ import logging
import voluptuous as vol
from homeassistant.components import camera, mqtt
-from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.components.camera import Camera
from homeassistant.const import CONF_DEVICE, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
@@ -13,7 +13,10 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from . import (
ATTR_DISCOVERY_HASH,
+ CONF_QOS,
CONF_UNIQUE_ID,
+ MqttAttributes,
+ MqttAvailability,
MqttDiscoveryUpdate,
MqttEntityDeviceInfo,
subscription,
@@ -25,13 +28,17 @@ _LOGGER = logging.getLogger(__name__)
CONF_TOPIC = "topic"
DEFAULT_NAME = "MQTT Camera"
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
- }
+PLATFORM_SCHEMA = (
+ mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend(
+ {
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ )
+ .extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema)
+ .extend(mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
)
@@ -69,7 +76,9 @@ async def _async_setup_entity(
async_add_entities([MqttCamera(config, config_entry, discovery_data)])
-class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
+class MqttCamera(
+ MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera
+):
"""representation of a MQTT camera."""
def __init__(self, config, config_entry, discovery_data):
@@ -78,12 +87,13 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
self._unique_id = config.get(CONF_UNIQUE_ID)
self._sub_state = None
- self._qos = 0
self._last_image = None
device_config = config.get(CONF_DEVICE)
Camera.__init__(self)
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
@@ -96,6 +106,8 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
"""Handle updated discovery message."""
config = PLATFORM_SCHEMA(discovery_payload)
self._config = config
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
await self.device_info_discovery_update(config)
await self._subscribe_topics()
self.async_write_ha_state()
@@ -115,7 +127,7 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
"state_topic": {
"topic": self._config[CONF_TOPIC],
"msg_callback": message_received,
- "qos": self._qos,
+ "qos": self._config[CONF_QOS],
"encoding": None,
}
},
@@ -126,6 +138,8 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
self._sub_state = 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 async_camera_image(self):
diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py
index 6044ec2af6e..5d1fe2e2505 100644
--- a/homeassistant/components/mqtt/const.py
+++ b/homeassistant/components/mqtt/const.py
@@ -4,6 +4,7 @@ CONF_DISCOVERY = "discovery"
DEFAULT_DISCOVERY = False
ATTR_DISCOVERY_HASH = "discovery_hash"
+ATTR_DISCOVERY_PAYLOAD = "discovery_payload"
ATTR_DISCOVERY_TOPIC = "discovery_topic"
CONF_STATE_TOPIC = "state_topic"
PROTOCOL_311 = "3.1.1"
diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py
new file mode 100644
index 00000000000..b51ee619a12
--- /dev/null
+++ b/homeassistant/components/mqtt/debug_info.py
@@ -0,0 +1,146 @@
+"""Helper to handle a set of topics to subscribe to."""
+from collections import deque
+from functools import wraps
+import logging
+from typing import Any
+
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC
+from .models import MessageCallbackType
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_MQTT_DEBUG_INFO = "mqtt_debug_info"
+STORED_MESSAGES = 10
+
+
+def log_messages(hass: HomeAssistantType, entity_id: str) -> MessageCallbackType:
+ """Wrap an MQTT message callback to support message logging."""
+
+ def _log_message(msg):
+ """Log message."""
+ debug_info = hass.data[DATA_MQTT_DEBUG_INFO]
+ messages = debug_info["entities"][entity_id]["topics"][msg.subscribed_topic]
+ messages.append(msg.payload)
+
+ def _decorator(msg_callback: MessageCallbackType):
+ @wraps(msg_callback)
+ def wrapper(msg: Any) -> None:
+ """Log message."""
+ _log_message(msg)
+ msg_callback(msg)
+
+ setattr(wrapper, "__entity_id", entity_id)
+ return wrapper
+
+ return _decorator
+
+
+def add_topic(hass, message_callback, topic):
+ """Prepare debug data for topic."""
+ entity_id = getattr(message_callback, "__entity_id", None)
+ if entity_id:
+ debug_info = hass.data.setdefault(
+ DATA_MQTT_DEBUG_INFO, {"entities": {}, "triggers": {}}
+ )
+ entity_info = debug_info["entities"].setdefault(
+ entity_id, {"topics": {}, "discovery_data": {}}
+ )
+ entity_info["topics"][topic] = deque([], STORED_MESSAGES)
+
+
+def remove_topic(hass, message_callback, topic):
+ """Remove debug data for topic."""
+ entity_id = getattr(message_callback, "__entity_id", None)
+ if entity_id and entity_id in hass.data[DATA_MQTT_DEBUG_INFO]["entities"]:
+ hass.data[DATA_MQTT_DEBUG_INFO]["entities"][entity_id]["topics"].pop(topic)
+
+
+def add_entity_discovery_data(hass, discovery_data, entity_id):
+ """Add discovery data."""
+ debug_info = hass.data.setdefault(
+ DATA_MQTT_DEBUG_INFO, {"entities": {}, "triggers": {}}
+ )
+ entity_info = debug_info["entities"].setdefault(
+ entity_id, {"topics": {}, "discovery_data": {}}
+ )
+ entity_info["discovery_data"] = discovery_data
+
+
+def update_entity_discovery_data(hass, discovery_payload, entity_id):
+ """Update discovery data."""
+ entity_info = hass.data[DATA_MQTT_DEBUG_INFO]["entities"][entity_id]
+ entity_info["discovery_data"][ATTR_DISCOVERY_PAYLOAD] = discovery_payload
+
+
+def remove_entity_data(hass, entity_id):
+ """Remove discovery data."""
+ hass.data[DATA_MQTT_DEBUG_INFO]["entities"].pop(entity_id)
+
+
+def add_trigger_discovery_data(hass, discovery_hash, discovery_data, device_id):
+ """Add discovery data."""
+ debug_info = hass.data.setdefault(
+ DATA_MQTT_DEBUG_INFO, {"entities": {}, "triggers": {}}
+ )
+ debug_info["triggers"][discovery_hash] = {
+ "device_id": device_id,
+ "discovery_data": discovery_data,
+ }
+
+
+def update_trigger_discovery_data(hass, discovery_hash, discovery_payload):
+ """Update discovery data."""
+ trigger_info = hass.data[DATA_MQTT_DEBUG_INFO]["triggers"][discovery_hash]
+ trigger_info["discovery_data"][ATTR_DISCOVERY_PAYLOAD] = discovery_payload
+
+
+def remove_trigger_discovery_data(hass, discovery_hash):
+ """Remove discovery data."""
+ hass.data[DATA_MQTT_DEBUG_INFO]["triggers"][discovery_hash]["discovery_data"] = None
+
+
+async def info_for_device(hass, device_id):
+ """Get debug info for a device."""
+ mqtt_info = {"entities": [], "triggers": []}
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entries = hass.helpers.entity_registry.async_entries_for_device(
+ entity_registry, device_id
+ )
+ mqtt_debug_info = hass.data.setdefault(
+ DATA_MQTT_DEBUG_INFO, {"entities": {}, "triggers": {}}
+ )
+ for entry in entries:
+ if entry.entity_id not in mqtt_debug_info["entities"]:
+ continue
+
+ entity_info = mqtt_debug_info["entities"][entry.entity_id]
+ topics = [
+ {"topic": topic, "messages": list(messages)}
+ for topic, messages in entity_info["topics"].items()
+ ]
+ discovery_data = {
+ "topic": entity_info["discovery_data"].get(ATTR_DISCOVERY_TOPIC, ""),
+ "payload": entity_info["discovery_data"].get(ATTR_DISCOVERY_PAYLOAD, ""),
+ }
+ mqtt_info["entities"].append(
+ {
+ "entity_id": entry.entity_id,
+ "topics": topics,
+ "discovery_data": discovery_data,
+ }
+ )
+
+ for trigger in mqtt_debug_info["triggers"].values():
+ if trigger["device_id"] != device_id:
+ continue
+
+ discovery_data = {
+ "topic": trigger["discovery_data"][ATTR_DISCOVERY_TOPIC],
+ "payload": trigger["discovery_data"][ATTR_DISCOVERY_PAYLOAD],
+ }
+ mqtt_info["triggers"].append({"discovery_data": discovery_data})
+
+ return mqtt_info
diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py
index 5bb5ccbd9d4..3b65243d078 100644
--- a/homeassistant/components/mqtt/device_trigger.py
+++ b/homeassistant/components/mqtt/device_trigger.py
@@ -25,6 +25,8 @@ from . import (
CONF_PAYLOAD,
CONF_QOS,
DOMAIN,
+ cleanup_device_registry,
+ debug_info,
)
from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash
@@ -182,14 +184,17 @@ async def async_setup_trigger(hass, config, config_entry, discovery_data):
if not payload:
# Empty payload: Remove trigger
_LOGGER.info("Removing trigger: %s", discovery_hash)
+ debug_info.remove_trigger_discovery_data(hass, 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()
+ await cleanup_device_registry(hass, device.id)
else:
# Non-empty payload: Update trigger
_LOGGER.info("Updating trigger: %s", discovery_hash)
+ debug_info.update_trigger_discovery_data(hass, discovery_hash, payload)
config = TRIGGER_DISCOVERY_SCHEMA(payload)
await _update_device(hass, config_entry, config)
device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id]
@@ -228,6 +233,9 @@ async def async_setup_trigger(hass, config, config_entry, discovery_data):
await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger(
config, discovery_hash, remove_signal
)
+ debug_info.add_trigger_discovery_data(
+ hass, discovery_hash, discovery_data, device.id
+ )
async def async_device_removed(hass: HomeAssistant, device_id: str):
@@ -239,6 +247,7 @@ async def async_device_removed(hass: HomeAssistant, device_id: str):
discovery_hash = device_trigger.discovery_data[ATTR_DISCOVERY_HASH]
discovery_topic = device_trigger.discovery_data[ATTR_DISCOVERY_TOPIC]
+ debug_info.remove_trigger_discovery_data(hass, discovery_hash)
device_trigger.detach_trigger()
clear_discovery_hash(hass, discovery_hash)
device_trigger.remove_signal()
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
index e6350179571..689b279c5e7 100644
--- a/homeassistant/components/mqtt/discovery.py
+++ b/homeassistant/components/mqtt/discovery.py
@@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import HomeAssistantType
from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS
-from .const import ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_TOPIC, CONF_STATE_TOPIC
+from .const import ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC
_LOGGER = logging.getLogger(__name__)
@@ -50,15 +50,6 @@ CONFIG_ENTRY_COMPONENTS = [
"vacuum",
]
-DEPRECATED_PLATFORM_TO_SCHEMA = {
- "light": {"mqtt_json": "json", "mqtt_template": "template"}
-}
-
-# These components require state_topic to be set.
-# If not specified, infer state_topic from discovery topic.
-IMPLICIT_STATE_TOPIC_COMPONENTS = ["alarm_control_panel", "binary_sensor", "sensor"]
-
-
ALREADY_DISCOVERED = "mqtt_discovered_components"
DATA_CONFIG_ENTRY_LOCK = "mqtt_config_entry_lock"
CONFIG_ENTRY_IS_SETUP = "mqtt_config_entry_is_setup"
@@ -73,6 +64,11 @@ def clear_discovery_hash(hass, discovery_hash):
del hass.data[ALREADY_DISCOVERED][discovery_hash]
+def set_discovery_hash(hass, discovery_hash):
+ """Clear entry in ALREADY_DISCOVERED list."""
+ hass.data[ALREADY_DISCOVERED][discovery_hash] = {}
+
+
class MQTTConfig(dict):
"""Dummy class to allow adding attributes."""
@@ -139,43 +135,13 @@ async def async_start(
setattr(payload, "__configuration_source__", f"MQTT (topic: '{topic}')")
discovery_data = {
ATTR_DISCOVERY_HASH: discovery_hash,
+ ATTR_DISCOVERY_PAYLOAD: payload,
ATTR_DISCOVERY_TOPIC: topic,
}
setattr(payload, "discovery_data", discovery_data)
- if CONF_PLATFORM in payload and "schema" not in payload:
- platform = payload[CONF_PLATFORM]
- if (
- component in DEPRECATED_PLATFORM_TO_SCHEMA
- and platform in DEPRECATED_PLATFORM_TO_SCHEMA[component]
- ):
- schema = DEPRECATED_PLATFORM_TO_SCHEMA[component][platform]
- payload["schema"] = schema
- _LOGGER.warning(
- '"platform": "%s" is deprecated, ' 'replace with "schema":"%s"',
- platform,
- schema,
- )
payload[CONF_PLATFORM] = "mqtt"
- if (
- CONF_STATE_TOPIC not in payload
- and component in IMPLICIT_STATE_TOPIC_COMPONENTS
- ):
- # state_topic not specified, infer from discovery topic
- fmt_node_id = f"{node_id}/" if node_id else ""
- payload[
- CONF_STATE_TOPIC
- ] = f"{discovery_topic}/{component}/{fmt_node_id}{object_id}/state"
- _LOGGER.warning(
- 'implicit %s is deprecated, add "%s":"%s" to '
- "%s discovery message",
- CONF_STATE_TOPIC,
- CONF_STATE_TOPIC,
- payload[CONF_STATE_TOPIC],
- topic,
- )
-
if ALREADY_DISCOVERED not in hass.data:
hass.data[ALREADY_DISCOVERED] = {}
if discovery_hash in hass.data[ALREADY_DISCOVERED]:
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
index 60ecf80fb63..0af5aaf2c76 100644
--- a/homeassistant/components/mqtt/light/schema_json.py
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -284,7 +284,7 @@ class MqttLightJson(
)
except KeyError:
pass
- except ValueError:
+ except (TypeError, ValueError):
_LOGGER.warning("Invalid brightness value received")
if self._color_temp is not None:
@@ -300,8 +300,6 @@ class MqttLightJson(
self._effect = values["effect"]
except KeyError:
pass
- except ValueError:
- _LOGGER.warning("Invalid effect value received")
if self._white_value is not None:
try:
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
index 853e7f4411f..cd3e704f624 100644
--- a/homeassistant/components/mqtt/light/schema_template.py
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -434,6 +434,9 @@ class MqttTemplate(
if ATTR_EFFECT in kwargs:
values["effect"] = kwargs.get(ATTR_EFFECT)
+ if self._optimistic:
+ self._effect = kwargs[ATTR_EFFECT]
+
if ATTR_FLASH in kwargs:
values["flash"] = kwargs.get(ATTR_FLASH)
diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py
index cfdecd3383d..3a4add57298 100644
--- a/homeassistant/components/mqtt/models.py
+++ b/homeassistant/components/mqtt/models.py
@@ -14,6 +14,7 @@ class Message:
payload = attr.ib(type=PublishPayloadType)
qos = attr.ib(type=int)
retain = attr.ib(type=bool)
+ subscribed_topic = attr.ib(type=str, default=None)
MessageCallbackType = Callable[[Message], None]
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 07910697d21..2704c5ae3a1 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -1,6 +1,5 @@
"""Support for MQTT sensors."""
from datetime import timedelta
-import json
import logging
from typing import Optional
@@ -36,12 +35,12 @@ from . import (
MqttEntityDeviceInfo,
subscription,
)
+from .debug_info import log_messages
from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
_LOGGER = logging.getLogger(__name__)
CONF_EXPIRE_AFTER = "expire_after"
-CONF_JSON_ATTRS = "json_attributes"
DEFAULT_NAME = "MQTT Sensor"
DEFAULT_FORCE_UPDATE = False
@@ -53,7 +52,6 @@ PLATFORM_SCHEMA = (
vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
vol.Optional(CONF_ICON): cv.icon,
- vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
@@ -110,16 +108,9 @@ class MqttSensor(
self._state = None
self._sub_state = None
self._expiration_trigger = None
- self._attributes = None
device_config = config.get(CONF_DEVICE)
- if config.get(CONF_JSON_ATTRS):
- _LOGGER.warning(
- 'configuration variable "json_attributes" is '
- 'deprecated, replace with "json_attributes_topic"'
- )
-
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
@@ -147,6 +138,7 @@ class MqttSensor(
template.hass = self.hass
@callback
+ @log_messages(self.hass, self.entity_id)
def message_received(msg):
"""Handle new MQTT messages."""
payload = msg.payload
@@ -165,22 +157,6 @@ class MqttSensor(
self.hass, self.value_is_expired, expiration_at
)
- json_attributes = set(self._config[CONF_JSON_ATTRS])
- if json_attributes:
- self._attributes = {}
- try:
- json_dict = json.loads(payload)
- if isinstance(json_dict, dict):
- attrs = {
- k: json_dict[k] for k in json_attributes & json_dict.keys()
- }
- self._attributes = attrs
- else:
- _LOGGER.warning("JSON result was not a dictionary")
- except ValueError:
- _LOGGER.warning("MQTT payload could not be parsed as JSON")
- _LOGGER.debug("Erroneous JSON: %s", payload)
-
if template is not None:
payload = template.async_render_with_possible_json_value(
payload, self._state
@@ -241,11 +217,6 @@ class MqttSensor(
"""Return the state of the entity."""
return self._state
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return self._attributes
-
@property
def unique_id(self):
"""Return a unique ID."""
diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py
index be48a769a23..b4793a49dca 100644
--- a/homeassistant/components/mqtt/subscription.py
+++ b/homeassistant/components/mqtt/subscription.py
@@ -8,6 +8,7 @@ from homeassistant.components import mqtt
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.loader import bind_hass
+from . import debug_info
from .const import DEFAULT_QOS
from .models import MessageCallbackType
@@ -18,6 +19,7 @@ _LOGGER = logging.getLogger(__name__)
class EntitySubscription:
"""Class to hold data about an active entity topic subscription."""
+ hass = attr.ib(type=HomeAssistantType)
topic = attr.ib(type=str)
message_callback = attr.ib(type=MessageCallbackType)
unsubscribe_callback = attr.ib(type=Optional[Callable[[], None]])
@@ -31,11 +33,16 @@ class EntitySubscription:
if other is not None and other.unsubscribe_callback is not None:
other.unsubscribe_callback()
+ # Clear debug data if it exists
+ debug_info.remove_topic(self.hass, other.message_callback, other.topic)
if self.topic is None:
# We were asked to remove the subscription or not to create it
return
+ # Prepare debug data
+ debug_info.add_topic(self.hass, self.message_callback, self.topic)
+
self.unsubscribe_callback = await mqtt.async_subscribe(
hass, self.topic, self.message_callback, self.qos, self.encoding
)
@@ -77,6 +84,7 @@ async def async_subscribe_topics(
unsubscribe_callback=None,
qos=value.get("qos", DEFAULT_QOS),
encoding=value.get("encoding", "utf-8"),
+ hass=hass,
)
# Get the current subscription state
current = current_subscriptions.pop(key, None)
@@ -87,6 +95,8 @@ async def async_subscribe_topics(
for remaining in current_subscriptions.values():
if remaining.unsubscribe_callback is not None:
remaining.unsubscribe_callback()
+ # Clear debug data if it exists
+ debug_info.remove_topic(hass, remaining.message_callback, remaining.topic)
return new_state
diff --git a/homeassistant/components/myq/.translations/ca.json b/homeassistant/components/myq/.translations/ca.json
new file mode 100644
index 00000000000..ea8fd9c10de
--- /dev/null
+++ b/homeassistant/components/myq/.translations/ca.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ ja est\u00e0 configurat"
+ },
+ "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",
+ "username": "Nom d'usuari"
+ },
+ "title": "Connexi\u00f3 amb la passarel\u00b7la de MyQ"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/da.json b/homeassistant/components/myq/.translations/da.json
new file mode 100644
index 00000000000..3e66091d851
--- /dev/null
+++ b/homeassistant/components/myq/.translations/da.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/de.json b/homeassistant/components/myq/.translations/de.json
new file mode 100644
index 00000000000..a345c05311c
--- /dev/null
+++ b/homeassistant/components/myq/.translations/de.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "invalid_auth": "Ung\u00fcltige Authentifizierung",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwort",
+ "username": "Benutzername"
+ },
+ "title": "Stellen Sie eine Verbindung zum MyQ Gateway her"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/en.json b/homeassistant/components/myq/.translations/en.json
new file mode 100644
index 00000000000..c367873cbc9
--- /dev/null
+++ b/homeassistant/components/myq/.translations/en.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Username"
+ },
+ "title": "Connect to the MyQ Gateway"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/es.json b/homeassistant/components/myq/.translations/es.json
new file mode 100644
index 00000000000..41db9de34a6
--- /dev/null
+++ b/homeassistant/components/myq/.translations/es.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ },
+ "title": "Conectar con el Gateway "
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/fr.json b/homeassistant/components/myq/.translations/fr.json
new file mode 100644
index 00000000000..eacf5fc445a
--- /dev/null
+++ b/homeassistant/components/myq/.translations/fr.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification invalide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ },
+ "title": "Connectez-vous \u00e0 la passerelle MyQ"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/it.json b/homeassistant/components/myq/.translations/it.json
new file mode 100644
index 00000000000..4f495e670f1
--- /dev/null
+++ b/homeassistant/components/myq/.translations/it.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ },
+ "title": "Connettersi al gateway MyQ"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/ko.json b/homeassistant/components/myq/.translations/ko.json
new file mode 100644
index 00000000000..db4ecc9ee4f
--- /dev/null
+++ b/homeassistant/components/myq/.translations/ko.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "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"
+ },
+ "title": "MyQ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/lb.json b/homeassistant/components/myq/.translations/lb.json
new file mode 100644
index 00000000000..8556f60016f
--- /dev/null
+++ b/homeassistant/components/myq/.translations/lb.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwuert",
+ "username": "Benotzernumm"
+ },
+ "title": "Mat NuHeat Router verbannen"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/no.json b/homeassistant/components/myq/.translations/no.json
new file mode 100644
index 00000000000..4d3ed384d75
--- /dev/null
+++ b/homeassistant/components/myq/.translations/no.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ },
+ "title": "Koble til MyQ Gateway"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/ru.json b/homeassistant/components/myq/.translations/ru.json
new file mode 100644
index 00000000000..ec89856496c
--- /dev/null
+++ b/homeassistant/components/myq/.translations/ru.json
@@ -0,0 +1,22 @@
+{
+ "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, \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",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "title": "MyQ"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/.translations/zh-Hant.json b/homeassistant/components/myq/.translations/zh-Hant.json
new file mode 100644
index 00000000000..b7560ed40ce
--- /dev/null
+++ b/homeassistant/components/myq/.translations/zh-Hant.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "MyQ \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "title": "\u9023\u7dda\u81f3 MyQ \u8def\u7531\u5668"
+ }
+ },
+ "title": "MyQ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py
index e9fa7900d90..fc1d374fe43 100644
--- a/homeassistant/components/myq/__init__.py
+++ b/homeassistant/components/myq/__init__.py
@@ -1 +1,74 @@
-"""The myq component."""
+"""The MyQ integration."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import pymyq
+from pymyq.errors import InvalidCredentialsError, MyQError
+
+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.helpers import aiohttp_client
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, MYQ_COORDINATOR, MYQ_GATEWAY, PLATFORMS, UPDATE_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the MyQ component."""
+
+ hass.data.setdefault(DOMAIN, {})
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up MyQ from a config entry."""
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+ conf = entry.data
+
+ try:
+ myq = await pymyq.login(conf[CONF_USERNAME], conf[CONF_PASSWORD], websession)
+ except InvalidCredentialsError as err:
+ _LOGGER.error("There was an error while logging in: %s", err)
+ return False
+ except MyQError:
+ raise ConfigEntryNotReady
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name="myq devices",
+ update_method=myq.update_device_info,
+ update_interval=timedelta(seconds=UPDATE_INTERVAL),
+ )
+
+ hass.data[DOMAIN][entry.entry_id] = {MYQ_GATEWAY: myq, MYQ_COORDINATOR: coordinator}
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py
new file mode 100644
index 00000000000..7ce303e5d19
--- /dev/null
+++ b/homeassistant/components/myq/binary_sensor.py
@@ -0,0 +1,108 @@
+"""Support for MyQ gateways."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_CONNECTIVITY,
+ BinarySensorDevice,
+)
+from homeassistant.core import callback
+
+from .const import (
+ DOMAIN,
+ KNOWN_MODELS,
+ MANUFACTURER,
+ MYQ_COORDINATOR,
+ MYQ_DEVICE_FAMILY,
+ MYQ_DEVICE_FAMILY_GATEWAY,
+ MYQ_DEVICE_STATE,
+ MYQ_DEVICE_STATE_ONLINE,
+ MYQ_GATEWAY,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up mysq covers."""
+ data = hass.data[DOMAIN][config_entry.entry_id]
+ myq = data[MYQ_GATEWAY]
+ coordinator = data[MYQ_COORDINATOR]
+
+ entities = []
+
+ for device in myq.devices.values():
+ if device.device_json[MYQ_DEVICE_FAMILY] == MYQ_DEVICE_FAMILY_GATEWAY:
+ entities.append(MyQBinarySensorDevice(coordinator, device))
+
+ async_add_entities(entities, True)
+
+
+class MyQBinarySensorDevice(BinarySensorDevice):
+ """Representation of a MyQ gateway."""
+
+ def __init__(self, coordinator, device):
+ """Initialize with API object, device id."""
+ self._coordinator = coordinator
+ self._device = device
+
+ @property
+ def device_class(self):
+ """We track connectivity for gateways."""
+ return DEVICE_CLASS_CONNECTIVITY
+
+ @property
+ def name(self):
+ """Return the name of the garage door if any."""
+ return f"{self._device.name} MyQ Gateway"
+
+ @property
+ def is_on(self):
+ """Return if the device is online."""
+ if not self._coordinator.last_update_success:
+ return False
+
+ # Not all devices report online so assume True if its missing
+ return self._device.device_json[MYQ_DEVICE_STATE].get(
+ MYQ_DEVICE_STATE_ONLINE, True
+ )
+
+ @property
+ def unique_id(self):
+ """Return a unique, Home Assistant friendly identifier for this entity."""
+ return self._device.device_id
+
+ async def async_update(self):
+ """Update status of cover."""
+ await self._coordinator.async_request_refresh()
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ device_info = {
+ "identifiers": {(DOMAIN, self._device.device_id)},
+ "name": self.name,
+ "manufacturer": MANUFACTURER,
+ "sw_version": self._device.firmware_version,
+ }
+ model = KNOWN_MODELS.get(self._device.device_id[2:4])
+ if model:
+ device_info["model"] = model
+
+ return device_info
+
+ @property
+ def should_poll(self):
+ """Return False, updates are controlled via coordinator."""
+ return False
+
+ @callback
+ def _async_consume_update(self):
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._coordinator.async_add_listener(self._async_consume_update)
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ self._coordinator.async_remove_listener(self._async_consume_update)
diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py
new file mode 100644
index 00000000000..07d57921e35
--- /dev/null
+++ b/homeassistant/components/myq/config_flow.py
@@ -0,0 +1,92 @@
+"""Config flow for MyQ integration."""
+import logging
+
+import pymyq
+from pymyq.errors import InvalidCredentialsError, MyQError
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import aiohttp_client
+
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
+)
+
+
+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.
+ """
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ try:
+ await pymyq.login(data[CONF_USERNAME], data[CONF_PASSWORD], websession)
+ except InvalidCredentialsError:
+ raise InvalidAuth
+ except MyQError:
+ raise CannotConnect
+
+ return {"title": data[CONF_USERNAME]}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for MyQ."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ 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
+ )
+
+ async def async_step_homekit(self, homekit_info):
+ """Handle HomeKit discovery."""
+ if self._async_current_entries():
+ # We can see myq on the network to tell them to configure
+ # it, but since the device will not give up the account it is
+ # bound to and there can be multiple myq gateways on a single
+ # account, we avoid showing the device as discovered once
+ # they already have one configured as they can always
+ # add a new one via "+"
+ return self.async_abort(reason="already_configured")
+ return await self.async_step_user()
+
+ 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)
+
+
+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/myq/const.py b/homeassistant/components/myq/const.py
new file mode 100644
index 00000000000..352c19ebd24
--- /dev/null
+++ b/homeassistant/components/myq/const.py
@@ -0,0 +1,78 @@
+"""The MyQ integration."""
+from pymyq.device import (
+ STATE_CLOSED as MYQ_STATE_CLOSED,
+ STATE_CLOSING as MYQ_STATE_CLOSING,
+ STATE_OPEN as MYQ_STATE_OPEN,
+ STATE_OPENING as MYQ_STATE_OPENING,
+)
+
+from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING
+
+DOMAIN = "myq"
+
+PLATFORMS = ["cover", "binary_sensor"]
+
+MYQ_DEVICE_TYPE = "device_type"
+MYQ_DEVICE_TYPE_GATE = "gate"
+
+MYQ_DEVICE_FAMILY = "device_family"
+MYQ_DEVICE_FAMILY_GATEWAY = "gateway"
+
+MYQ_DEVICE_STATE = "state"
+MYQ_DEVICE_STATE_ONLINE = "online"
+
+
+MYQ_TO_HASS = {
+ MYQ_STATE_CLOSED: STATE_CLOSED,
+ MYQ_STATE_CLOSING: STATE_CLOSING,
+ MYQ_STATE_OPEN: STATE_OPEN,
+ MYQ_STATE_OPENING: STATE_OPENING,
+}
+
+MYQ_GATEWAY = "myq_gateway"
+MYQ_COORDINATOR = "coordinator"
+
+# myq has some ratelimits in place
+# and 61 seemed to be work every time
+UPDATE_INTERVAL = 61
+
+# Estimated time it takes myq to start transition from one
+# state to the next.
+TRANSITION_START_DURATION = 7
+
+# Estimated time it takes myq to complete a transition
+# from one state to another
+TRANSITION_COMPLETE_DURATION = 37
+
+MANUFACTURER = "The Chamberlain Group Inc."
+
+KNOWN_MODELS = {
+ "00": "Chamberlain Ethernet Gateway",
+ "01": "LiftMaster Ethernet Gateway",
+ "02": "Craftsman Ethernet Gateway",
+ "03": "Chamberlain Wi-Fi hub",
+ "04": "LiftMaster Wi-Fi hub",
+ "05": "Craftsman Wi-Fi hub",
+ "08": "LiftMaster Wi-Fi GDO DC w/Battery Backup",
+ "09": "Chamberlain Wi-Fi GDO DC w/Battery Backup",
+ "10": "Craftsman Wi-Fi GDO DC 3/4HP",
+ "11": "MyQ Replacement Logic Board Wi-Fi GDO DC 3/4HP",
+ "12": "Chamberlain Wi-Fi GDO DC 1.25HP",
+ "13": "LiftMaster Wi-Fi GDO DC 1.25HP",
+ "14": "Craftsman Wi-Fi GDO DC 1.25HP",
+ "15": "MyQ Replacement Logic Board Wi-Fi GDO DC 1.25HP",
+ "0A": "Chamberlain Wi-Fi GDO or Gate Operator AC",
+ "0B": "LiftMaster Wi-Fi GDO or Gate Operator AC",
+ "0C": "Craftsman Wi-Fi GDO or Gate Operator AC",
+ "0D": "MyQ Replacement Logic Board Wi-Fi GDO or Gate Operator AC",
+ "0E": "Chamberlain Wi-Fi GDO DC 3/4HP",
+ "0F": "LiftMaster Wi-Fi GDO DC 3/4HP",
+ "20": "Chamberlain MyQ Home Bridge",
+ "21": "LiftMaster MyQ Home Bridge",
+ "23": "Chamberlain Smart Garage Hub",
+ "24": "LiftMaster Smart Garage Hub",
+ "27": "LiftMaster Wi-Fi Wall Mount opener",
+ "28": "LiftMaster Commercial Wi-Fi Wall Mount operator",
+ "80": "EU LiftMaster Ethernet Gateway",
+ "81": "EU Chamberlain Ethernet Gateway",
+}
diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py
index 3f0895d9931..57308a778a5 100644
--- a/homeassistant/components/myq/cover.py
+++ b/homeassistant/components/myq/cover.py
@@ -1,35 +1,47 @@
"""Support for MyQ-Enabled Garage Doors."""
import logging
+import time
-from pymyq import login
-from pymyq.errors import MyQError
import voluptuous as vol
from homeassistant.components.cover import (
+ DEVICE_CLASS_GARAGE,
+ DEVICE_CLASS_GATE,
PLATFORM_SCHEMA,
SUPPORT_CLOSE,
SUPPORT_OPEN,
CoverDevice,
)
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_PASSWORD,
CONF_TYPE,
CONF_USERNAME,
STATE_CLOSED,
STATE_CLOSING,
- STATE_OPEN,
STATE_OPENING,
)
-from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import async_call_later
+
+from .const import (
+ DOMAIN,
+ KNOWN_MODELS,
+ MANUFACTURER,
+ MYQ_COORDINATOR,
+ MYQ_DEVICE_STATE,
+ MYQ_DEVICE_STATE_ONLINE,
+ MYQ_DEVICE_TYPE,
+ MYQ_DEVICE_TYPE_GATE,
+ MYQ_GATEWAY,
+ MYQ_TO_HASS,
+ TRANSITION_COMPLETE_DURATION,
+ TRANSITION_START_DURATION,
+)
_LOGGER = logging.getLogger(__name__)
-MYQ_TO_HASS = {
- "closed": STATE_CLOSED,
- "closing": STATE_CLOSING,
- "open": STATE_OPEN,
- "opening": STATE_OPENING,
-}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@@ -38,43 +50,70 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
# This parameter is no longer used; keeping it to avoid a breaking change in
# a hotfix, but in a future main release, this should be removed:
vol.Optional(CONF_TYPE): cv.string,
- }
+ },
)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the platform."""
- websession = aiohttp_client.async_get_clientsession(hass)
- username = config[CONF_USERNAME]
- password = config[CONF_PASSWORD]
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_USERNAME: config[CONF_USERNAME],
+ CONF_PASSWORD: config[CONF_PASSWORD],
+ },
+ )
+ )
- try:
- myq = await login(username, password, websession)
- except MyQError as err:
- _LOGGER.error("There was an error while logging in: %s", err)
- return
- async_add_entities([MyQDevice(device) for device in myq.covers.values()], True)
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up mysq covers."""
+ data = hass.data[DOMAIN][config_entry.entry_id]
+ myq = data[MYQ_GATEWAY]
+ coordinator = data[MYQ_COORDINATOR]
+
+ async_add_entities(
+ [MyQDevice(coordinator, device) for device in myq.covers.values()], True
+ )
class MyQDevice(CoverDevice):
"""Representation of a MyQ cover."""
- def __init__(self, device):
+ def __init__(self, coordinator, device):
"""Initialize with API object, device id."""
+ self._coordinator = coordinator
self._device = device
+ self._last_action_timestamp = 0
+ self._scheduled_transition_update = None
@property
def device_class(self):
"""Define this cover as a garage door."""
- return "garage"
+ device_type = self._device.device_json.get(MYQ_DEVICE_TYPE)
+ if device_type is not None and device_type == MYQ_DEVICE_TYPE_GATE:
+ return DEVICE_CLASS_GATE
+ return DEVICE_CLASS_GARAGE
@property
def name(self):
"""Return the name of the garage door if any."""
return self._device.name
+ @property
+ def available(self):
+ """Return if the device is online."""
+ if not self._coordinator.last_update_success:
+ return False
+
+ # Not all devices report online so assume True if its missing
+ return self._device.device_json[MYQ_DEVICE_STATE].get(
+ MYQ_DEVICE_STATE_ONLINE, True
+ )
+
@property
def is_closed(self):
"""Return true if cover is closed, else False."""
@@ -102,12 +141,78 @@ class MyQDevice(CoverDevice):
async def async_close_cover(self, **kwargs):
"""Issue close command to cover."""
+ self._last_action_timestamp = time.time()
await self._device.close()
+ self._async_schedule_update_for_transition()
async def async_open_cover(self, **kwargs):
"""Issue open command to cover."""
+ self._last_action_timestamp = time.time()
await self._device.open()
+ self._async_schedule_update_for_transition()
+
+ @callback
+ def _async_schedule_update_for_transition(self):
+ self.async_write_ha_state()
+
+ # Cancel any previous updates
+ if self._scheduled_transition_update:
+ self._scheduled_transition_update()
+
+ # Schedule an update for when we expect the transition
+ # to be completed so the garage door or gate does not
+ # seem like its closing or opening for a long time
+ self._scheduled_transition_update = async_call_later(
+ self.hass,
+ TRANSITION_COMPLETE_DURATION,
+ self._async_complete_schedule_update,
+ )
+
+ async def _async_complete_schedule_update(self, _):
+ """Update status of the cover via coordinator."""
+ self._scheduled_transition_update = None
+ await self._coordinator.async_request_refresh()
async def async_update(self):
"""Update status of cover."""
- await self._device.update()
+ await self._coordinator.async_request_refresh()
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ device_info = {
+ "identifiers": {(DOMAIN, self._device.device_id)},
+ "name": self._device.name,
+ "manufacturer": MANUFACTURER,
+ "sw_version": self._device.firmware_version,
+ }
+ model = KNOWN_MODELS.get(self._device.device_id[2:4])
+ if model:
+ device_info["model"] = model
+ if self._device.parent_device_id:
+ device_info["via_device"] = (DOMAIN, self._device.parent_device_id)
+ return device_info
+
+ @callback
+ def _async_consume_update(self):
+ if time.time() - self._last_action_timestamp <= TRANSITION_START_DURATION:
+ # If we just started a transition we need
+ # to prevent a bouncy state
+ return
+
+ self.async_write_ha_state()
+
+ @property
+ def should_poll(self):
+ """Return False, updates are controlled via coordinator."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._coordinator.async_add_listener(self._async_consume_update)
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ self._coordinator.async_remove_listener(self._async_consume_update)
+ if self._scheduled_transition_update:
+ self._scheduled_transition_update()
diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json
index 7e00e025bd3..afee7d4d77f 100644
--- a/homeassistant/components/myq/manifest.json
+++ b/homeassistant/components/myq/manifest.json
@@ -2,7 +2,15 @@
"domain": "myq",
"name": "MyQ",
"documentation": "https://www.home-assistant.io/integrations/myq",
- "requirements": ["pymyq==2.0.1"],
+ "requirements": [
+ "pymyq==2.0.1"
+ ],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@bdraco"],
+ "config_flow": true,
+ "homekit": {
+ "models": [
+ "819LMB"
+ ]
+ }
}
diff --git a/homeassistant/components/myq/strings.json b/homeassistant/components/myq/strings.json
new file mode 100644
index 00000000000..2aa0eab328e
--- /dev/null
+++ b/homeassistant/components/myq/strings.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "title": "MyQ",
+ "step": {
+ "user": {
+ "title": "Connect to the MyQ Gateway",
+ "data": {
+ "username": "Username",
+ "password": "Password"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "MyQ is already configured"
+ }
+ }
+}
diff --git a/homeassistant/components/neato/.translations/no.json b/homeassistant/components/neato/.translations/no.json
index 084c4b50e45..dc17289c0e3 100644
--- a/homeassistant/components/neato/.translations/no.json
+++ b/homeassistant/components/neato/.translations/no.json
@@ -22,6 +22,6 @@
"title": "Neato kontoinformasjon"
}
},
- "title": "Neato"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/no.json b/homeassistant/components/nest/.translations/no.json
index 743c47e00c8..9f19d22d939 100644
--- a/homeassistant/components/nest/.translations/no.json
+++ b/homeassistant/components/nest/.translations/no.json
@@ -28,6 +28,6 @@
"title": "Koble til Nest konto"
}
},
- "title": "Nest"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/netatmo/.translations/no.json b/homeassistant/components/netatmo/.translations/no.json
index 68a91633642..98e5a7eb352 100644
--- a/homeassistant/components/netatmo/.translations/no.json
+++ b/homeassistant/components/netatmo/.translations/no.json
@@ -13,6 +13,6 @@
"title": "Velg autentiseringsmetode"
}
},
- "title": "Netatmo"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
index 1f1b7088b29..fe6526a16eb 100644
--- a/homeassistant/components/netatmo/climate.py
+++ b/homeassistant/components/netatmo/climate.py
@@ -56,6 +56,7 @@ STATE_NETATMO_MAX = "max"
STATE_NETATMO_AWAY = PRESET_AWAY
STATE_NETATMO_OFF = STATE_OFF
STATE_NETATMO_MANUAL = "manual"
+STATE_NETATMO_HOME = "home"
PRESET_MAP_NETATMO = {
PRESET_FROST_GUARD: STATE_NETATMO_HG,
@@ -173,8 +174,11 @@ class NetatmoThermostat(ClimateDevice):
self._support_flags = SUPPORT_FLAGS
self._hvac_mode = None
self._battery_level = None
+ self._connected = None
self.update_without_throttle = False
- self._module_type = self._data.room_status.get(room_id, {}).get("module_type")
+ self._module_type = self._data.room_status.get(room_id, {}).get(
+ "module_type", NA_VALVE
+ )
if self._module_type == NA_THERM:
self._operation_list.append(HVAC_MODE_OFF)
@@ -252,25 +256,20 @@ class NetatmoThermostat(ClimateDevice):
def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
- mode = None
-
if hvac_mode == HVAC_MODE_OFF:
- mode = STATE_NETATMO_OFF
+ self.turn_off()
elif hvac_mode == HVAC_MODE_AUTO:
- mode = PRESET_SCHEDULE
+ if self.hvac_mode == HVAC_MODE_OFF:
+ self.turn_on()
+ self.set_preset_mode(PRESET_SCHEDULE)
elif hvac_mode == HVAC_MODE_HEAT:
- mode = PRESET_BOOST
-
- self.set_preset_mode(mode)
+ self.set_preset_mode(PRESET_BOOST)
def set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if self.target_temperature == 0:
self._data.homestatus.setroomThermpoint(
- self._data.home_id,
- self._room_id,
- STATE_NETATMO_MANUAL,
- DEFAULT_MIN_TEMP,
+ self._data.home_id, self._room_id, STATE_NETATMO_HOME,
)
if (
@@ -283,7 +282,7 @@ class NetatmoThermostat(ClimateDevice):
STATE_NETATMO_MANUAL,
DEFAULT_MAX_TEMP,
)
- elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX, STATE_NETATMO_OFF]:
+ elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX]:
self._data.homestatus.setroomThermpoint(
self._data.home_id, self._room_id, PRESET_MAP_NETATMO[preset_mode]
)
@@ -293,6 +292,7 @@ class NetatmoThermostat(ClimateDevice):
)
else:
_LOGGER.error("Preset mode '%s' not available", preset_mode)
+
self.update_without_throttle = True
self.schedule_update_ha_state()
@@ -328,6 +328,35 @@ class NetatmoThermostat(ClimateDevice):
return attr
+ def turn_off(self):
+ """Turn the entity off."""
+ if self._module_type == NA_VALVE:
+ self._data.homestatus.setroomThermpoint(
+ self._data.home_id,
+ self._room_id,
+ STATE_NETATMO_MANUAL,
+ DEFAULT_MIN_TEMP,
+ )
+ elif self.hvac_mode != HVAC_MODE_OFF:
+ self._data.homestatus.setroomThermpoint(
+ self._data.home_id, self._room_id, STATE_NETATMO_OFF
+ )
+ self.update_without_throttle = True
+ self.schedule_update_ha_state()
+
+ def turn_on(self):
+ """Turn the entity on."""
+ self._data.homestatus.setroomThermpoint(
+ self._data.home_id, self._room_id, STATE_NETATMO_HOME
+ )
+ self.update_without_throttle = True
+ self.schedule_update_ha_state()
+
+ @property
+ def available(self) -> bool:
+ """If the device hasn't been able to connect, mark as unavailable."""
+ return bool(self._connected)
+
def update(self):
"""Get the latest data from NetAtmo API and updates the states."""
try:
@@ -355,12 +384,14 @@ class NetatmoThermostat(ClimateDevice):
self._battery_level = self._data.room_status[self._room_id].get(
"battery_level"
)
+ self._connected = True
except KeyError as err:
- _LOGGER.error(
+ _LOGGER.debug(
"The thermostat in room %s seems to be out of reach. (%s)",
self._room_name,
err,
)
+ self._connected = False
self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY]
diff --git a/homeassistant/components/nexia/.translations/ca.json b/homeassistant/components/nexia/.translations/ca.json
new file mode 100644
index 00000000000..005edb82b59
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/ca.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Aquest dispositiu nexia home ja est\u00e0 configurat"
+ },
+ "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",
+ "username": "Nom d'usuari"
+ },
+ "title": "Connexi\u00f3 amb mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/da.json b/homeassistant/components/nexia/.translations/da.json
new file mode 100644
index 00000000000..3e66091d851
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/da.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/de.json b/homeassistant/components/nexia/.translations/de.json
new file mode 100644
index 00000000000..bda92cc7fe3
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/de.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dieses Nexia Home ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "invalid_auth": "Ung\u00fcltige Authentifizierung",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwort",
+ "username": "Benutzername"
+ },
+ "title": "Stellen Sie eine Verbindung zu mynexia.com her"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/en.json b/homeassistant/components/nexia/.translations/en.json
new file mode 100644
index 00000000000..c6dcaed91f8
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/en.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This nexia home is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Username"
+ },
+ "title": "Connect to mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/es.json b/homeassistant/components/nexia/.translations/es.json
new file mode 100644
index 00000000000..836c6b514c2
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/es.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Este nexia home ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ },
+ "title": "Conectar con mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/fr.json b/homeassistant/components/nexia/.translations/fr.json
new file mode 100644
index 00000000000..653cc0b3f04
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/fr.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Cette maison Nexia est d\u00e9j\u00e0 configur\u00e9e"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification non valide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ },
+ "title": "Se connecter \u00e0 mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/it.json b/homeassistant/components/nexia/.translations/it.json
new file mode 100644
index 00000000000..5fdd9a6095e
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/it.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Questo Nexia Home \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ },
+ "title": "Connettersi a mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/ko.json b/homeassistant/components/nexia/.translations/ko.json
new file mode 100644
index 00000000000..daabbe77ea7
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/ko.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "nexia home \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "title": "mynexia.com \uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/lb.json b/homeassistant/components/nexia/.translations/lb.json
new file mode 100644
index 00000000000..ae80d218786
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/lb.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Den Nexia Home ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwuert",
+ "username": "Benotzernumm"
+ },
+ "title": "Mat mynexia.com verbannen"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/no.json b/homeassistant/components/nexia/.translations/no.json
new file mode 100644
index 00000000000..84dbcf5b503
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/no.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dette nexia hjem er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ },
+ "title": "Koble til mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/ru.json b/homeassistant/components/nexia/.translations/ru.json
new file mode 100644
index 00000000000..a294518a777
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/ru.json
@@ -0,0 +1,22 @@
+{
+ "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.",
+ "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"
+ },
+ "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/.translations/zh-Hant.json b/homeassistant/components/nexia/.translations/zh-Hant.json
new file mode 100644
index 00000000000..7a768c1ed21
--- /dev/null
+++ b/homeassistant/components/nexia/.translations/zh-Hant.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Nexia home \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "title": "\u9023\u7dda\u81f3 mynexia.com"
+ }
+ },
+ "title": "Nexia"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py
new file mode 100644
index 00000000000..5c317794c2a
--- /dev/null
+++ b/homeassistant/components/nexia/__init__.py
@@ -0,0 +1,123 @@
+"""Support for Nexia / Trane XL Thermostats."""
+import asyncio
+from datetime import timedelta
+from functools import partial
+import logging
+
+from nexia.home import NexiaHome
+from requests.exceptions import ConnectTimeout, HTTPError
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, NEXIA_DEVICE, PLATFORMS, UPDATE_COORDINATOR
+
+_LOGGER = logging.getLogger(__name__)
+
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ },
+ extra=vol.ALLOW_EXTRA,
+ ),
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+DEFAULT_UPDATE_RATE = 120
+
+
+async def async_setup(hass: HomeAssistant, config: dict) -> bool:
+ """Set up the nexia component from YAML."""
+
+ conf = config.get(DOMAIN)
+ hass.data.setdefault(DOMAIN, {})
+
+ if not conf:
+ return True
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
+ )
+ )
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Configure the base Nexia device for Home Assistant."""
+
+ conf = entry.data
+ username = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+
+ try:
+ nexia_home = await hass.async_add_executor_job(
+ partial(
+ NexiaHome,
+ username=username,
+ password=password,
+ device_name=hass.config.location_name,
+ )
+ )
+ except ConnectTimeout as ex:
+ _LOGGER.error("Unable to connect to Nexia service: %s", ex)
+ raise ConfigEntryNotReady
+ except HTTPError as http_ex:
+ if http_ex.response.status_code >= 400 and http_ex.response.status_code < 500:
+ _LOGGER.error(
+ "Access error from Nexia service, please check credentials: %s",
+ http_ex,
+ )
+ return False
+ _LOGGER.error("HTTP error from Nexia service: %s", http_ex)
+ raise ConfigEntryNotReady
+
+ async def _async_update_data():
+ """Fetch data from API endpoint."""
+ return await hass.async_add_job(nexia_home.update)
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name="Nexia update",
+ update_method=_async_update_data,
+ update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE),
+ )
+
+ hass.data[DOMAIN][entry.entry_id] = {
+ NEXIA_DEVICE: nexia_home,
+ UPDATE_COORDINATOR: coordinator,
+ }
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py
new file mode 100644
index 00000000000..5c33412c647
--- /dev/null
+++ b/homeassistant/components/nexia/binary_sensor.py
@@ -0,0 +1,54 @@
+"""Support for Nexia / Trane XL Thermostats."""
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from .const import DOMAIN, NEXIA_DEVICE, UPDATE_COORDINATOR
+from .entity import NexiaThermostatEntity
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up sensors for a Nexia device."""
+
+ nexia_data = hass.data[DOMAIN][config_entry.entry_id]
+ nexia_home = nexia_data[NEXIA_DEVICE]
+ coordinator = nexia_data[UPDATE_COORDINATOR]
+
+ entities = []
+ for thermostat_id in nexia_home.get_thermostat_ids():
+ thermostat = nexia_home.get_thermostat_by_id(thermostat_id)
+ entities.append(
+ NexiaBinarySensor(
+ coordinator, thermostat, "is_blower_active", "Blower Active"
+ )
+ )
+ if thermostat.has_emergency_heat():
+ entities.append(
+ NexiaBinarySensor(
+ coordinator,
+ thermostat,
+ "is_emergency_heat_active",
+ "Emergency Heat Active",
+ )
+ )
+
+ async_add_entities(entities, True)
+
+
+class NexiaBinarySensor(NexiaThermostatEntity, BinarySensorDevice):
+ """Provices Nexia BinarySensor support."""
+
+ def __init__(self, coordinator, thermostat, sensor_call, sensor_name):
+ """Initialize the nexia sensor."""
+ super().__init__(
+ coordinator,
+ thermostat,
+ name=f"{thermostat.get_name()} {sensor_name}",
+ unique_id=f"{thermostat.thermostat_id}_{sensor_call}",
+ )
+ self._call = sensor_call
+ self._state = None
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return getattr(self._thermostat, self._call)()
diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py
new file mode 100644
index 00000000000..8af1be20b1e
--- /dev/null
+++ b/homeassistant/components/nexia/climate.py
@@ -0,0 +1,468 @@
+"""Support for Nexia / Trane XL thermostats."""
+import logging
+
+from nexia.const import (
+ FAN_MODES,
+ OPERATION_MODE_AUTO,
+ OPERATION_MODE_COOL,
+ OPERATION_MODE_HEAT,
+ OPERATION_MODE_OFF,
+ SYSTEM_STATUS_COOL,
+ SYSTEM_STATUS_HEAT,
+ SYSTEM_STATUS_IDLE,
+ UNIT_FAHRENHEIT,
+)
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_HUMIDITY,
+ ATTR_MAX_HUMIDITY,
+ ATTR_MIN_HUMIDITY,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW,
+ CURRENT_HVAC_COOL,
+ CURRENT_HVAC_HEAT,
+ CURRENT_HVAC_IDLE,
+ CURRENT_HVAC_OFF,
+ HVAC_MODE_AUTO,
+ HVAC_MODE_COOL,
+ HVAC_MODE_HEAT,
+ HVAC_MODE_HEAT_COOL,
+ HVAC_MODE_OFF,
+ SUPPORT_AUX_HEAT,
+ SUPPORT_FAN_MODE,
+ SUPPORT_PRESET_MODE,
+ SUPPORT_TARGET_HUMIDITY,
+ SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_RANGE,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_TEMPERATURE,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+)
+from homeassistant.helpers import entity_platform
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import dispatcher_send
+
+from .const import (
+ ATTR_AIRCLEANER_MODE,
+ ATTR_DEHUMIDIFY_SETPOINT,
+ ATTR_DEHUMIDIFY_SUPPORTED,
+ ATTR_HUMIDIFY_SETPOINT,
+ ATTR_HUMIDIFY_SUPPORTED,
+ ATTR_ZONE_STATUS,
+ DOMAIN,
+ NEXIA_DEVICE,
+ SIGNAL_THERMOSTAT_UPDATE,
+ SIGNAL_ZONE_UPDATE,
+ UPDATE_COORDINATOR,
+)
+from .entity import NexiaThermostatZoneEntity
+from .util import percent_conv
+
+SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode"
+SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint"
+
+SET_AIRCLEANER_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_AIRCLEANER_MODE): cv.string,
+ }
+)
+
+SET_HUMIDITY_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_HUMIDITY): vol.All(
+ vol.Coerce(int), vol.Range(min=35, max=65)
+ ),
+ }
+)
+
+
+_LOGGER = logging.getLogger(__name__)
+
+#
+# Nexia has two bits to determine hvac mode
+# There are actually eight states so we map to
+# the most significant state
+#
+# 1. Zone Mode : Auto / Cooling / Heating / Off
+# 2. Run Mode : Hold / Run Schedule
+#
+#
+HA_TO_NEXIA_HVAC_MODE_MAP = {
+ HVAC_MODE_HEAT: OPERATION_MODE_HEAT,
+ HVAC_MODE_COOL: OPERATION_MODE_COOL,
+ HVAC_MODE_HEAT_COOL: OPERATION_MODE_AUTO,
+ HVAC_MODE_AUTO: OPERATION_MODE_AUTO,
+ HVAC_MODE_OFF: OPERATION_MODE_OFF,
+}
+NEXIA_TO_HA_HVAC_MODE_MAP = {
+ value: key for key, value in HA_TO_NEXIA_HVAC_MODE_MAP.items()
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up climate for a Nexia device."""
+
+ nexia_data = hass.data[DOMAIN][config_entry.entry_id]
+ nexia_home = nexia_data[NEXIA_DEVICE]
+ coordinator = nexia_data[UPDATE_COORDINATOR]
+
+ platform = entity_platform.current_platform.get()
+
+ platform.async_register_entity_service(
+ SERVICE_SET_HUMIDIFY_SETPOINT,
+ SET_HUMIDITY_SCHEMA,
+ SERVICE_SET_HUMIDIFY_SETPOINT,
+ )
+ platform.async_register_entity_service(
+ SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, SERVICE_SET_AIRCLEANER_MODE,
+ )
+
+ entities = []
+ for thermostat_id in nexia_home.get_thermostat_ids():
+ thermostat = nexia_home.get_thermostat_by_id(thermostat_id)
+ for zone_id in thermostat.get_zone_ids():
+ zone = thermostat.get_zone_by_id(zone_id)
+ entities.append(NexiaZone(coordinator, zone))
+
+ async_add_entities(entities, True)
+
+
+class NexiaZone(NexiaThermostatZoneEntity, ClimateDevice):
+ """Provides Nexia Climate support."""
+
+ def __init__(self, coordinator, zone):
+ """Initialize the thermostat."""
+ super().__init__(
+ coordinator, zone, name=zone.get_name(), unique_id=zone.zone_id
+ )
+ self._undo_humidfy_dispatcher = None
+ self._undo_aircleaner_dispatcher = None
+ # The has_* calls are stable for the life of the device
+ # and do not do I/O
+ self._has_relative_humidity = self._thermostat.has_relative_humidity()
+ self._has_emergency_heat = self._thermostat.has_emergency_heat()
+ self._has_humidify_support = self._thermostat.has_humidify_support()
+ self._has_dehumidify_support = self._thermostat.has_dehumidify_support()
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ supported = (
+ SUPPORT_TARGET_TEMPERATURE_RANGE
+ | SUPPORT_TARGET_TEMPERATURE
+ | SUPPORT_FAN_MODE
+ | SUPPORT_PRESET_MODE
+ )
+
+ if self._has_humidify_support or self._has_dehumidify_support:
+ supported |= SUPPORT_TARGET_HUMIDITY
+
+ if self._has_emergency_heat:
+ supported |= SUPPORT_AUX_HEAT
+
+ return supported
+
+ @property
+ def is_fan_on(self):
+ """Blower is on."""
+ return self._thermostat.is_blower_active()
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS if self._thermostat.get_unit() == "C" else TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._zone.get_temperature()
+
+ @property
+ def fan_mode(self):
+ """Return the fan setting."""
+ return self._thermostat.get_fan_mode()
+
+ @property
+ def fan_modes(self):
+ """Return the list of available fan modes."""
+ return FAN_MODES
+
+ @property
+ def min_temp(self):
+ """Minimum temp for the current setting."""
+ return (self._thermostat.get_setpoint_limits())[0]
+
+ @property
+ def max_temp(self):
+ """Maximum temp for the current setting."""
+ return (self._thermostat.get_setpoint_limits())[1]
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ self._thermostat.set_fan_mode(fan_mode)
+ self._signal_thermostat_update()
+
+ @property
+ def preset_mode(self):
+ """Preset that is active."""
+ return self._zone.get_preset()
+
+ @property
+ def preset_modes(self):
+ """All presets."""
+ return self._zone.get_presets()
+
+ def set_humidity(self, humidity):
+ """Dehumidify target."""
+ self._thermostat.set_dehumidify_setpoint(humidity / 100.0)
+ self._signal_thermostat_update()
+
+ @property
+ def target_humidity(self):
+ """Humidity indoors setpoint."""
+ if self._has_dehumidify_support:
+ return percent_conv(self._thermostat.get_dehumidify_setpoint())
+ if self._has_humidify_support:
+ return percent_conv(self._thermostat.get_humidify_setpoint())
+ return None
+
+ @property
+ def current_humidity(self):
+ """Humidity indoors."""
+ if self._has_relative_humidity:
+ return percent_conv(self._thermostat.get_relative_humidity())
+ return None
+
+ @property
+ def target_temperature(self):
+ """Temperature we try to reach."""
+ current_mode = self._zone.get_current_mode()
+
+ if current_mode == OPERATION_MODE_COOL:
+ return self._zone.get_cooling_setpoint()
+ if current_mode == OPERATION_MODE_HEAT:
+ return self._zone.get_heating_setpoint()
+ return None
+
+ @property
+ def target_temperature_step(self):
+ """Step size of temperature units."""
+ if self._thermostat.get_unit() == UNIT_FAHRENHEIT:
+ return 1.0
+ return 0.5
+
+ @property
+ def target_temperature_high(self):
+ """Highest temperature we are trying to reach."""
+ current_mode = self._zone.get_current_mode()
+
+ if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
+ return None
+ return self._zone.get_cooling_setpoint()
+
+ @property
+ def target_temperature_low(self):
+ """Lowest temperature we are trying to reach."""
+ current_mode = self._zone.get_current_mode()
+
+ if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
+ return None
+ return self._zone.get_heating_setpoint()
+
+ @property
+ def hvac_action(self) -> str:
+ """Operation ie. heat, cool, idle."""
+ system_status = self._thermostat.get_system_status()
+ zone_called = self._zone.is_calling()
+
+ if self._zone.get_requested_mode() == OPERATION_MODE_OFF:
+ return CURRENT_HVAC_OFF
+ if not zone_called:
+ return CURRENT_HVAC_IDLE
+ if system_status == SYSTEM_STATUS_COOL:
+ return CURRENT_HVAC_COOL
+ if system_status == SYSTEM_STATUS_HEAT:
+ return CURRENT_HVAC_HEAT
+ if system_status == SYSTEM_STATUS_IDLE:
+ return CURRENT_HVAC_IDLE
+ return CURRENT_HVAC_IDLE
+
+ @property
+ def hvac_mode(self):
+ """Return current mode, as the user-visible name."""
+ mode = self._zone.get_requested_mode()
+ hold = self._zone.is_in_permanent_hold()
+
+ # If the device is in hold mode with
+ # OPERATION_MODE_AUTO
+ # overriding the schedule by still
+ # heating and cooling to the
+ # temp range.
+ if hold and mode == OPERATION_MODE_AUTO:
+ return HVAC_MODE_HEAT_COOL
+
+ return NEXIA_TO_HA_HVAC_MODE_MAP[mode]
+
+ @property
+ def hvac_modes(self):
+ """List of HVAC available modes."""
+ return [
+ HVAC_MODE_OFF,
+ HVAC_MODE_AUTO,
+ HVAC_MODE_HEAT_COOL,
+ HVAC_MODE_HEAT,
+ HVAC_MODE_COOL,
+ ]
+
+ def set_temperature(self, **kwargs):
+ """Set target temperature."""
+ new_heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW, None)
+ new_cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH, None)
+ set_temp = kwargs.get(ATTR_TEMPERATURE, None)
+
+ deadband = self._thermostat.get_deadband()
+ cur_cool_temp = self._zone.get_cooling_setpoint()
+ cur_heat_temp = self._zone.get_heating_setpoint()
+ (min_temp, max_temp) = self._thermostat.get_setpoint_limits()
+
+ # Check that we're not going to hit any minimum or maximum values
+ if new_heat_temp and new_heat_temp + deadband > max_temp:
+ new_heat_temp = max_temp - deadband
+ if new_cool_temp and new_cool_temp - deadband < min_temp:
+ new_cool_temp = min_temp + deadband
+
+ # Check that we're within the deadband range, fix it if we're not
+ if new_heat_temp and new_heat_temp != cur_heat_temp:
+ if new_cool_temp - new_heat_temp < deadband:
+ new_cool_temp = new_heat_temp + deadband
+ if new_cool_temp and new_cool_temp != cur_cool_temp:
+ if new_cool_temp - new_heat_temp < deadband:
+ new_heat_temp = new_cool_temp - deadband
+
+ self._zone.set_heat_cool_temp(
+ heat_temperature=new_heat_temp,
+ cool_temperature=new_cool_temp,
+ set_temperature=set_temp,
+ )
+ self._signal_zone_update()
+
+ @property
+ def is_aux_heat(self):
+ """Emergency heat state."""
+ return self._thermostat.is_emergency_heat_active()
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ data = super().device_state_attributes
+
+ data[ATTR_ZONE_STATUS] = self._zone.get_status()
+
+ if not self._has_relative_humidity:
+ return data
+
+ min_humidity = percent_conv(self._thermostat.get_humidity_setpoint_limits()[0])
+ max_humidity = percent_conv(self._thermostat.get_humidity_setpoint_limits()[1])
+ data.update(
+ {
+ ATTR_MIN_HUMIDITY: min_humidity,
+ ATTR_MAX_HUMIDITY: max_humidity,
+ ATTR_DEHUMIDIFY_SUPPORTED: self._has_dehumidify_support,
+ ATTR_HUMIDIFY_SUPPORTED: self._has_humidify_support,
+ }
+ )
+
+ if self._has_dehumidify_support:
+ dehumdify_setpoint = percent_conv(
+ self._thermostat.get_dehumidify_setpoint()
+ )
+ data[ATTR_DEHUMIDIFY_SETPOINT] = dehumdify_setpoint
+
+ if self._has_humidify_support:
+ humdify_setpoint = percent_conv(self._thermostat.get_humidify_setpoint())
+ data[ATTR_HUMIDIFY_SETPOINT] = humdify_setpoint
+
+ return data
+
+ def set_preset_mode(self, preset_mode: str):
+ """Set the preset mode."""
+ self._zone.set_preset(preset_mode)
+ self._signal_zone_update()
+
+ def turn_aux_heat_off(self):
+ """Turn. Aux Heat off."""
+ self._thermostat.set_emergency_heat(False)
+ self._signal_thermostat_update()
+
+ def turn_aux_heat_on(self):
+ """Turn. Aux Heat on."""
+ self._thermostat.set_emergency_heat(True)
+ self._signal_thermostat_update()
+
+ def turn_off(self):
+ """Turn. off the zone."""
+ self.set_hvac_mode(OPERATION_MODE_OFF)
+ self._signal_zone_update()
+
+ def turn_on(self):
+ """Turn. on the zone."""
+ self.set_hvac_mode(OPERATION_MODE_AUTO)
+ self._signal_zone_update()
+
+ def set_hvac_mode(self, hvac_mode: str) -> None:
+ """Set the system mode (Auto, Heat_Cool, Cool, Heat, etc)."""
+ if hvac_mode == HVAC_MODE_AUTO:
+ self._zone.call_return_to_schedule()
+ self._zone.set_mode(mode=OPERATION_MODE_AUTO)
+ else:
+ self._zone.call_permanent_hold()
+ self._zone.set_mode(mode=HA_TO_NEXIA_HVAC_MODE_MAP[hvac_mode])
+
+ self.schedule_update_ha_state()
+
+ def set_aircleaner_mode(self, aircleaner_mode):
+ """Set the aircleaner mode."""
+ self._thermostat.set_air_cleaner(aircleaner_mode)
+ self._signal_thermostat_update()
+
+ def set_humidify_setpoint(self, humidity):
+ """Set the humidify setpoint."""
+ self._thermostat.set_humidify_setpoint(humidity / 100.0)
+ self._signal_thermostat_update()
+
+ def _signal_thermostat_update(self):
+ """Signal a thermostat update.
+
+ Whenever the underlying library does an action against
+ a thermostat, the data for the thermostat and all
+ connected zone is updated.
+
+ Update all the zones on the thermostat.
+ """
+ dispatcher_send(
+ self.hass, f"{SIGNAL_THERMOSTAT_UPDATE}-{self._thermostat.thermostat_id}"
+ )
+
+ def _signal_zone_update(self):
+ """Signal a zone update.
+
+ Whenever the underlying library does an action against
+ a zone, the data for the zone is updated.
+
+ Update a single zone.
+ """
+ dispatcher_send(self.hass, f"{SIGNAL_ZONE_UPDATE}-{self._zone.zone_id}")
+
+ async def async_update(self):
+ """Update the entity.
+
+ Only used by the generic entity update service.
+ """
+ await self._coordinator.async_request_refresh()
diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py
new file mode 100644
index 00000000000..5844cb8da20
--- /dev/null
+++ b/homeassistant/components/nexia/config_flow.py
@@ -0,0 +1,88 @@
+"""Config flow for Nexia integration."""
+import logging
+
+from nexia.home import NexiaHome
+from requests.exceptions import ConnectTimeout, HTTPError
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str})
+
+
+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.
+ """
+ try:
+ nexia_home = NexiaHome(
+ username=data[CONF_USERNAME],
+ password=data[CONF_PASSWORD],
+ auto_login=False,
+ auto_update=False,
+ device_name=hass.config.location_name,
+ )
+ await hass.async_add_executor_job(nexia_home.login)
+ except ConnectTimeout as ex:
+ _LOGGER.error("Unable to connect to Nexia service: %s", ex)
+ raise CannotConnect
+ except HTTPError as http_ex:
+ _LOGGER.error("HTTP error from Nexia service: %s", http_ex)
+ if http_ex.response.status_code >= 400 and http_ex.response.status_code < 500:
+ raise InvalidAuth
+ raise CannotConnect
+
+ if not nexia_home.get_name():
+ raise InvalidAuth
+
+ info = {"title": nexia_home.get_name(), "house_id": nexia_home.house_id}
+ _LOGGER.debug("Setup ok with info: %s", info)
+ return info
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Nexia."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(info["house_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
+ )
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ return await self.async_step_user(user_input)
+
+
+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/nexia/const.py b/homeassistant/components/nexia/const.py
new file mode 100644
index 00000000000..dbe7b71705c
--- /dev/null
+++ b/homeassistant/components/nexia/const.py
@@ -0,0 +1,31 @@
+"""Nexia constants."""
+
+PLATFORMS = ["sensor", "binary_sensor", "climate", "scene"]
+
+ATTRIBUTION = "Data provided by mynexia.com"
+
+NOTIFICATION_ID = "nexia_notification"
+NOTIFICATION_TITLE = "Nexia Setup"
+
+NEXIA_DEVICE = "device"
+NEXIA_SCAN_INTERVAL = "scan_interval"
+
+DOMAIN = "nexia"
+DEFAULT_ENTITY_NAMESPACE = "nexia"
+
+ATTR_DESCRIPTION = "description"
+
+ATTR_AIRCLEANER_MODE = "aircleaner_mode"
+
+ATTR_ZONE_STATUS = "zone_status"
+ATTR_HUMIDIFY_SUPPORTED = "humidify_supported"
+ATTR_DEHUMIDIFY_SUPPORTED = "dehumidify_supported"
+ATTR_HUMIDIFY_SETPOINT = "humidify_setpoint"
+ATTR_DEHUMIDIFY_SETPOINT = "dehumidify_setpoint"
+
+UPDATE_COORDINATOR = "update_coordinator"
+
+MANUFACTURER = "Trane"
+
+SIGNAL_ZONE_UPDATE = "NEXIA_CLIMATE_ZONE_UPDATE"
+SIGNAL_THERMOSTAT_UPDATE = "NEXIA_CLIMATE_THERMOSTAT_UPDATE"
diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py
new file mode 100644
index 00000000000..60675cc5888
--- /dev/null
+++ b/homeassistant/components/nexia/entity.py
@@ -0,0 +1,133 @@
+"""The nexia integration base entity."""
+
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ ATTRIBUTION,
+ DOMAIN,
+ MANUFACTURER,
+ SIGNAL_THERMOSTAT_UPDATE,
+ SIGNAL_ZONE_UPDATE,
+)
+
+
+class NexiaEntity(Entity):
+ """Base class for nexia entities."""
+
+ def __init__(self, coordinator, name, unique_id):
+ """Initialize the entity."""
+ super().__init__()
+ self._unique_id = unique_id
+ self._name = name
+ self._coordinator = coordinator
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._coordinator.last_update_success
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ @property
+ def should_poll(self):
+ """Return False, updates are controlled via coordinator."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ self._coordinator.async_remove_listener(self.async_write_ha_state)
+
+
+class NexiaThermostatEntity(NexiaEntity):
+ """Base class for nexia devices attached to a thermostat."""
+
+ def __init__(self, coordinator, thermostat, name, unique_id):
+ """Initialize the entity."""
+ super().__init__(coordinator, name, unique_id)
+ self._thermostat = thermostat
+ self._thermostat_update_subscription = None
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ return {
+ "identifiers": {(DOMAIN, self._thermostat.thermostat_id)},
+ "name": self._thermostat.get_name(),
+ "model": self._thermostat.get_model(),
+ "sw_version": self._thermostat.get_firmware(),
+ "manufacturer": MANUFACTURER,
+ }
+
+ async def async_added_to_hass(self):
+ """Listen for signals for services."""
+ await super().async_added_to_hass()
+ self._thermostat_update_subscription = async_dispatcher_connect(
+ self.hass,
+ f"{SIGNAL_THERMOSTAT_UPDATE}-{self._thermostat.thermostat_id}",
+ self.async_write_ha_state,
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Unsub from signals for services."""
+ await super().async_will_remove_from_hass()
+ if self._thermostat_update_subscription:
+ self._thermostat_update_subscription()
+
+
+class NexiaThermostatZoneEntity(NexiaThermostatEntity):
+ """Base class for nexia devices attached to a thermostat."""
+
+ def __init__(self, coordinator, zone, name, unique_id):
+ """Initialize the entity."""
+ super().__init__(coordinator, zone.thermostat, name, unique_id)
+ self._zone = zone
+ self._zone_update_subscription = None
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ data = super().device_info
+ data.update(
+ {
+ "identifiers": {(DOMAIN, self._zone.zone_id)},
+ "name": self._zone.get_name(),
+ "via_device": (DOMAIN, self._zone.thermostat.thermostat_id),
+ }
+ )
+ return data
+
+ async def async_added_to_hass(self):
+ """Listen for signals for services."""
+ await super().async_added_to_hass()
+ self._zone_update_subscription = async_dispatcher_connect(
+ self.hass,
+ f"{SIGNAL_ZONE_UPDATE}-{self._zone.zone_id}",
+ self.async_write_ha_state,
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Unsub from signals for services."""
+ await super().async_will_remove_from_hass()
+ if self._zone_update_subscription:
+ self._zone_update_subscription()
diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json
new file mode 100644
index 00000000000..e69ea352c8e
--- /dev/null
+++ b/homeassistant/components/nexia/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "nexia",
+ "name": "Nexia",
+ "requirements": ["nexia==0.8.0"],
+ "codeowners": ["@ryannazaretian", "@bdraco"],
+ "documentation": "https://www.home-assistant.io/integrations/nexia",
+ "config_flow": true
+}
diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py
new file mode 100644
index 00000000000..fb851618aec
--- /dev/null
+++ b/homeassistant/components/nexia/scene.py
@@ -0,0 +1,58 @@
+"""Support for Nexia Automations."""
+
+from homeassistant.components.scene import Scene
+from homeassistant.helpers.event import async_call_later
+
+from .const import ATTR_DESCRIPTION, DOMAIN, NEXIA_DEVICE, UPDATE_COORDINATOR
+from .entity import NexiaEntity
+
+SCENE_ACTIVATION_TIME = 5
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up automations for a Nexia device."""
+
+ nexia_data = hass.data[DOMAIN][config_entry.entry_id]
+ nexia_home = nexia_data[NEXIA_DEVICE]
+ coordinator = nexia_data[UPDATE_COORDINATOR]
+ entities = []
+
+ # Automation switches
+ for automation_id in nexia_home.get_automation_ids():
+ automation = nexia_home.get_automation_by_id(automation_id)
+
+ entities.append(NexiaAutomationScene(coordinator, automation))
+
+ async_add_entities(entities, True)
+
+
+class NexiaAutomationScene(NexiaEntity, Scene):
+ """Provides Nexia automation support."""
+
+ def __init__(self, coordinator, automation):
+ """Initialize the automation scene."""
+ super().__init__(
+ coordinator, name=automation.name, unique_id=automation.automation_id,
+ )
+ self._automation = automation
+
+ @property
+ def device_state_attributes(self):
+ """Return the scene specific state attributes."""
+ data = super().device_state_attributes
+ data[ATTR_DESCRIPTION] = self._automation.description
+ return data
+
+ @property
+ def icon(self):
+ """Return the icon of the automation scene."""
+ return "mdi:script-text-outline"
+
+ async def async_activate(self):
+ """Activate an automation scene."""
+ await self.hass.async_add_executor_job(self._automation.activate)
+
+ async def refresh_callback(_):
+ await self._coordinator.async_refresh()
+
+ async_call_later(self.hass, SCENE_ACTIVATION_TIME, refresh_callback)
diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py
new file mode 100644
index 00000000000..abbffa2b844
--- /dev/null
+++ b/homeassistant/components/nexia/sensor.py
@@ -0,0 +1,239 @@
+"""Support for Nexia / Trane XL Thermostats."""
+
+from nexia.const import UNIT_CELSIUS
+
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
+)
+
+from .const import DOMAIN, NEXIA_DEVICE, UPDATE_COORDINATOR
+from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity
+from .util import percent_conv
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up sensors for a Nexia device."""
+
+ nexia_data = hass.data[DOMAIN][config_entry.entry_id]
+ nexia_home = nexia_data[NEXIA_DEVICE]
+ coordinator = nexia_data[UPDATE_COORDINATOR]
+ entities = []
+
+ # Thermostat / System Sensors
+ for thermostat_id in nexia_home.get_thermostat_ids():
+ thermostat = nexia_home.get_thermostat_by_id(thermostat_id)
+
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_system_status",
+ "System Status",
+ None,
+ None,
+ )
+ )
+ # Air cleaner
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_air_cleaner_mode",
+ "Air Cleaner Mode",
+ None,
+ None,
+ )
+ )
+ # Compressor Speed
+ if thermostat.has_variable_speed_compressor():
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_current_compressor_speed",
+ "Current Compressor Speed",
+ None,
+ UNIT_PERCENTAGE,
+ percent_conv,
+ )
+ )
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_requested_compressor_speed",
+ "Requested Compressor Speed",
+ None,
+ UNIT_PERCENTAGE,
+ percent_conv,
+ )
+ )
+ # Outdoor Temperature
+ if thermostat.has_outdoor_temperature():
+ unit = (
+ TEMP_CELSIUS
+ if thermostat.get_unit() == UNIT_CELSIUS
+ else TEMP_FAHRENHEIT
+ )
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_outdoor_temperature",
+ "Outdoor Temperature",
+ DEVICE_CLASS_TEMPERATURE,
+ unit,
+ )
+ )
+ # Relative Humidity
+ if thermostat.has_relative_humidity():
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_relative_humidity",
+ "Relative Humidity",
+ DEVICE_CLASS_HUMIDITY,
+ UNIT_PERCENTAGE,
+ percent_conv,
+ )
+ )
+
+ # Zone Sensors
+ for zone_id in thermostat.get_zone_ids():
+ zone = thermostat.get_zone_by_id(zone_id)
+ unit = (
+ TEMP_CELSIUS
+ if thermostat.get_unit() == UNIT_CELSIUS
+ else TEMP_FAHRENHEIT
+ )
+ # Temperature
+ entities.append(
+ NexiaThermostatZoneSensor(
+ coordinator,
+ zone,
+ "get_temperature",
+ "Temperature",
+ DEVICE_CLASS_TEMPERATURE,
+ unit,
+ None,
+ )
+ )
+ # Zone Status
+ entities.append(
+ NexiaThermostatZoneSensor(
+ coordinator, zone, "get_status", "Zone Status", None, None,
+ )
+ )
+ # Setpoint Status
+ entities.append(
+ NexiaThermostatZoneSensor(
+ coordinator,
+ zone,
+ "get_setpoint_status",
+ "Zone Setpoint Status",
+ None,
+ None,
+ )
+ )
+
+ async_add_entities(entities, True)
+
+
+class NexiaThermostatSensor(NexiaThermostatEntity):
+ """Provides Nexia thermostat sensor support."""
+
+ def __init__(
+ self,
+ coordinator,
+ thermostat,
+ sensor_call,
+ sensor_name,
+ sensor_class,
+ sensor_unit,
+ modifier=None,
+ ):
+ """Initialize the sensor."""
+ super().__init__(
+ coordinator,
+ thermostat,
+ name=f"{thermostat.get_name()} {sensor_name}",
+ unique_id=f"{thermostat.thermostat_id}_{sensor_call}",
+ )
+ self._call = sensor_call
+ self._class = sensor_class
+ self._state = None
+ self._unit_of_measurement = sensor_unit
+ self._modifier = modifier
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._class
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ val = getattr(self._thermostat, self._call)()
+ if self._modifier:
+ val = self._modifier(val)
+ if isinstance(val, float):
+ val = round(val, 1)
+ return val
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+
+class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity):
+ """Nexia Zone Sensor Support."""
+
+ def __init__(
+ self,
+ coordinator,
+ zone,
+ sensor_call,
+ sensor_name,
+ sensor_class,
+ sensor_unit,
+ modifier=None,
+ ):
+ """Create a zone sensor."""
+
+ super().__init__(
+ coordinator,
+ zone,
+ name=f"{zone.get_name()} {sensor_name}",
+ unique_id=f"{zone.zone_id}_{sensor_call}",
+ )
+ self._call = sensor_call
+ self._class = sensor_class
+ self._state = None
+ self._unit_of_measurement = sensor_unit
+ self._modifier = modifier
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._class
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ val = getattr(self._zone, self._call)()
+ if self._modifier:
+ val = self._modifier(val)
+ if isinstance(val, float):
+ val = round(val, 1)
+ return val
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml
new file mode 100644
index 00000000000..725b215da5a
--- /dev/null
+++ b/homeassistant/components/nexia/services.yaml
@@ -0,0 +1,19 @@
+set_aircleaner_mode:
+ description: "The air cleaner mode."
+ fields:
+ entity_id:
+ description: "This setting will affect all zones connected to the thermostat."
+ example: climate.master_bedroom
+ aircleaner_mode:
+ description: "The air cleaner mode to set. Options include \"auto\", \"quick\", or \"allergy\"."
+ example: allergy
+
+set_humidify_setpoint:
+ description: "The humidification set point."
+ fields:
+ entity_id:
+ description: "This setting will affect all zones connected to the thermostat."
+ example: climate.master_bedroom
+ humidity:
+ description: "The humidification setpoint as an int, range 35-65."
+ example: 45
\ No newline at end of file
diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json
new file mode 100644
index 00000000000..d3fabfb0b4d
--- /dev/null
+++ b/homeassistant/components/nexia/strings.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "title": "Nexia",
+ "step": {
+ "user": {
+ "title": "Connect to mynexia.com",
+ "data": {
+ "username": "Username",
+ "password": "Password"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "This nexia home is already configured"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nexia/util.py b/homeassistant/components/nexia/util.py
new file mode 100644
index 00000000000..d2ff10c8d34
--- /dev/null
+++ b/homeassistant/components/nexia/util.py
@@ -0,0 +1,6 @@
+"""Utils for Nexia / Trane XL Thermostats."""
+
+
+def percent_conv(val):
+ """Convert an actual percentage (0.0-1.0) to 0-100 scale."""
+ return round(val * 100.0, 1)
diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py
new file mode 100644
index 00000000000..39eb16ec265
--- /dev/null
+++ b/homeassistant/components/nextcloud/__init__.py
@@ -0,0 +1,147 @@
+"""The Nextcloud integration."""
+from datetime import timedelta
+import logging
+
+from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD,
+ CONF_SCAN_INTERVAL,
+ CONF_URL,
+ CONF_USERNAME,
+)
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "nextcloud"
+NEXTCLOUD_COMPONENTS = ("sensor", "binary_sensor")
+SCAN_INTERVAL = timedelta(seconds=60)
+
+# Validate user configuration
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Required(CONF_URL): cv.url,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period,
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+BINARY_SENSORS = (
+ "nextcloud_system_enable_avatars",
+ "nextcloud_system_enable_previews",
+ "nextcloud_system_filelocking.enabled",
+ "nextcloud_system_debug",
+)
+
+SENSORS = (
+ "nextcloud_system_version",
+ "nextcloud_system_theme",
+ "nextcloud_system_memcache.local",
+ "nextcloud_system_memcache.distributed",
+ "nextcloud_system_memcache.locking",
+ "nextcloud_system_freespace",
+ "nextcloud_system_cpuload",
+ "nextcloud_system_mem_total",
+ "nextcloud_system_mem_free",
+ "nextcloud_system_swap_total",
+ "nextcloud_system_swap_free",
+ "nextcloud_system_apps_num_installed",
+ "nextcloud_system_apps_num_updates_available",
+ "nextcloud_system_apps_app_updates_calendar",
+ "nextcloud_system_apps_app_updates_contacts",
+ "nextcloud_system_apps_app_updates_tasks",
+ "nextcloud_system_apps_app_updates_twofactor_totp",
+ "nextcloud_storage_num_users",
+ "nextcloud_storage_num_files",
+ "nextcloud_storage_num_storages",
+ "nextcloud_storage_num_storages_local",
+ "nextcloud_storage_num_storage_home",
+ "nextcloud_storage_num_storages_other",
+ "nextcloud_shares_num_shares",
+ "nextcloud_shares_num_shares_user",
+ "nextcloud_shares_num_shares_groups",
+ "nextcloud_shares_num_shares_link",
+ "nextcloud_shares_num_shares_mail",
+ "nextcloud_shares_num_shares_room",
+ "nextcloud_shares_num_shares_link_no_password",
+ "nextcloud_shares_num_fed_shares_sent",
+ "nextcloud_shares_num_fed_shares_received",
+ "nextcloud_shares_permissions_3_1",
+ "nextcloud_server_webserver",
+ "nextcloud_server_php_version",
+ "nextcloud_server_php_memory_limit",
+ "nextcloud_server_php_max_execution_time",
+ "nextcloud_server_php_upload_max_filesize",
+ "nextcloud_database_type",
+ "nextcloud_database_version",
+ "nextcloud_database_version",
+ "nextcloud_activeusers_last5minutes",
+ "nextcloud_activeusers_last1hour",
+ "nextcloud_activeusers_last24hours",
+)
+
+
+def setup(hass, config):
+ """Set up the Nextcloud integration."""
+ # Fetch Nextcloud Monitor api data
+ conf = config[DOMAIN]
+
+ try:
+ ncm = NextcloudMonitor(conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD])
+ except NextcloudMonitorError:
+ _LOGGER.error("Nextcloud setup failed - Check configuration")
+
+ hass.data[DOMAIN] = get_data_points(ncm.data)
+ hass.data[DOMAIN]["instance"] = conf[CONF_URL]
+
+ def nextcloud_update(event_time):
+ """Update data from nextcloud api."""
+ try:
+ ncm.update()
+ except NextcloudMonitorError:
+ _LOGGER.error("Nextcloud update failed")
+ return False
+
+ hass.data[DOMAIN] = get_data_points(ncm.data)
+
+ # Update sensors on time interval
+ track_time_interval(hass, nextcloud_update, conf[CONF_SCAN_INTERVAL])
+
+ for component in NEXTCLOUD_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+# Use recursion to create list of sensors & values based on nextcloud api data
+def get_data_points(api_data, key_path="", leaf=False):
+ """Use Recursion to discover data-points and values.
+
+ Get dictionary of data-points by recursing through dict returned by api until
+ the dictionary value does not contain another dictionary and use the
+ resulting path of dictionary keys and resulting value as the name/value
+ for the data-point.
+
+ returns: dictionary of data-point/values
+ """
+ result = {}
+ for key, value in api_data.items():
+ if isinstance(value, dict):
+ if leaf:
+ key_path = f"{key}_"
+ if not leaf:
+ key_path += f"{key}_"
+ leaf = True
+ result.update(get_data_points(value, key_path, leaf))
+ else:
+ result[f"{DOMAIN}_{key_path}{key}"] = value
+ leaf = False
+ return result
diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py
new file mode 100644
index 00000000000..9e4c6f5d969
--- /dev/null
+++ b/homeassistant/components/nextcloud/binary_sensor.py
@@ -0,0 +1,52 @@
+"""Summary binary data from Nextcoud."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import BINARY_SENSORS, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nextcloud sensors."""
+ if discovery_info is None:
+ return
+ binary_sensors = []
+ for name in hass.data[DOMAIN]:
+ if name in BINARY_SENSORS:
+ binary_sensors.append(NextcloudBinarySensor(name))
+ add_entities(binary_sensors, True)
+
+
+class NextcloudBinarySensor(BinarySensorDevice):
+ """Represents a Nextcloud binary sensor."""
+
+ def __init__(self, item):
+ """Initialize the Nextcloud binary sensor."""
+ self._name = item
+ self._is_on = None
+
+ @property
+ def icon(self):
+ """Return the icon for this binary sensor."""
+ return "mdi:cloud"
+
+ @property
+ def name(self):
+ """Return the name for this binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._is_on == "yes"
+
+ @property
+ def unique_id(self):
+ """Return the unique ID for this binary sensor."""
+ return f"{self.hass.data[DOMAIN]['instance']}#{self._name}"
+
+ def update(self):
+ """Update the binary sensor."""
+ self._is_on = self.hass.data[DOMAIN][self._name]
diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json
new file mode 100644
index 00000000000..4db0019920d
--- /dev/null
+++ b/homeassistant/components/nextcloud/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "nextcloud",
+ "name": "Nextcloud",
+ "documentation": "https://www.home-assistant.io/integrations/nextcloud",
+ "requirements": [
+ "nextcloudmonitor==1.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@meichthys"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py
new file mode 100644
index 00000000000..aacd33ec3e8
--- /dev/null
+++ b/homeassistant/components/nextcloud/sensor.py
@@ -0,0 +1,52 @@
+"""Summary data from Nextcoud."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN, SENSORS
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nextcloud sensors."""
+ if discovery_info is None:
+ return
+ sensors = []
+ for name in hass.data[DOMAIN]:
+ if name in SENSORS:
+ sensors.append(NextcloudSensor(name))
+ add_entities(sensors, True)
+
+
+class NextcloudSensor(Entity):
+ """Represents a Nextcloud sensor."""
+
+ def __init__(self, item):
+ """Initialize the Nextcloud sensor."""
+ self._name = item
+ self._state = None
+
+ @property
+ def icon(self):
+ """Return the icon for this sensor."""
+ return "mdi:cloud"
+
+ @property
+ def name(self):
+ """Return the name for this sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state for this sensor."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return the unique ID for this sensor."""
+ return f"{self.hass.data[DOMAIN]['instance']}#{self._name}"
+
+ def update(self):
+ """Update the sensor."""
+ self._state = self.hass.data[DOMAIN][self._name]
diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py
index fba84c936f5..57b9bdb61fa 100644
--- a/homeassistant/components/nissan_leaf/__init__.py
+++ b/homeassistant/components/nissan_leaf/__init__.py
@@ -380,7 +380,10 @@ class LeafDataStore:
)
return server_info
except CarwingsError:
- _LOGGER.error("An error occurred getting battery status.")
+ _LOGGER.error("An error occurred getting battery status")
+ return None
+ except KeyError:
+ _LOGGER.error("An error occurred parsing response from server")
return None
async def async_get_climate(self):
diff --git a/homeassistant/components/notion/.translations/bg.json b/homeassistant/components/notion/.translations/bg.json
index 33ce361958a..1c78180e2a8 100644
--- a/homeassistant/components/notion/.translations/bg.json
+++ b/homeassistant/components/notion/.translations/bg.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e\u0442\u043e \u0438\u043c\u0435 \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u043e",
"invalid_credentials": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430",
"no_devices": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0430"
},
diff --git a/homeassistant/components/notion/.translations/ca.json b/homeassistant/components/notion/.translations/ca.json
index 09f598ef5d1..b6e73a5e209 100644
--- a/homeassistant/components/notion/.translations/ca.json
+++ b/homeassistant/components/notion/.translations/ca.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "No s'han trobat dispositius al compte"
},
diff --git a/homeassistant/components/notion/.translations/da.json b/homeassistant/components/notion/.translations/da.json
index 784d106b94c..6b139fa6e66 100644
--- a/homeassistant/components/notion/.translations/da.json
+++ b/homeassistant/components/notion/.translations/da.json
@@ -4,7 +4,6 @@
"already_configured": "Dette brugernavn er allerede i brug."
},
"error": {
- "identifier_exists": "Brugernavn er allerede registreret",
"invalid_credentials": "Ugyldigt brugernavn eller adgangskode",
"no_devices": "Ingen enheder fundet i konto"
},
diff --git a/homeassistant/components/notion/.translations/de.json b/homeassistant/components/notion/.translations/de.json
index e11a16458c9..1ccd8c86bdc 100644
--- a/homeassistant/components/notion/.translations/de.json
+++ b/homeassistant/components/notion/.translations/de.json
@@ -4,7 +4,6 @@
"already_configured": "Dieser Benutzername wird bereits benutzt."
},
"error": {
- "identifier_exists": "Benutzername bereits registriert",
"invalid_credentials": "Ung\u00fcltiger Benutzername oder Passwort",
"no_devices": "Keine Ger\u00e4te im Konto gefunden"
},
diff --git a/homeassistant/components/notion/.translations/en.json b/homeassistant/components/notion/.translations/en.json
index 2476293a216..b729b368c37 100644
--- a/homeassistant/components/notion/.translations/en.json
+++ b/homeassistant/components/notion/.translations/en.json
@@ -4,7 +4,6 @@
"already_configured": "This username is already in use."
},
"error": {
- "identifier_exists": "Username already registered",
"invalid_credentials": "Invalid username or password",
"no_devices": "No devices found in account"
},
diff --git a/homeassistant/components/notion/.translations/es-419.json b/homeassistant/components/notion/.translations/es-419.json
index 1f4968f24e1..ad2f19b0668 100644
--- a/homeassistant/components/notion/.translations/es-419.json
+++ b/homeassistant/components/notion/.translations/es-419.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Nombre de usuario ya registrado",
"invalid_credentials": "Nombre de usuario o contrase\u00f1a inv\u00e1lidos",
"no_devices": "No se han encontrado dispositivos en la cuenta."
},
diff --git a/homeassistant/components/notion/.translations/es.json b/homeassistant/components/notion/.translations/es.json
index 08d02bd7493..7293e8f229f 100644
--- a/homeassistant/components/notion/.translations/es.json
+++ b/homeassistant/components/notion/.translations/es.json
@@ -4,7 +4,6 @@
"already_configured": "Esta nombre de usuario ya est\u00e1 en uso."
},
"error": {
- "identifier_exists": "Nombre de usuario ya registrado",
"invalid_credentials": "Usuario o contrase\u00f1a no v\u00e1lido",
"no_devices": "No se han encontrado dispositivos en la cuenta"
},
diff --git a/homeassistant/components/notion/.translations/fr.json b/homeassistant/components/notion/.translations/fr.json
index 5f0bdd48a8a..ae24ba70419 100644
--- a/homeassistant/components/notion/.translations/fr.json
+++ b/homeassistant/components/notion/.translations/fr.json
@@ -1,7 +1,9 @@
{
"config": {
+ "abort": {
+ "already_configured": "Ce nom d'utilisateur est d\u00e9j\u00e0 utilis\u00e9."
+ },
"error": {
- "identifier_exists": "Nom d'utilisateur d\u00e9j\u00e0 enregistr\u00e9",
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
"no_devices": "Aucun appareil trouv\u00e9 sur le compte"
},
diff --git a/homeassistant/components/notion/.translations/hr.json b/homeassistant/components/notion/.translations/hr.json
index b20317a236a..93ab9a4bf51 100644
--- a/homeassistant/components/notion/.translations/hr.json
+++ b/homeassistant/components/notion/.translations/hr.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Korisni\u010dko ime je ve\u0107 registrirano",
"invalid_credentials": "Neispravno korisni\u010dko ime ili lozinka",
"no_devices": "Nisu prona\u0111eni ure\u0111aji na ra\u010dunu"
},
diff --git a/homeassistant/components/notion/.translations/hu.json b/homeassistant/components/notion/.translations/hu.json
index 79878858ddc..285e6c7b485 100644
--- a/homeassistant/components/notion/.translations/hu.json
+++ b/homeassistant/components/notion/.translations/hu.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Felhaszn\u00e1l\u00f3n\u00e9v m\u00e1r regisztr\u00e1lva van",
"invalid_credentials": "\u00c9rv\u00e9nytelen felhaszn\u00e1l\u00f3n\u00e9v vagy jelsz\u00f3",
"no_devices": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a fi\u00f3kban"
},
diff --git a/homeassistant/components/notion/.translations/it.json b/homeassistant/components/notion/.translations/it.json
index 18ad0987aa7..e33b50f1938 100644
--- a/homeassistant/components/notion/.translations/it.json
+++ b/homeassistant/components/notion/.translations/it.json
@@ -4,7 +4,6 @@
"already_configured": "Questo nome utente \u00e8 gi\u00e0 in uso."
},
"error": {
- "identifier_exists": "Nome utente gi\u00e0 registrato",
"invalid_credentials": "Nome utente o password non validi",
"no_devices": "Nessun dispositivo trovato nell'account"
},
diff --git a/homeassistant/components/notion/.translations/ko.json b/homeassistant/components/notion/.translations/ko.json
index 52c7b6339cb..c848684ab59 100644
--- a/homeassistant/components/notion/.translations/ko.json
+++ b/homeassistant/components/notion/.translations/ko.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "\uacc4\uc815\uc5d0 \ub4f1\ub85d\ub41c \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4"
},
diff --git a/homeassistant/components/notion/.translations/lb.json b/homeassistant/components/notion/.translations/lb.json
index bc9fa9633b2..b5d2eabd507 100644
--- a/homeassistant/components/notion/.translations/lb.json
+++ b/homeassistant/components/notion/.translations/lb.json
@@ -4,7 +4,6 @@
"already_configured": "D\u00ebse Benotzernumm g\u00ebtt scho benotzt."
},
"error": {
- "identifier_exists": "Benotzernumm ass scho registr\u00e9iert",
"invalid_credentials": "Ong\u00ebltege Benotzernumm oder Passwuert",
"no_devices": "Keng Apparater am Kont fonnt"
},
diff --git a/homeassistant/components/notion/.translations/nl.json b/homeassistant/components/notion/.translations/nl.json
index c26fb50e075..f45ea87f972 100644
--- a/homeassistant/components/notion/.translations/nl.json
+++ b/homeassistant/components/notion/.translations/nl.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Gebruikersnaam al geregistreerd",
"invalid_credentials": "Ongeldige gebruikersnaam of wachtwoord",
"no_devices": "Geen apparaten gevonden in account"
},
diff --git a/homeassistant/components/notion/.translations/no.json b/homeassistant/components/notion/.translations/no.json
index 16105e680c5..302ef3f2b39 100644
--- a/homeassistant/components/notion/.translations/no.json
+++ b/homeassistant/components/notion/.translations/no.json
@@ -4,7 +4,6 @@
"already_configured": "Dette brukernavnet er allerede i bruk."
},
"error": {
- "identifier_exists": "Brukernavn er allerede registrert",
"invalid_credentials": "Ugyldig brukernavn eller passord",
"no_devices": "Ingen enheter funnet i kontoen"
},
diff --git a/homeassistant/components/notion/.translations/pl.json b/homeassistant/components/notion/.translations/pl.json
index 07facb21e93..fb9ffaad9c0 100644
--- a/homeassistant/components/notion/.translations/pl.json
+++ b/homeassistant/components/notion/.translations/pl.json
@@ -4,7 +4,6 @@
"already_configured": "Ta nazwa u\u017cytkownika jest ju\u017c w u\u017cyciu."
},
"error": {
- "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"
},
diff --git a/homeassistant/components/notion/.translations/pt-BR.json b/homeassistant/components/notion/.translations/pt-BR.json
index 4e81ac03665..5f790c02a40 100644
--- a/homeassistant/components/notion/.translations/pt-BR.json
+++ b/homeassistant/components/notion/.translations/pt-BR.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Nome de usu\u00e1rio j\u00e1 registrado",
"invalid_credentials": "Usu\u00e1rio ou senha inv\u00e1lidos",
"no_devices": "Nenhum dispositivo encontrado na conta"
},
diff --git a/homeassistant/components/notion/.translations/ru.json b/homeassistant/components/notion/.translations/ru.json
index 6e64ebbe7aa..41627cc6ab0 100644
--- a/homeassistant/components/notion/.translations/ru.json
+++ b/homeassistant/components/notion/.translations/ru.json
@@ -4,7 +4,6 @@
"already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f."
},
"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\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.",
"no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e."
},
diff --git a/homeassistant/components/notion/.translations/sl.json b/homeassistant/components/notion/.translations/sl.json
index bbc87c6722a..5abe6164038 100644
--- a/homeassistant/components/notion/.translations/sl.json
+++ b/homeassistant/components/notion/.translations/sl.json
@@ -1,7 +1,9 @@
{
"config": {
+ "abort": {
+ "already_configured": "To uporabni\u0161ko ime je \u017ee v uporabi."
+ },
"error": {
- "identifier_exists": "Uporabni\u0161ko ime je \u017ee registrirano",
"invalid_credentials": "Neveljavno uporabni\u0161ko ime ali geslo",
"no_devices": "V ra\u010dunu ni najdene nobene naprave"
},
diff --git a/homeassistant/components/notion/.translations/sv.json b/homeassistant/components/notion/.translations/sv.json
index 958cc48af28..89648180246 100644
--- a/homeassistant/components/notion/.translations/sv.json
+++ b/homeassistant/components/notion/.translations/sv.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "Anv\u00e4ndarnamn \u00e4r redan anv\u00e4nt",
"invalid_credentials": "Felaktigt anv\u00e4ndarnamn eller l\u00f6senord",
"no_devices": "Inga enheter hittades p\u00e5 kontot"
},
diff --git a/homeassistant/components/notion/.translations/zh-Hans.json b/homeassistant/components/notion/.translations/zh-Hans.json
index 81d93727956..0e61657f615 100644
--- a/homeassistant/components/notion/.translations/zh-Hans.json
+++ b/homeassistant/components/notion/.translations/zh-Hans.json
@@ -1,7 +1,6 @@
{
"config": {
"error": {
- "identifier_exists": "\u7528\u6237\u540d\u5df2\u6ce8\u518c",
"invalid_credentials": "\u65e0\u6548\u7684\u7528\u6237\u540d\u6216\u5bc6\u7801",
"no_devices": "\u5e10\u6237\u4e2d\u627e\u4e0d\u5230\u8bbe\u5907"
},
diff --git a/homeassistant/components/notion/.translations/zh-Hant.json b/homeassistant/components/notion/.translations/zh-Hant.json
index c426dfa3265..2767c504b78 100644
--- a/homeassistant/components/notion/.translations/zh-Hant.json
+++ b/homeassistant/components/notion/.translations/zh-Hant.json
@@ -4,7 +4,6 @@
"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",
"no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u8a2d\u5099"
},
diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py
index f387e820253..e9c45c62816 100644
--- a/homeassistant/components/notion/__init__.py
+++ b/homeassistant/components/notion/__init__.py
@@ -306,13 +306,22 @@ class NotionEntity(Entity):
def update():
"""Update the entity."""
self.hass.async_create_task(self._update_bridge_id())
- self.async_schedule_update_ha_state(True)
+ self.update_from_latest_data()
+ self.async_write_ha_state()
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, TOPIC_DATA_UPDATE, update
)
+ self.update_from_latest_data()
+
async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
+ self._async_unsub_dispatcher_connect = None
+
+ @callback
+ def update_from_latest_data(self):
+ """Update the entity from the latest data."""
+ raise NotImplementedError
diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py
index 5079348e821..53a98204704 100644
--- a/homeassistant/components/notion/binary_sensor.py
+++ b/homeassistant/components/notion/binary_sensor.py
@@ -2,6 +2,7 @@
import logging
from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
from . import (
BINARY_SENSOR_TYPES,
@@ -75,7 +76,8 @@ class NotionBinarySensor(NotionEntity, BinarySensorDevice):
if task["task_type"] == SENSOR_SMOKE_CO:
return self._state != "no_alarm"
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Fetch new state data for the sensor."""
task = self._notion.tasks[self._task_id]
diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py
index 0e1c9f25cb2..918ee8c5f95 100644
--- a/homeassistant/components/notion/sensor.py
+++ b/homeassistant/components/notion/sensor.py
@@ -1,6 +1,8 @@
"""Support for Notion sensors."""
import logging
+from homeassistant.core import callback
+
from . import SENSOR_TEMPERATURE, SENSOR_TYPES, NotionEntity
from .const import DATA_CLIENT, DOMAIN
@@ -58,7 +60,8 @@ class NotionSensor(NotionEntity):
"""Return the unit of measurement."""
return self._unit
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Fetch new state data for the sensor."""
task = self._notion.tasks[self._task_id]
diff --git a/homeassistant/components/nuheat/.translations/ca.json b/homeassistant/components/nuheat/.translations/ca.json
new file mode 100644
index 00000000000..6c2f739b94c
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/ca.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El term\u00f2stat ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "invalid_thermostat": "El n\u00famero de s\u00e8rie del term\u00f2stat no \u00e9s v\u00e0lid.",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrasenya",
+ "serial_number": "N\u00famero de s\u00e8rie del term\u00f2stat.",
+ "username": "Nom d'usuari"
+ },
+ "description": "Has d\u2019obtenir el n\u00famero de s\u00e8rie o identificador del teu term\u00f2stat entrant a https://MyNuHeat.com i seleccionant el teu term\u00f2stat.",
+ "title": "Connexi\u00f3 amb NuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/da.json b/homeassistant/components/nuheat/.translations/da.json
new file mode 100644
index 00000000000..3e66091d851
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/da.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/de.json b/homeassistant/components/nuheat/.translations/de.json
new file mode 100644
index 00000000000..adbc63b8157
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/de.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Der Thermostat ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "invalid_auth": "Ung\u00fcltige Authentifizierung",
+ "invalid_thermostat": "Die Seriennummer des Thermostats ist ung\u00fcltig.",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwort",
+ "serial_number": "Seriennummer des Thermostats.",
+ "username": "Benutzername"
+ },
+ "description": "Sie m\u00fcssen die numerische Seriennummer oder ID Ihres Thermostats erhalten, indem Sie sich bei https://MyNuHeat.com anmelden und Ihre Thermostate ausw\u00e4hlen.",
+ "title": "Stellen Sie eine Verbindung zu NuHeat her"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/en.json b/homeassistant/components/nuheat/.translations/en.json
new file mode 100644
index 00000000000..4b82319be62
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/en.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "The thermostat is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "invalid_thermostat": "The thermostat serial number is invalid.",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "serial_number": "Serial number of the thermostat.",
+ "username": "Username"
+ },
+ "description": "You will need to obtain your thermostat\u2019s numeric serial number or ID by logging into https://MyNuHeat.com and selecting your thermostat(s).",
+ "title": "Connect to the NuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/es.json b/homeassistant/components/nuheat/.translations/es.json
new file mode 100644
index 00000000000..3a37b65b9dd
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/es.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El termostato ya est\u00e1 configurado."
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar, por favor int\u00e9ntalo de nuevo",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "invalid_thermostat": "El n\u00famero de serie del termostato no es v\u00e1lido.",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "serial_number": "N\u00famero de serie del termostato.",
+ "username": "Nombre de usuario"
+ },
+ "description": "Necesitas obtener el n\u00famero de serie o el ID de tu termostato iniciando sesi\u00f3n en https://MyNuHeat.com y seleccionando tu(s) termostato(s).",
+ "title": "ConectarNuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/fr.json b/homeassistant/components/nuheat/.translations/fr.json
new file mode 100644
index 00000000000..21012de756b
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/fr.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le thermostat est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification non valide",
+ "invalid_thermostat": "Le num\u00e9ro de s\u00e9rie du thermostat n'est pas valide.",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Mot de passe",
+ "serial_number": "Num\u00e9ro de s\u00e9rie du thermostat.",
+ "username": "Nom d'utilisateur"
+ },
+ "title": "Connectez-vous au NuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/it.json b/homeassistant/components/nuheat/.translations/it.json
new file mode 100644
index 00000000000..a98f24a9651
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/it.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il termostato \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "invalid_auth": "Autenticazione non valida",
+ "invalid_thermostat": "Il numero di serie del termostato non \u00e8 valido.",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "serial_number": "Numero di serie del termostato.",
+ "username": "Nome utente"
+ },
+ "description": "\u00c8 necessario ottenere il numero di serie o l'ID numerico del termostato accedendo a https://MyNuHeat.com e selezionando il termostato.",
+ "title": "Connettersi al NuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/ko.json b/homeassistant/components/nuheat/.translations/ko.json
new file mode 100644
index 00000000000..01db5835907
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/ko.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc628\ub3c4 \uc870\uc808\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "invalid_thermostat": "\uc628\ub3c4 \uc870\uc808\uae30\uc758 \uc2dc\ub9ac\uc5bc \ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "serial_number": "\uc628\ub3c4 \uc870\uc808\uae30\uc758 \uc2dc\ub9ac\uc5bc \ubc88\ud638",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "description": "https://MyNuHeat.com \uc5d0 \ub85c\uadf8\uc778\ud558\uace0 \uc628\ub3c4 \uc870\uc808\uae30\ub97c \uc120\ud0dd\ud558\uc5ec \uc628\ub3c4 \uc870\uc808\uae30\uc758 \uc2dc\ub9ac\uc5bc \ubc88\ud638 \ub610\ub294 \ub610\ub294 ID \ub97c \uc5bb\uc5b4\uc57c \ud569\ub2c8\ub2e4.",
+ "title": "NuHeat \uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/lb.json b/homeassistant/components/nuheat/.translations/lb.json
new file mode 100644
index 00000000000..fd2b3114d4d
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/lb.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Den Thermostat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "invalid_thermostat": "Seriennummer vum Thermostat ass ong\u00eblteg",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwuert",
+ "serial_number": "Seriennummer vum Thermostat",
+ "username": "Benotzernumm"
+ },
+ "description": "Du brauchs d'Seriennummer oder ID vum Thermostat, andeems Du dech op https://MyNuHeat.com umells an den Thermostat auswielt.",
+ "title": "Mat NuHeat verbannen"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/no.json b/homeassistant/components/nuheat/.translations/no.json
new file mode 100644
index 00000000000..74c0b8a8f54
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/no.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Termostaten er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "invalid_auth": "Ugyldig godkjenning",
+ "invalid_thermostat": "Termostatens serienummer er ugyldig.",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passord",
+ "serial_number": "Termostatenes serienummer.",
+ "username": "Brukernavn"
+ },
+ "description": "Du m\u00e5 skaffe termostats numeriske serienummer eller ID ved \u00e5 logge inn p\u00e5 https://MyNuHeat.com og velge termostaten (e).",
+ "title": "Koble til NuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/ru.json b/homeassistant/components/nuheat/.translations/ru.json
new file mode 100644
index 00000000000..9a2ff139dd2
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/ru.json
@@ -0,0 +1,25 @@
+{
+ "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\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.",
+ "invalid_thermostat": "\u0421\u0435\u0440\u0438\u0439\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u0440\u043c\u043e\u0441\u0442\u0430\u0442\u0430 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "serial_number": "\u0421\u0435\u0440\u0438\u0439\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u0440\u043c\u043e\u0441\u0442\u0430\u0442\u0430.",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "description": "\u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u0439 \u043d\u043e\u043c\u0435\u0440 \u0438\u043b\u0438 ID \u0412\u0430\u0448\u0435\u0433\u043e \u0442\u0435\u0440\u043c\u043e\u0441\u0442\u0430\u0442\u0430, \u043d\u0430 \u0441\u0430\u0439\u0442\u0435 https://MyNuHeat.com.",
+ "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/.translations/zh-Hant.json b/homeassistant/components/nuheat/.translations/zh-Hant.json
new file mode 100644
index 00000000000..e228abeecd9
--- /dev/null
+++ b/homeassistant/components/nuheat/.translations/zh-Hant.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6eab\u63a7\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "invalid_thermostat": "\u6eab\u63a7\u5668\u5e8f\u865f\u7121\u6548\u3002",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "serial_number": "\u6eab\u63a7\u5668\u5e8f\u865f\u3002",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "description": "\u9700\u8981\u67e5\u770b\u60a8\u7684\u6eab\u63a7\u5668\u5e8f\u865f\u6216\u767b\u5165 https://MyNuHeat.com \u4e4b ID \u4e26\u9078\u64c7\u60a8\u7684\u6eab\u63a7\u5668\u3002",
+ "title": "\u9023\u7dda\u81f3 NuHeat"
+ }
+ },
+ "title": "NuHeat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py
index 88e10270d18..ca47f831370 100644
--- a/homeassistant/components/nuheat/__init__.py
+++ b/homeassistant/components/nuheat/__init__.py
@@ -1,16 +1,21 @@
"""Support for NuHeat thermostats."""
+import asyncio
import logging
import nuheat
+import requests
import voluptuous as vol
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME
-from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+
+from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "nuheat"
-
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -27,16 +32,81 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup(hass, config):
- """Set up the NuHeat thermostat component."""
- conf = config[DOMAIN]
- username = conf.get(CONF_USERNAME)
- password = conf.get(CONF_PASSWORD)
- devices = conf.get(CONF_DEVICES)
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the NuHeat component."""
+ hass.data.setdefault(DOMAIN, {})
+ conf = config.get(DOMAIN)
+ if not conf:
+ return True
+
+ for serial_number in conf[CONF_DEVICES]:
+ # Since the api currently doesn't permit fetching the serial numbers
+ # and they have to be specified we create a separate config entry for
+ # each serial number. This won't increase the number of http
+ # requests as each thermostat has to be updated anyways.
+ # This also allows us to validate that the entered valid serial
+ # numbers and do not end up with a config entry where half of the
+ # devices work.
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_USERNAME: conf[CONF_USERNAME],
+ CONF_PASSWORD: conf[CONF_PASSWORD],
+ CONF_SERIAL_NUMBER: serial_number,
+ },
+ )
+ )
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up NuHeat from a config entry."""
+
+ conf = entry.data
+
+ username = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+ serial_number = conf[CONF_SERIAL_NUMBER]
api = nuheat.NuHeat(username, password)
- api.authenticate()
- hass.data[DOMAIN] = (api, devices)
- discovery.load_platform(hass, "climate", DOMAIN, {}, config)
+ try:
+ await hass.async_add_executor_job(api.authenticate)
+ except requests.exceptions.Timeout:
+ raise ConfigEntryNotReady
+ except requests.exceptions.HTTPError as ex:
+ if ex.response.status_code > 400 and ex.response.status_code < 500:
+ _LOGGER.error("Failed to login to nuheat: %s", ex)
+ return False
+ raise ConfigEntryNotReady
+ except Exception as ex: # pylint: disable=broad-except
+ _LOGGER.error("Failed to login to nuheat: %s", ex)
+ return False
+
+ hass.data[DOMAIN][entry.entry_id] = (api, serial_number)
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py
index 5cf9bd6fc58..c1d591c03eb 100644
--- a/homeassistant/components/nuheat/climate.py
+++ b/homeassistant/components/nuheat/climate.py
@@ -1,94 +1,91 @@
"""Support for NuHeat thermostats."""
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
+import time
-import voluptuous as vol
+from nuheat.config import SCHEDULE_HOLD, SCHEDULE_RUN, SCHEDULE_TEMPORARY_HOLD
+from nuheat.util import (
+ celsius_to_nuheat,
+ fahrenheit_to_nuheat,
+ nuheat_to_celsius,
+ nuheat_to_fahrenheit,
+)
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
+ ATTR_HVAC_MODE,
+ CURRENT_HVAC_HEAT,
+ CURRENT_HVAC_IDLE,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
- HVAC_MODE_OFF,
- PRESET_NONE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- ATTR_TEMPERATURE,
- TEMP_CELSIUS,
- TEMP_FAHRENHEIT,
-)
-import homeassistant.helpers.config_validation as cv
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.helpers import event as event_helper
from homeassistant.util import Throttle
-from . import DOMAIN
+from .const import (
+ DOMAIN,
+ MANUFACTURER,
+ NUHEAT_API_STATE_SHIFT_DELAY,
+ NUHEAT_DATETIME_FORMAT,
+ NUHEAT_KEY_HOLD_SET_POINT_DATE_TIME,
+ NUHEAT_KEY_SCHEDULE_MODE,
+ NUHEAT_KEY_SET_POINT_TEMP,
+ TEMP_HOLD_TIME_SEC,
+)
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
-# Hold modes
-MODE_AUTO = HVAC_MODE_AUTO # Run device schedule
-MODE_HOLD_TEMPERATURE = "temperature"
-MODE_TEMPORARY_HOLD = "temporary_temperature"
+# The device does not have an off function.
+# To turn it off set to min_temp and PRESET_PERMANENT_HOLD
+OPERATION_LIST = [HVAC_MODE_AUTO, HVAC_MODE_HEAT]
-OPERATION_LIST = [HVAC_MODE_HEAT, HVAC_MODE_OFF]
+PRESET_RUN = "Run Schedule"
+PRESET_TEMPORARY_HOLD = "Temporary Hold"
+PRESET_PERMANENT_HOLD = "Permanent Hold"
-SCHEDULE_HOLD = 3
-SCHEDULE_RUN = 1
-SCHEDULE_TEMPORARY_HOLD = 2
+PRESET_MODES = [PRESET_RUN, PRESET_TEMPORARY_HOLD, PRESET_PERMANENT_HOLD]
-SERVICE_RESUME_PROGRAM = "resume_program"
+PRESET_MODE_TO_SCHEDULE_MODE_MAP = {
+ PRESET_RUN: SCHEDULE_RUN,
+ PRESET_TEMPORARY_HOLD: SCHEDULE_TEMPORARY_HOLD,
+ PRESET_PERMANENT_HOLD: SCHEDULE_HOLD,
+}
-RESUME_PROGRAM_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
+SCHEDULE_MODE_TO_PRESET_MODE_MAP = {
+ value: key for key, value in PRESET_MODE_TO_SCHEDULE_MODE_MAP.items()
+}
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the NuHeat thermostat(s)."""
- if discovery_info is None:
- return
+ api, serial_number = hass.data[DOMAIN][config_entry.entry_id]
temperature_unit = hass.config.units.temperature_unit
- api, serial_numbers = hass.data[DOMAIN]
- thermostats = [
- NuHeatThermostat(api, serial_number, temperature_unit)
- for serial_number in serial_numbers
- ]
- add_entities(thermostats, True)
+ thermostat = await hass.async_add_executor_job(api.get_thermostat, serial_number)
+ entity = NuHeatThermostat(thermostat, temperature_unit)
- def resume_program_set_service(service):
- """Resume the program on the target thermostats."""
- entity_id = service.data.get(ATTR_ENTITY_ID)
- if entity_id:
- target_thermostats = [
- device for device in thermostats if device.entity_id in entity_id
- ]
- else:
- target_thermostats = thermostats
+ # No longer need a service as set_hvac_mode to auto does this
+ # since climate 1.0 has been implemented
- for thermostat in target_thermostats:
- thermostat.resume_program()
-
- thermostat.schedule_update_ha_state(True)
-
- hass.services.register(
- DOMAIN,
- SERVICE_RESUME_PROGRAM,
- resume_program_set_service,
- schema=RESUME_PROGRAM_SCHEMA,
- )
+ async_add_entities([entity], True)
class NuHeatThermostat(ClimateDevice):
"""Representation of a NuHeat Thermostat."""
- def __init__(self, api, serial_number, temperature_unit):
+ def __init__(self, thermostat, temperature_unit):
"""Initialize the thermostat."""
- self._thermostat = api.get_thermostat(serial_number)
+ self._thermostat = thermostat
self._temperature_unit = temperature_unit
+ self._schedule_mode = None
+ self._target_temperature = None
self._force_update = False
@property
@@ -118,12 +115,33 @@ class NuHeatThermostat(ClimateDevice):
return self._thermostat.fahrenheit
@property
- def hvac_mode(self):
- """Return current operation. ie. heat, idle."""
- if self._thermostat.heating:
- return HVAC_MODE_HEAT
+ def unique_id(self):
+ """Return the unique id."""
+ return self._thermostat.serial_number
- return HVAC_MODE_OFF
+ @property
+ def available(self):
+ """Return the unique id."""
+ return self._thermostat.online
+
+ def set_hvac_mode(self, hvac_mode):
+ """Set the system mode."""
+ if hvac_mode == HVAC_MODE_AUTO:
+ self._set_schedule_mode(SCHEDULE_RUN)
+ elif hvac_mode == HVAC_MODE_HEAT:
+ self._set_schedule_mode(SCHEDULE_HOLD)
+
+ @property
+ def hvac_mode(self):
+ """Return current setting heat or auto."""
+ if self._schedule_mode in (SCHEDULE_TEMPORARY_HOLD, SCHEDULE_HOLD):
+ return HVAC_MODE_HEAT
+ return HVAC_MODE_AUTO
+
+ @property
+ def hvac_action(self):
+ """Return current operation heat or idle."""
+ return CURRENT_HVAC_HEAT if self._thermostat.heating else CURRENT_HVAC_IDLE
@property
def min_temp(self):
@@ -145,69 +163,109 @@ class NuHeatThermostat(ClimateDevice):
def target_temperature(self):
"""Return the currently programmed temperature."""
if self._temperature_unit == "C":
- return self._thermostat.target_celsius
+ return nuheat_to_celsius(self._target_temperature)
- return self._thermostat.target_fahrenheit
+ return nuheat_to_fahrenheit(self._target_temperature)
@property
def preset_mode(self):
"""Return current preset mode."""
- schedule_mode = self._thermostat.schedule_mode
- if schedule_mode == SCHEDULE_RUN:
- return MODE_AUTO
-
- if schedule_mode == SCHEDULE_HOLD:
- return MODE_HOLD_TEMPERATURE
-
- if schedule_mode == SCHEDULE_TEMPORARY_HOLD:
- return MODE_TEMPORARY_HOLD
-
- return MODE_AUTO
+ return SCHEDULE_MODE_TO_PRESET_MODE_MAP.get(self._schedule_mode, PRESET_RUN)
@property
def preset_modes(self):
"""Return available preset modes."""
- return [PRESET_NONE, MODE_HOLD_TEMPERATURE, MODE_TEMPORARY_HOLD]
+ return PRESET_MODES
@property
def hvac_modes(self):
"""Return list of possible operation modes."""
return OPERATION_LIST
- def resume_program(self):
- """Resume the thermostat's programmed schedule."""
- self._thermostat.resume_schedule()
- self._force_update = True
-
def set_preset_mode(self, preset_mode):
"""Update the hold mode of the thermostat."""
- if preset_mode == PRESET_NONE:
- schedule_mode = SCHEDULE_RUN
-
- elif preset_mode == MODE_HOLD_TEMPERATURE:
- schedule_mode = SCHEDULE_HOLD
-
- elif preset_mode == MODE_TEMPORARY_HOLD:
- schedule_mode = SCHEDULE_TEMPORARY_HOLD
+ self._set_schedule_mode(
+ PRESET_MODE_TO_SCHEDULE_MODE_MAP.get(preset_mode, SCHEDULE_RUN)
+ )
+ def _set_schedule_mode(self, schedule_mode):
+ """Set a schedule mode."""
+ self._schedule_mode = schedule_mode
+ # Changing the property here does the actual set
self._thermostat.schedule_mode = schedule_mode
- self._force_update = True
+ self._schedule_update()
def set_temperature(self, **kwargs):
"""Set a new target temperature."""
- temperature = kwargs.get(ATTR_TEMPERATURE)
- if self._temperature_unit == "C":
- self._thermostat.target_celsius = temperature
- else:
- self._thermostat.target_fahrenheit = temperature
-
- _LOGGER.debug(
- "Setting NuHeat thermostat temperature to %s %s",
- temperature,
- self.temperature_unit,
+ self._set_temperature_and_mode(
+ kwargs.get(ATTR_TEMPERATURE), hvac_mode=kwargs.get(ATTR_HVAC_MODE)
)
+ def _set_temperature_and_mode(self, temperature, hvac_mode=None, preset_mode=None):
+ """Set temperature and hvac mode at the same time."""
+ if self._temperature_unit == "C":
+ target_temperature = celsius_to_nuheat(temperature)
+ else:
+ target_temperature = fahrenheit_to_nuheat(temperature)
+
+ # If they set a temperature without changing the mode
+ # to heat, we behave like the device does locally
+ # and set a temp hold.
+ target_schedule_mode = SCHEDULE_TEMPORARY_HOLD
+ if preset_mode:
+ target_schedule_mode = PRESET_MODE_TO_SCHEDULE_MODE_MAP.get(
+ preset_mode, SCHEDULE_RUN
+ )
+ elif self._schedule_mode == SCHEDULE_HOLD or (
+ hvac_mode and hvac_mode == HVAC_MODE_HEAT
+ ):
+ target_schedule_mode = SCHEDULE_HOLD
+
+ _LOGGER.debug(
+ "Setting NuHeat thermostat temperature to %s %s and schedule mode: %s",
+ temperature,
+ self.temperature_unit,
+ target_schedule_mode,
+ )
+
+ target_temperature = max(
+ min(self._thermostat.max_temperature, target_temperature),
+ self._thermostat.min_temperature,
+ )
+
+ request = {
+ NUHEAT_KEY_SET_POINT_TEMP: target_temperature,
+ NUHEAT_KEY_SCHEDULE_MODE: target_schedule_mode,
+ }
+
+ if target_schedule_mode == SCHEDULE_TEMPORARY_HOLD:
+ request[NUHEAT_KEY_HOLD_SET_POINT_DATE_TIME] = datetime.fromtimestamp(
+ time.time() + TEMP_HOLD_TIME_SEC
+ ).strftime(NUHEAT_DATETIME_FORMAT)
+
+ self._thermostat.set_data(request)
+ self._schedule_mode = target_schedule_mode
+ self._target_temperature = target_temperature
+ self._schedule_update()
+
+ def _schedule_update(self):
+ if not self.hass:
+ return
+
+ # Update the new state
+ self.schedule_update_ha_state(False)
+
+ # nuheat has a delay switching state
+ # so we schedule a poll of the api
+ # in the future to make sure the change actually
+ # took effect
+ event_helper.call_later(
+ self.hass, NUHEAT_API_STATE_SHIFT_DELAY, self._schedule_force_refresh
+ )
+
+ def _schedule_force_refresh(self, _):
self._force_update = True
+ self.schedule_update_ha_state(True)
def update(self):
"""Get the latest state from the thermostat."""
@@ -221,3 +279,15 @@ class NuHeatThermostat(ClimateDevice):
def _throttled_update(self, **kwargs):
"""Get the latest state from the thermostat with a throttle."""
self._thermostat.get_data()
+ self._schedule_mode = self._thermostat.schedule_mode
+ self._target_temperature = self._thermostat.target_temperature
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ return {
+ "identifiers": {(DOMAIN, self._thermostat.serial_number)},
+ "name": self._thermostat.room,
+ "model": "nVent Signature",
+ "manufacturer": MANUFACTURER,
+ }
diff --git a/homeassistant/components/nuheat/config_flow.py b/homeassistant/components/nuheat/config_flow.py
new file mode 100644
index 00000000000..4f12f590057
--- /dev/null
+++ b/homeassistant/components/nuheat/config_flow.py
@@ -0,0 +1,104 @@
+"""Config flow for NuHeat integration."""
+import logging
+
+import nuheat
+import requests.exceptions
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .const import CONF_SERIAL_NUMBER
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Required(CONF_SERIAL_NUMBER): str,
+ }
+)
+
+
+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.
+ """
+ api = nuheat.NuHeat(data[CONF_USERNAME], data[CONF_PASSWORD])
+
+ try:
+ await hass.async_add_executor_job(api.authenticate)
+ except requests.exceptions.Timeout:
+ raise CannotConnect
+ except requests.exceptions.HTTPError as ex:
+ if ex.response.status_code > 400 and ex.response.status_code < 500:
+ raise InvalidAuth
+ raise CannotConnect
+ #
+ # The underlying module throws a generic exception on login failure
+ #
+ except Exception: # pylint: disable=broad-except
+ raise InvalidAuth
+
+ try:
+ thermostat = await hass.async_add_executor_job(
+ api.get_thermostat, data[CONF_SERIAL_NUMBER]
+ )
+ except requests.exceptions.HTTPError:
+ raise InvalidThermostat
+
+ return {"title": thermostat.room, "serial_number": thermostat.serial_number}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for NuHeat."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except InvalidThermostat:
+ errors["base"] = "invalid_thermostat"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(info["serial_number"])
+ 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
+ )
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ await self.async_set_unique_id(user_input[CONF_SERIAL_NUMBER])
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_user(user_input)
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid auth."""
+
+
+class InvalidThermostat(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid thermostat."""
diff --git a/homeassistant/components/nuheat/const.py b/homeassistant/components/nuheat/const.py
new file mode 100644
index 00000000000..bd44dcb1711
--- /dev/null
+++ b/homeassistant/components/nuheat/const.py
@@ -0,0 +1,18 @@
+"""Constants for NuHeat thermostats."""
+
+DOMAIN = "nuheat"
+
+PLATFORMS = ["climate"]
+
+CONF_SERIAL_NUMBER = "serial_number"
+
+MANUFACTURER = "NuHeat"
+
+NUHEAT_API_STATE_SHIFT_DELAY = 1
+
+TEMP_HOLD_TIME_SEC = 43200
+
+NUHEAT_KEY_SET_POINT_TEMP = "SetPointTemp"
+NUHEAT_KEY_SCHEDULE_MODE = "ScheduleMode"
+NUHEAT_KEY_HOLD_SET_POINT_DATE_TIME = "HoldSetPointDateTime"
+NUHEAT_DATETIME_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json
index fa011443245..ef78870854c 100644
--- a/homeassistant/components/nuheat/manifest.json
+++ b/homeassistant/components/nuheat/manifest.json
@@ -1,8 +1,9 @@
{
- "domain": "nuheat",
- "name": "NuHeat",
- "documentation": "https://www.home-assistant.io/integrations/nuheat",
- "requirements": ["nuheat==0.3.0"],
- "dependencies": [],
- "codeowners": []
+ "domain": "nuheat",
+ "name": "NuHeat",
+ "documentation": "https://www.home-assistant.io/integrations/nuheat",
+ "requirements": ["nuheat==0.3.0"],
+ "dependencies": [],
+ "codeowners": ["@bdraco"],
+ "config_flow": true
}
diff --git a/homeassistant/components/nuheat/services.yaml b/homeassistant/components/nuheat/services.yaml
deleted file mode 100644
index 6639fcd9898..00000000000
--- a/homeassistant/components/nuheat/services.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-resume_program:
- description: Resume the programmed schedule.
- fields:
- entity_id:
- description: Name(s) of entities to change.
- example: 'climate.kitchen'
diff --git a/homeassistant/components/nuheat/strings.json b/homeassistant/components/nuheat/strings.json
new file mode 100644
index 00000000000..4bfbb8ef62a
--- /dev/null
+++ b/homeassistant/components/nuheat/strings.json
@@ -0,0 +1,25 @@
+{
+ "config" : {
+ "error" : {
+ "unknown" : "Unexpected error",
+ "cannot_connect" : "Failed to connect, please try again",
+ "invalid_auth" : "Invalid authentication",
+ "invalid_thermostat" : "The thermostat serial number is invalid."
+ },
+ "title" : "NuHeat",
+ "abort" : {
+ "already_configured" : "The thermostat is already configured"
+ },
+ "step" : {
+ "user" : {
+ "title" : "Connect to the NuHeat",
+ "description": "You will need to obtain your thermostat’s numeric serial number or ID by logging into https://MyNuHeat.com and selecting your thermostat(s).",
+ "data" : {
+ "username" : "Username",
+ "password" : "Password",
+ "serial_number" : "Serial number of the thermostat."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/nut/.translations/ca.json b/homeassistant/components/nut/.translations/ca.json
new file mode 100644
index 00000000000..01a21920cfa
--- /dev/null
+++ b/homeassistant/components/nut/.translations/ca.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "\u00c0lies",
+ "host": "Amfitri\u00f3",
+ "name": "Nom",
+ "password": "Contrasenya",
+ "port": "Port",
+ "resources": "Recursos",
+ "username": "Nom d'usuari"
+ },
+ "title": "No s'ha pogut connectar amb el servidor NUT"
+ }
+ },
+ "title": "Eines de xarxa UPS (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Recursos"
+ },
+ "description": "Selecciona els recursos del sensor"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/da.json b/homeassistant/components/nut/.translations/da.json
new file mode 100644
index 00000000000..3e66091d851
--- /dev/null
+++ b/homeassistant/components/nut/.translations/da.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/de.json b/homeassistant/components/nut/.translations/de.json
new file mode 100644
index 00000000000..611db3acfd6
--- /dev/null
+++ b/homeassistant/components/nut/.translations/de.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "Alias",
+ "host": "Host",
+ "name": "Name",
+ "password": "Passwort",
+ "port": "Port",
+ "resources": "Ressourcen",
+ "username": "Benutzername"
+ },
+ "title": "Stellen Sie eine Verbindung zum NUT-Server her"
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Ressourcen"
+ },
+ "description": "W\u00e4hlen Sie Sensorressourcen"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/en.json b/homeassistant/components/nut/.translations/en.json
new file mode 100644
index 00000000000..66ea276eca0
--- /dev/null
+++ b/homeassistant/components/nut/.translations/en.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "Alias",
+ "host": "Host",
+ "name": "Name",
+ "password": "Password",
+ "port": "Port",
+ "resources": "Resources",
+ "username": "Username"
+ },
+ "description": "If there are multiple UPSs attached to the NUT server, enter the name UPS to query in the 'Alias' field.",
+ "title": "Connect to the NUT server"
+ }
+ },
+ "title": "Network UPS Tools (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Resources"
+ },
+ "description": "Choose Sensor Resources"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/es.json b/homeassistant/components/nut/.translations/es.json
new file mode 100644
index 00000000000..34944816c81
--- /dev/null
+++ b/homeassistant/components/nut/.translations/es.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "Alias",
+ "host": "Host",
+ "name": "Nombre",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "resources": "Recursos",
+ "username": "Usuario"
+ },
+ "description": "Si hay varios UPS conectados al servidor NUT, introduzca el nombre UPS a buscar en el campo 'Alias'.",
+ "title": "Conectar con el servidor NUT"
+ }
+ },
+ "title": "Herramientas de UPS de red (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Recursos"
+ },
+ "description": "Elegir Recursos del Sensor"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/ko.json b/homeassistant/components/nut/.translations/ko.json
new file mode 100644
index 00000000000..f9fa46b6667
--- /dev/null
+++ b/homeassistant/components/nut/.translations/ko.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \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": {
+ "alias": "\ubcc4\uba85",
+ "host": "\ud638\uc2a4\ud2b8",
+ "name": "\uc774\ub984",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "port": "\ud3ec\ud2b8",
+ "resources": "\ub9ac\uc18c\uc2a4",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "description": "NUT \uc11c\ubc84\uc5d0 UPS \uac00 \uc5ec\ub7ec \uac1c \uc5f0\uacb0\ub418\uc5b4 \uc788\ub294 \uacbd\uc6b0 '\ubcc4\uba85' \uc785\ub825\ub780\uc5d0 \uc870\ud68c\ud560 UPS \uc774\ub984\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "title": "NUT \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "\ub124\ud2b8\uc6cc\ud06c UPS \ub3c4\uad6c (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "\ub9ac\uc18c\uc2a4"
+ },
+ "description": "\uc13c\uc11c \ub9ac\uc18c\uc2a4 \uc120\ud0dd"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/lb.json b/homeassistant/components/nut/.translations/lb.json
new file mode 100644
index 00000000000..7e9ec8ddd97
--- /dev/null
+++ b/homeassistant/components/nut/.translations/lb.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "Alias",
+ "host": "Apparat",
+ "name": "Numm",
+ "password": "Passwuert",
+ "port": "Port",
+ "resources": "Ressourcen",
+ "username": "Benotzernumm"
+ },
+ "description": "Falls m\u00e9i w\u00e9i een UPS mat deem NUT Server verbonnen ass, g\u00e8eff den UPS Numm am 'Alias' Feld un fir ze sichen.",
+ "title": "Mam NUT Server verbannen"
+ }
+ },
+ "title": "Network UPS Tools (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Ressourcen"
+ },
+ "description": "Sensor Ressourcen auswielen"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/no.json b/homeassistant/components/nut/.translations/no.json
new file mode 100644
index 00000000000..31fc3e513c1
--- /dev/null
+++ b/homeassistant/components/nut/.translations/no.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "Alias",
+ "host": "Vert",
+ "name": "Navn",
+ "password": "Passord",
+ "port": "Port",
+ "resources": "Ressurser",
+ "username": "Brukernavn"
+ },
+ "description": "Hvis det er flere UPS-er knyttet til NUT-serveren, angir du navnet UPS for \u00e5 sp\u00f8rre i 'Alias' -feltet.",
+ "title": "Koble til NUT-serveren"
+ }
+ },
+ "title": "Nettverk UPS-verkt\u00f8y (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Ressurser"
+ },
+ "description": "Velg Sensorressurser"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/pl.json b/homeassistant/components/nut/.translations/pl.json
new file mode 100644
index 00000000000..ee9a67b243b
--- /dev/null
+++ b/homeassistant/components/nut/.translations/pl.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.",
+ "unknown": "Niespodziewany b\u0142\u0105d."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "Alias",
+ "host": "Host",
+ "name": "Nazwa",
+ "password": "Has\u0142o",
+ "port": "Port",
+ "resources": "Zasoby",
+ "username": "Nazwa u\u017cytkownika"
+ },
+ "description": "Je\u015bli do serwera NUT pod\u0142\u0105czonych jest wiele zasilaczy UPS, wprowad\u017a w polu Alias nazw\u0119 zasilacza UPS, kt\u00f3rego dotyczy zapytanie.",
+ "title": "Po\u0142\u0105cz z serwerem NUT"
+ }
+ },
+ "title": "Sieciowe narz\u0119dzia UPS (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "Zasoby"
+ },
+ "description": "Wybierz zasoby sensor\u00f3w"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/ru.json b/homeassistant/components/nut/.translations/ru.json
new file mode 100644
index 00000000000..7bc48ec2e3f
--- /dev/null
+++ b/homeassistant/components/nut/.translations/ru.json
@@ -0,0 +1,37 @@
+{
+ "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\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "\u041f\u0441\u0435\u0432\u0434\u043e\u043d\u0438\u043c",
+ "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",
+ "resources": "\u0420\u0435\u0441\u0443\u0440\u0441\u044b",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "description": "\u0415\u0441\u043b\u0438 \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 NUT \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0418\u0411\u041f, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u0418\u0411\u041f \u0434\u043b\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432 \u043f\u043e\u043b\u0435 '\u041f\u0441\u0435\u0432\u0434\u043e\u043d\u0438\u043c'.",
+ "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 NUT"
+ }
+ },
+ "title": "Network UPS Tools (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "\u0420\u0435\u0441\u0443\u0440\u0441\u044b"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0440\u0435\u0441\u0443\u0440\u0441\u044b \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/.translations/zh-Hant.json b/homeassistant/components/nut/.translations/zh-Hant.json
new file mode 100644
index 00000000000..760a66ba1a5
--- /dev/null
+++ b/homeassistant/components/nut/.translations/zh-Hant.json
@@ -0,0 +1,37 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "alias": "\u5225\u540d",
+ "host": "\u4e3b\u6a5f\u7aef",
+ "name": "\u540d\u7a31",
+ "password": "\u5bc6\u78bc",
+ "port": "\u901a\u8a0a\u57e0",
+ "resources": "\u8cc7\u6e90",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "description": "\u5047\u5982 NUT \u4f3a\u670d\u5668\u4e0b\u64c1\u6709\u591a\u7d44 UPS\uff0c\u65bc\u300c\u5225\u540d\u300d\u6b04\u4f4d\u8f38\u5165 UPS \u540d\u7a31\u3002",
+ "title": "\u9023\u7dda\u81f3 NUT \u4f3a\u670d\u5668"
+ }
+ },
+ "title": "Network UPS Tools (NUT)"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "resources": "\u8cc7\u6e90"
+ },
+ "description": "\u9078\u64c7\u50b3\u611f\u5668\u8cc7\u6e90"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py
index e51145c8eaa..a990cdf94b8 100644
--- a/homeassistant/components/nut/__init__.py
+++ b/homeassistant/components/nut/__init__.py
@@ -1 +1,210 @@
"""The nut component."""
+import asyncio
+import logging
+
+from pynut2.nut2 import PyNUTClient, PyNUTError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_ALIAS,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_RESOURCES,
+ CONF_USERNAME,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+
+from .const import (
+ DOMAIN,
+ PLATFORMS,
+ PYNUT_DATA,
+ PYNUT_FIRMWARE,
+ PYNUT_MANUFACTURER,
+ PYNUT_MODEL,
+ PYNUT_STATUS,
+ PYNUT_UNIQUE_ID,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Network UPS Tools (NUT) component."""
+ hass.data.setdefault(DOMAIN, {})
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Network UPS Tools (NUT) from a config entry."""
+
+ config = entry.data
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+
+ alias = config.get(CONF_ALIAS)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ data = PyNUTData(host, port, alias, username, password)
+
+ status = await hass.async_add_executor_job(pynutdata_status, data)
+
+ if not status:
+ _LOGGER.error("NUT Sensor has no data, unable to set up")
+ raise ConfigEntryNotReady
+
+ _LOGGER.debug("NUT Sensors Available: %s", status)
+
+ hass.data[DOMAIN][entry.entry_id] = {
+ PYNUT_DATA: data,
+ PYNUT_STATUS: status,
+ PYNUT_UNIQUE_ID: _unique_id_from_status(status),
+ PYNUT_MANUFACTURER: _manufacturer_from_status(status),
+ PYNUT_MODEL: _model_from_status(status),
+ PYNUT_FIRMWARE: _firmware_from_status(status),
+ }
+
+ entry.add_update_listener(_async_update_listener)
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+def _manufacturer_from_status(status):
+ """Find the best manufacturer value from the status."""
+ return (
+ status.get("device.mfr")
+ or status.get("ups.mfr")
+ or status.get("ups.vendorid")
+ or status.get("driver.version.data")
+ )
+
+
+def _model_from_status(status):
+ """Find the best model value from the status."""
+ return (
+ status.get("device.model")
+ or status.get("ups.model")
+ or status.get("ups.productid")
+ )
+
+
+def _firmware_from_status(status):
+ """Find the best firmware value from the status."""
+ return status.get("ups.firmware") or status.get("ups.firmware.aux")
+
+
+def _serial_from_status(status):
+ """Find the best serialvalue from the status."""
+ serial = status.get("device.serial") or status.get("ups.serial")
+ if serial and serial == "unknown":
+ return None
+ return serial
+
+
+def _unique_id_from_status(status):
+ """Find the best unique id value from the status."""
+ serial = _serial_from_status(status)
+ # We must have a serial for this to be unique
+ if not serial:
+ return None
+
+ manufacturer = _manufacturer_from_status(status)
+ model = _model_from_status(status)
+
+ unique_id_group = []
+ if manufacturer:
+ unique_id_group.append(manufacturer)
+ if model:
+ unique_id_group.append(model)
+ if serial:
+ unique_id_group.append(serial)
+ return "_".join(unique_id_group)
+
+
+def find_resources_in_config_entry(config_entry):
+ """Find the configured resources in the config entry."""
+ if CONF_RESOURCES in config_entry.options:
+ return config_entry.options[CONF_RESOURCES]
+ return config_entry.data[CONF_RESOURCES]
+
+
+def pynutdata_status(data):
+ """Wrap for data update as a callable."""
+ return data.status
+
+
+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 PyNUTData:
+ """Stores the data retrieved from NUT.
+
+ For each entity to use, acts as the single point responsible for fetching
+ updates from the server.
+ """
+
+ def __init__(self, host, port, alias, username, password):
+ """Initialize the data object."""
+
+ self._host = host
+ self._alias = alias
+
+ # Establish client with persistent=False to open/close connection on
+ # each update call. This is more reliable with async.
+ self._client = PyNUTClient(self._host, port, username, password, 5, False)
+ self._status = None
+
+ @property
+ def status(self):
+ """Get latest update if throttle allows. Return status."""
+ self.update()
+ return self._status
+
+ def _get_alias(self):
+ """Get the ups alias from NUT."""
+ try:
+ return next(iter(self._client.list_ups()))
+ except PyNUTError as err:
+ _LOGGER.error("Failure getting NUT ups alias, %s", err)
+ return None
+
+ def _get_status(self):
+ """Get the ups status from NUT."""
+ if self._alias is None:
+ self._alias = self._get_alias()
+
+ try:
+ return self._client.list_vars(self._alias)
+ except (PyNUTError, ConnectionResetError) as err:
+ _LOGGER.debug("Error getting NUT vars for host %s: %s", self._host, err)
+ return None
+
+ def update(self, **kwargs):
+ """Fetch the latest status from NUT."""
+ self._status = self._get_status()
diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py
new file mode 100644
index 00000000000..04889bb3f3f
--- /dev/null
+++ b/homeassistant/components/nut/config_flow.py
@@ -0,0 +1,143 @@
+"""Config flow for Network UPS Tools (NUT) integration."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import (
+ CONF_ALIAS,
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_RESOURCES,
+ CONF_USERNAME,
+)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import PyNUTData, find_resources_in_config_entry, pynutdata_status
+from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, SENSOR_TYPES
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+SENSOR_DICT = {sensor_id: SENSOR_TYPES[sensor_id][0] for sensor_id in SENSOR_TYPES}
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
+ vol.Required(CONF_RESOURCES): cv.multi_select(SENSOR_DICT),
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): str,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Optional(CONF_ALIAS): str,
+ vol.Optional(CONF_USERNAME): str,
+ vol.Optional(CONF_PASSWORD): str,
+ }
+)
+
+
+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.
+ """
+
+ host = data[CONF_HOST]
+ port = data[CONF_PORT]
+ alias = data.get(CONF_ALIAS)
+ username = data.get(CONF_USERNAME)
+ password = data.get(CONF_PASSWORD)
+
+ data = PyNUTData(host, port, alias, username, password)
+
+ status = await hass.async_add_executor_job(pynutdata_status, data)
+
+ if not status:
+ raise CannotConnect
+
+ return {"title": _format_host_port_alias(host, port, alias)}
+
+
+def _format_host_port_alias(host, port, alias):
+ """Format a host, port, and alias so it can be used for comparison or display."""
+ if alias:
+ return f"{alias}@{host}:{port}"
+ return f"{host}:{port}"
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Network UPS Tools (NUT)."""
+
+ 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:
+ if self._host_port_alias_already_configured(
+ user_input[CONF_HOST], user_input[CONF_PORT], user_input.get(CONF_ALIAS)
+ ):
+ return self.async_abort(reason="already_configured")
+ try:
+ info = await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ 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
+ )
+
+ def _host_port_alias_already_configured(self, host, port, alias):
+ """See if we already have a nut entry matching user input configured."""
+ existing_host_port_aliases = {
+ _format_host_port_alias(host, port, alias)
+ for entry in self._async_current_entries()
+ }
+ return _format_host_port_alias(host, port, alias) in existing_host_port_aliases
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ return await self.async_step_user(user_input)
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler(config_entry)
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a option flow for nut."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry):
+ """Initialize options flow."""
+ self.config_entry = config_entry
+
+ async def async_step_init(self, user_input=None):
+ """Handle options flow."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ resources = find_resources_in_config_entry(self.config_entry)
+
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_RESOURCES, default=resources): cv.multi_select(
+ SENSOR_DICT
+ ),
+ }
+ )
+ return self.async_show_form(step_id="init", data_schema=data_schema)
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py
new file mode 100644
index 00000000000..ea164e70b93
--- /dev/null
+++ b/homeassistant/components/nut/const.py
@@ -0,0 +1,125 @@
+"""The nut component."""
+from homeassistant.const import POWER_WATT, TEMP_CELSIUS, TIME_SECONDS, UNIT_PERCENTAGE
+
+DOMAIN = "nut"
+
+PLATFORMS = ["sensor"]
+
+
+DEFAULT_NAME = "NUT UPS"
+DEFAULT_HOST = "localhost"
+DEFAULT_PORT = 3493
+
+KEY_STATUS = "ups.status"
+KEY_STATUS_DISPLAY = "ups.status.display"
+
+PYNUT_DATA = "data"
+PYNUT_STATUS = "status"
+PYNUT_UNIQUE_ID = "unique_id"
+PYNUT_MANUFACTURER = "manufacturer"
+PYNUT_MODEL = "model"
+PYNUT_FIRMWARE = "firmware"
+
+SENSOR_TYPES = {
+ "ups.status.display": ["Status", "", "mdi:information-outline"],
+ "ups.status": ["Status Data", "", "mdi:information-outline"],
+ "ups.alarm": ["Alarms", "", "mdi:alarm"],
+ "ups.temperature": ["UPS Temperature", TEMP_CELSIUS, "mdi:thermometer"],
+ "ups.load": ["Load", UNIT_PERCENTAGE, "mdi:gauge"],
+ "ups.load.high": ["Overload Setting", UNIT_PERCENTAGE, "mdi:gauge"],
+ "ups.id": ["System identifier", "", "mdi:information-outline"],
+ "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"],
+ "ups.contacts": ["External Contacts", "", "mdi:information-outline"],
+ "ups.efficiency": ["Efficiency", UNIT_PERCENTAGE, "mdi:gauge"],
+ "ups.power": ["Current Apparent Power", "VA", "mdi:flash"],
+ "ups.power.nominal": ["Nominal Power", "VA", "mdi:flash"],
+ "ups.realpower": ["Current Real Power", POWER_WATT, "mdi:flash"],
+ "ups.realpower.nominal": ["Nominal Real Power", POWER_WATT, "mdi:flash"],
+ "ups.beeper.status": ["Beeper Status", "", "mdi:information-outline"],
+ "ups.type": ["UPS Type", "", "mdi:information-outline"],
+ "ups.watchdog.status": ["Watchdog Status", "", "mdi:information-outline"],
+ "ups.start.auto": ["Start on AC", "", "mdi:information-outline"],
+ "ups.start.battery": ["Start on Battery", "", "mdi:information-outline"],
+ "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline"],
+ "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline"],
+ "battery.charge": ["Battery Charge", UNIT_PERCENTAGE, "mdi:gauge"],
+ "battery.charge.low": ["Low Battery Setpoint", UNIT_PERCENTAGE, "mdi:gauge"],
+ "battery.charge.restart": [
+ "Minimum Battery to Start",
+ UNIT_PERCENTAGE,
+ "mdi:gauge",
+ ],
+ "battery.charge.warning": [
+ "Warning Battery Setpoint",
+ UNIT_PERCENTAGE,
+ "mdi:gauge",
+ ],
+ "battery.charger.status": ["Charging Status", "", "mdi:information-outline"],
+ "battery.voltage": ["Battery Voltage", "V", "mdi:flash"],
+ "battery.voltage.nominal": ["Nominal Battery Voltage", "V", "mdi:flash"],
+ "battery.voltage.low": ["Low Battery Voltage", "V", "mdi:flash"],
+ "battery.voltage.high": ["High Battery Voltage", "V", "mdi:flash"],
+ "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash"],
+ "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", 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",
+ "",
+ "mdi:information-outline",
+ ],
+ "battery.date": ["Battery Date", "", "mdi:calendar"],
+ "battery.mfr.date": ["Battery Manuf. Date", "", "mdi:calendar"],
+ "battery.packs": ["Number of Batteries", "", "mdi:information-outline"],
+ "battery.packs.bad": ["Number of Bad Batteries", "", "mdi:information-outline"],
+ "battery.type": ["Battery Chemistry", "", "mdi:information-outline"],
+ "input.sensitivity": ["Input Power Sensitivity", "", "mdi:information-outline"],
+ "input.transfer.low": ["Low Voltage Transfer", "V", "mdi:flash"],
+ "input.transfer.high": ["High Voltage Transfer", "V", "mdi:flash"],
+ "input.transfer.reason": ["Voltage Transfer Reason", "", "mdi:information-outline"],
+ "input.voltage": ["Input Voltage", "V", "mdi:flash"],
+ "input.voltage.nominal": ["Nominal Input Voltage", "V", "mdi:flash"],
+ "input.frequency": ["Input Line Frequency", "hz", "mdi:flash"],
+ "input.frequency.nominal": ["Nominal Input Line Frequency", "hz", "mdi:flash"],
+ "input.frequency.status": ["Input Frequency Status", "", "mdi:information-outline"],
+ "output.current": ["Output Current", "A", "mdi:flash"],
+ "output.current.nominal": ["Nominal Output Current", "A", "mdi:flash"],
+ "output.voltage": ["Output Voltage", "V", "mdi:flash"],
+ "output.voltage.nominal": ["Nominal Output Voltage", "V", "mdi:flash"],
+ "output.frequency": ["Output Frequency", "hz", "mdi:flash"],
+ "output.frequency.nominal": ["Nominal Output Frequency", "hz", "mdi:flash"],
+}
+
+STATE_TYPES = {
+ "OL": "Online",
+ "OB": "On Battery",
+ "LB": "Low Battery",
+ "HB": "High Battery",
+ "RB": "Battery Needs Replaced",
+ "CHRG": "Battery Charging",
+ "DISCHRG": "Battery Discharging",
+ "BYPASS": "Bypass Active",
+ "CAL": "Runtime Calibration",
+ "OFF": "Offline",
+ "OVER": "Overloaded",
+ "TRIM": "Trimming Voltage",
+ "BOOST": "Boosting Voltage",
+ "FSD": "Forced Shutdown",
+ "ALARM": "Alarm",
+}
diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json
index a44e70f9aa9..26accb5edb8 100644
--- a/homeassistant/components/nut/manifest.json
+++ b/homeassistant/components/nut/manifest.json
@@ -2,7 +2,10 @@
"domain": "nut",
"name": "Network UPS Tools (NUT)",
"documentation": "https://www.home-assistant.io/integrations/nut",
- "requirements": ["pynut2==2.1.2"],
+ "requirements": [
+ "pynut2==2.1.2"
+ ],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@bdraco"],
+ "config_flow": true
}
diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py
index 15dee84dd9b..a611c8d4268 100644
--- a/homeassistant/components/nut/sensor.py
+++ b/homeassistant/components/nut/sensor.py
@@ -2,10 +2,10 @@
from datetime import timedelta
import logging
-from pynut2.nut2 import PyNUTClient, PyNUTError
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_STATE,
CONF_ALIAS,
@@ -15,141 +15,33 @@ from homeassistant.const import (
CONF_PORT,
CONF_RESOURCES,
CONF_USERNAME,
- POWER_WATT,
STATE_UNKNOWN,
- TEMP_CELSIUS,
- TIME_SECONDS,
- UNIT_PERCENTAGE,
)
-from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
-from homeassistant.util import Throttle
+
+from .const import (
+ DEFAULT_HOST,
+ DEFAULT_NAME,
+ DEFAULT_PORT,
+ DOMAIN,
+ KEY_STATUS,
+ KEY_STATUS_DISPLAY,
+ PYNUT_DATA,
+ PYNUT_FIRMWARE,
+ PYNUT_MANUFACTURER,
+ PYNUT_MODEL,
+ PYNUT_STATUS,
+ PYNUT_UNIQUE_ID,
+ SENSOR_TYPES,
+ STATE_TYPES,
+)
_LOGGER = logging.getLogger(__name__)
-DEFAULT_NAME = "NUT UPS"
-DEFAULT_HOST = "localhost"
-DEFAULT_PORT = 3493
-KEY_STATUS = "ups.status"
-KEY_STATUS_DISPLAY = "ups.status.display"
+SCAN_INTERVAL = timedelta(seconds=60)
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
-
-SENSOR_TYPES = {
- "ups.status.display": ["Status", "", "mdi:information-outline"],
- "ups.status": ["Status Data", "", "mdi:information-outline"],
- "ups.alarm": ["Alarms", "", "mdi:alarm"],
- "ups.time": ["Internal Time", "", "mdi:calendar-clock"],
- "ups.date": ["Internal Date", "", "mdi:calendar"],
- "ups.model": ["Model", "", "mdi:information-outline"],
- "ups.mfr": ["Manufacturer", "", "mdi:information-outline"],
- "ups.mfr.date": ["Manufacture Date", "", "mdi:calendar"],
- "ups.serial": ["Serial Number", "", "mdi:information-outline"],
- "ups.vendorid": ["Vendor ID", "", "mdi:information-outline"],
- "ups.productid": ["Product ID", "", "mdi:information-outline"],
- "ups.firmware": ["Firmware Version", "", "mdi:information-outline"],
- "ups.firmware.aux": ["Firmware Version 2", "", "mdi:information-outline"],
- "ups.temperature": ["UPS Temperature", TEMP_CELSIUS, "mdi:thermometer"],
- "ups.load": ["Load", UNIT_PERCENTAGE, "mdi:gauge"],
- "ups.load.high": ["Overload Setting", UNIT_PERCENTAGE, "mdi:gauge"],
- "ups.id": ["System identifier", "", "mdi:information-outline"],
- "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"],
- "ups.contacts": ["External Contacts", "", "mdi:information-outline"],
- "ups.efficiency": ["Efficiency", UNIT_PERCENTAGE, "mdi:gauge"],
- "ups.power": ["Current Apparent Power", "VA", "mdi:flash"],
- "ups.power.nominal": ["Nominal Power", "VA", "mdi:flash"],
- "ups.realpower": ["Current Real Power", POWER_WATT, "mdi:flash"],
- "ups.realpower.nominal": ["Nominal Real Power", POWER_WATT, "mdi:flash"],
- "ups.beeper.status": ["Beeper Status", "", "mdi:information-outline"],
- "ups.type": ["UPS Type", "", "mdi:information-outline"],
- "ups.watchdog.status": ["Watchdog Status", "", "mdi:information-outline"],
- "ups.start.auto": ["Start on AC", "", "mdi:information-outline"],
- "ups.start.battery": ["Start on Battery", "", "mdi:information-outline"],
- "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline"],
- "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline"],
- "battery.charge": ["Battery Charge", UNIT_PERCENTAGE, "mdi:gauge"],
- "battery.charge.low": ["Low Battery Setpoint", UNIT_PERCENTAGE, "mdi:gauge"],
- "battery.charge.restart": [
- "Minimum Battery to Start",
- UNIT_PERCENTAGE,
- "mdi:gauge",
- ],
- "battery.charge.warning": [
- "Warning Battery Setpoint",
- UNIT_PERCENTAGE,
- "mdi:gauge",
- ],
- "battery.charger.status": ["Charging Status", "", "mdi:information-outline"],
- "battery.voltage": ["Battery Voltage", "V", "mdi:flash"],
- "battery.voltage.nominal": ["Nominal Battery Voltage", "V", "mdi:flash"],
- "battery.voltage.low": ["Low Battery Voltage", "V", "mdi:flash"],
- "battery.voltage.high": ["High Battery Voltage", "V", "mdi:flash"],
- "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash"],
- "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", 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",
- "",
- "mdi:information-outline",
- ],
- "battery.date": ["Battery Date", "", "mdi:calendar"],
- "battery.mfr.date": ["Battery Manuf. Date", "", "mdi:calendar"],
- "battery.packs": ["Number of Batteries", "", "mdi:information-outline"],
- "battery.packs.bad": ["Number of Bad Batteries", "", "mdi:information-outline"],
- "battery.type": ["Battery Chemistry", "", "mdi:information-outline"],
- "input.sensitivity": ["Input Power Sensitivity", "", "mdi:information-outline"],
- "input.transfer.low": ["Low Voltage Transfer", "V", "mdi:flash"],
- "input.transfer.high": ["High Voltage Transfer", "V", "mdi:flash"],
- "input.transfer.reason": ["Voltage Transfer Reason", "", "mdi:information-outline"],
- "input.voltage": ["Input Voltage", "V", "mdi:flash"],
- "input.voltage.nominal": ["Nominal Input Voltage", "V", "mdi:flash"],
- "input.frequency": ["Input Line Frequency", "hz", "mdi:flash"],
- "input.frequency.nominal": ["Nominal Input Line Frequency", "hz", "mdi:flash"],
- "input.frequency.status": ["Input Frequency Status", "", "mdi:information-outline"],
- "output.current": ["Output Current", "A", "mdi:flash"],
- "output.current.nominal": ["Nominal Output Current", "A", "mdi:flash"],
- "output.voltage": ["Output Voltage", "V", "mdi:flash"],
- "output.voltage.nominal": ["Nominal Output Voltage", "V", "mdi:flash"],
- "output.frequency": ["Output Frequency", "hz", "mdi:flash"],
- "output.frequency.nominal": ["Nominal Output Frequency", "hz", "mdi:flash"],
-}
-
-STATE_TYPES = {
- "OL": "Online",
- "OB": "On Battery",
- "LB": "Low Battery",
- "HB": "High Battery",
- "RB": "Battery Needs Replaced",
- "CHRG": "Battery Charging",
- "DISCHRG": "Battery Discharging",
- "BYPASS": "Bypass Active",
- "CAL": "Runtime Calibration",
- "OFF": "Offline",
- "OVER": "Overloaded",
- "TRIM": "Trimming Voltage",
- "BOOST": "Boosting Voltage",
- "FSD": "Forced Shutdown",
- "ALARM": "Alarm",
-}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@@ -165,33 +57,48 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Import the platform into a config entry."""
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+ )
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the NUT sensors."""
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
- alias = config.get(CONF_ALIAS)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- data = PyNUTData(host, port, alias, username, password)
-
- if data.status is None:
- _LOGGER.error("NUT Sensor has no data, unable to set up")
- raise PlatformNotReady
-
- _LOGGER.debug("NUT Sensors Available: %s", data.status)
+ config = config_entry.data
+ pynut_data = hass.data[DOMAIN][config_entry.entry_id]
+ data = pynut_data[PYNUT_DATA]
+ status = pynut_data[PYNUT_STATUS]
+ unique_id = pynut_data[PYNUT_UNIQUE_ID]
+ manufacturer = pynut_data[PYNUT_MANUFACTURER]
+ model = pynut_data[PYNUT_MODEL]
+ firmware = pynut_data[PYNUT_FIRMWARE]
entities = []
- for resource in config[CONF_RESOURCES]:
+ name = config[CONF_NAME]
+ if CONF_RESOURCES in config_entry.options:
+ resources = config_entry.options[CONF_RESOURCES]
+ else:
+ resources = config_entry.data[CONF_RESOURCES]
+
+ for resource in resources:
sensor_type = resource.lower()
# Display status is a special case that falls back to the status value
# of the UPS instead.
- if sensor_type in data.status or (
- sensor_type == KEY_STATUS_DISPLAY and KEY_STATUS in data.status
+ if sensor_type in status or (
+ sensor_type == KEY_STATUS_DISPLAY and KEY_STATUS in status
):
- entities.append(NUTSensor(name, data, sensor_type))
+ entities.append(
+ NUTSensor(
+ name, data, sensor_type, unique_id, manufacturer, model, firmware
+ )
+ )
else:
_LOGGER.warning(
"Sensor type: %s does not appear in the NUT status "
@@ -199,27 +106,52 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
sensor_type,
)
- try:
- data.update(no_throttle=True)
- except data.pynuterror as err:
- _LOGGER.error(
- "Failure while testing NUT status retrieval. Cannot continue setup: %s", err
- )
- raise PlatformNotReady
-
- add_entities(entities, True)
+ async_add_entities(entities, True)
class NUTSensor(Entity):
"""Representation of a sensor entity for NUT status values."""
- def __init__(self, name, data, sensor_type):
+ def __init__(
+ self, name, data, sensor_type, unique_id, manufacturer, model, firmware
+ ):
"""Initialize the sensor."""
self._data = data
- self.type = sensor_type
+ self._type = sensor_type
+ self._manufacturer = manufacturer
+ self._firmware = firmware
+ self._model = model
+ self._device_name = name
self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0])
self._unit = SENSOR_TYPES[sensor_type][1]
self._state = None
+ self._unique_id = unique_id
+ self._display_state = None
+ self._available = False
+
+ @property
+ def device_info(self):
+ """Device info for the ups."""
+ if not self._unique_id:
+ return None
+ device_info = {
+ "identifiers": {(DOMAIN, self._unique_id)},
+ "name": self._device_name,
+ }
+ if self._model:
+ device_info["model"] = self._model
+ if self._manufacturer:
+ device_info["manufacturer"] = self._manufacturer
+ if self._firmware:
+ device_info["sw_version"] = self._firmware
+ return device_info
+
+ @property
+ def unique_id(self):
+ """Sensor Unique id."""
+ if not self._unique_id:
+ return None
+ return f"{self._unique_id}_{self._type}"
@property
def name(self):
@@ -229,7 +161,7 @@ class NUTSensor(Entity):
@property
def icon(self):
"""Icon to use in the frontend, if any."""
- return SENSOR_TYPES[self.type][2]
+ return SENSOR_TYPES[self._type][2]
@property
def state(self):
@@ -241,91 +173,41 @@ class NUTSensor(Entity):
"""Return the unit of measurement of this entity, if any."""
return self._unit
+ @property
+ def available(self):
+ """Return if the device is polling successfully."""
+ return self._available
+
@property
def device_state_attributes(self):
"""Return the sensor attributes."""
- attr = dict()
- attr[ATTR_STATE] = self.display_state()
- return attr
-
- def display_state(self):
- """Return UPS display state."""
- if self._data.status is None:
- return STATE_TYPES["OFF"]
- try:
- return " ".join(
- STATE_TYPES[state] for state in self._data.status[KEY_STATUS].split()
- )
- except KeyError:
- return STATE_UNKNOWN
+ return {ATTR_STATE: self._display_state}
def update(self):
"""Get the latest status and use it to update our sensor state."""
- if self._data.status is None:
- self._state = None
+ status = self._data.status
+
+ if status is None:
+ self._available = False
return
+ self._available = True
+ self._display_state = _format_display_state(status)
# In case of the display status sensor, keep a human-readable form
# as the sensor state.
- if self.type == KEY_STATUS_DISPLAY:
- self._state = self.display_state()
- elif self.type not in self._data.status:
+ if self._type == KEY_STATUS_DISPLAY:
+ self._state = self._display_state
+ elif self._type not in status:
self._state = None
else:
- self._state = self._data.status[self.type]
+ self._state = status[self._type]
-class PyNUTData:
- """Stores the data retrieved from NUT.
-
- For each entity to use, acts as the single point responsible for fetching
- updates from the server.
- """
-
- def __init__(self, host, port, alias, username, password):
- """Initialize the data object."""
-
- self._host = host
- self._port = port
- self._alias = alias
- self._username = username
- self._password = password
-
- self.pynuterror = PyNUTError
- # Establish client with persistent=False to open/close connection on
- # each update call. This is more reliable with async.
- self._client = PyNUTClient(
- self._host, self._port, self._username, self._password, 5, False
- )
-
- self._status = None
-
- @property
- def status(self):
- """Get latest update if throttle allows. Return status."""
- self.update()
- return self._status
-
- def _get_alias(self):
- """Get the ups alias from NUT."""
- try:
- return next(iter(self._client.list_ups()))
- except self.pynuterror as err:
- _LOGGER.error("Failure getting NUT ups alias, %s", err)
- return None
-
- def _get_status(self):
- """Get the ups status from NUT."""
- if self._alias is None:
- self._alias = self._get_alias()
-
- try:
- return self._client.list_vars(self._alias)
- except (self.pynuterror, ConnectionResetError) as err:
- _LOGGER.debug("Error getting NUT vars for host %s: %s", self._host, err)
- return None
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self, **kwargs):
- """Fetch the latest status from NUT."""
- self._status = self._get_status()
+def _format_display_state(status):
+ """Return UPS display state."""
+ if status is None:
+ return STATE_TYPES["OFF"]
+ try:
+ return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split())
+ except KeyError:
+ return STATE_UNKNOWN
diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json
new file mode 100644
index 00000000000..e37a019af78
--- /dev/null
+++ b/homeassistant/components/nut/strings.json
@@ -0,0 +1,38 @@
+{
+ "config": {
+ "title": "Network UPS Tools (NUT)",
+ "step": {
+ "user": {
+ "title": "Connect to the NUT server",
+ "description": "If there are multiple UPSs attached to the NUT server, enter the name UPS to query in the 'Alias' field.",
+ "data": {
+ "name": "Name",
+ "host": "Host",
+ "port": "Port",
+ "alias": "Alias",
+ "username": "Username",
+ "password": "Password",
+ "resources": "Resources"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "Device is already configured"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "Choose Sensor Resources",
+ "data": {
+ "resources": "Resources"
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json
index 3a979922eba..de85a85842a 100644
--- a/homeassistant/components/obihai/manifest.json
+++ b/homeassistant/components/obihai/manifest.json
@@ -2,7 +2,7 @@
"domain": "obihai",
"name": "Obihai",
"documentation": "https://www.home-assistant.io/integrations/obihai",
- "requirements": ["pyobihai==1.2.0"],
+ "requirements": ["pyobihai==1.2.1"],
"dependencies": [],
"codeowners": ["@dshokouhi"]
}
diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py
index 13d09de0542..a81b381f1ed 100644
--- a/homeassistant/components/obihai/sensor.py
+++ b/homeassistant/components/obihai/sensor.py
@@ -59,8 +59,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for key in services:
sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
- for key in line_services:
- sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
+ if line_services is not None:
+ for key in line_services:
+ sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
for key in call_direction:
sensors.append(ObihaiServiceSensors(pyobihai, serial, key))
@@ -136,8 +137,9 @@ class ObihaiServiceSensors(Entity):
services = self._pyobihai.get_line_state()
- if self._service_name in services:
- self._state = services.get(self._service_name)
+ if services is not None:
+ if self._service_name in services:
+ self._state = services.get(self._service_name)
call_direction = self._pyobihai.get_call_direction()
diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py
index 8eac430ac49..fa859861fb7 100644
--- a/homeassistant/components/onboarding/views.py
+++ b/homeassistant/components/onboarding/views.py
@@ -3,6 +3,7 @@ import asyncio
import voluptuous as vol
+from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import callback
@@ -99,7 +100,7 @@ class UserOnboardingView(_BaseOnboardingView):
provider = _async_get_hass_provider(hass)
await provider.async_initialize()
- user = await hass.auth.async_create_user(data["name"])
+ user = await hass.auth.async_create_user(data["name"], [GROUP_ID_ADMIN])
await hass.async_add_executor_job(
provider.data.add_auth, data["username"], data["password"]
)
diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py
index cb518d6c5ee..0c6a3bffa1b 100644
--- a/homeassistant/components/onvif/camera.py
+++ b/homeassistant/components/onvif/camera.py
@@ -10,6 +10,8 @@ from haffmpeg.camera import CameraMjpeg
from haffmpeg.tools import IMAGE_JPEG, ImageFrame
import onvif
from onvif import ONVIFCamera, exceptions
+import requests
+from requests.auth import HTTPDigestAuth
import voluptuous as vol
from zeep.asyncio import AsyncTransport
from zeep.exceptions import Fault
@@ -166,6 +168,7 @@ class ONVIFHassCamera(Camera):
self._profile_index = config.get(CONF_PROFILE)
self._ptz_service = None
self._input = None
+ self._snapshot = None
self.stream_options[CONF_RTSP_TRANSPORT] = config.get(CONF_RTSP_TRANSPORT)
self._mac = None
@@ -198,6 +201,7 @@ class ONVIFHassCamera(Camera):
await self.async_obtain_mac_address()
await self.async_check_date_and_time()
await self.async_obtain_input_uri()
+ await self.async_obtain_snapshot_uri()
self.setup_ptz()
except ClientConnectionError as err:
_LOGGER.warning(
@@ -372,6 +376,52 @@ class ONVIFHassCamera(Camera):
except exceptions.ONVIFError as err:
_LOGGER.error("Couldn't setup camera '%s'. Error: %s", self._name, err)
+ async def async_obtain_snapshot_uri(self):
+ """Set the snapshot uri for the camera."""
+ _LOGGER.debug(
+ "Connecting with ONVIF Camera: %s on port %s", self._host, self._port
+ )
+
+ try:
+ _LOGGER.debug("Retrieving profiles")
+
+ media_service = self._camera.create_media_service()
+
+ profiles = await media_service.GetProfiles()
+
+ _LOGGER.debug("Retrieved '%d' profiles", len(profiles))
+
+ if self._profile_index >= len(profiles):
+ _LOGGER.warning(
+ "ONVIF Camera '%s' doesn't provide profile %d."
+ " Using the last profile.",
+ self._name,
+ self._profile_index,
+ )
+ self._profile_index = -1
+
+ _LOGGER.debug("Using profile index '%d'", self._profile_index)
+
+ _LOGGER.debug("Retrieving snapshot uri")
+
+ # Fix Onvif setup error on Goke GK7102 based IP camera
+ # where we need to recreate media_service #26781
+ media_service = self._camera.create_media_service()
+
+ req = media_service.create_type("GetSnapshotUri")
+ req.ProfileToken = profiles[self._profile_index].token
+
+ snapshot_uri = await media_service.GetSnapshotUri(req)
+ self._snapshot = snapshot_uri.Uri
+
+ _LOGGER.debug(
+ "ONVIF Camera Using the following URL for %s snapshot: %s",
+ self._name,
+ self._snapshot,
+ )
+ except exceptions.ONVIFError as err:
+ _LOGGER.error("Couldn't setup camera '%s'. Error: %s", self._name, err)
+
def setup_ptz(self):
"""Set up PTZ if available."""
_LOGGER.debug("Setting up the ONVIF PTZ service")
@@ -454,16 +504,41 @@ class ONVIFHassCamera(Camera):
async def async_camera_image(self):
"""Return a still image response from the camera."""
-
_LOGGER.debug("Retrieving image from camera '%s'", self._name)
+ image = None
- ffmpeg = ImageFrame(self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop)
+ if self._snapshot is not None:
+ auth = None
+ if self._username and self._password:
+ auth = HTTPDigestAuth(self._username, self._password)
- image = await asyncio.shield(
- ffmpeg.get_image(
- self._input, output_format=IMAGE_JPEG, extra_cmd=self._ffmpeg_arguments
+ def fetch():
+ """Read image from a URL."""
+ try:
+ response = requests.get(self._snapshot, timeout=5, auth=auth)
+ return response.content
+ except requests.exceptions.RequestException as error:
+ _LOGGER.error(
+ "Fetch snapshot image failed from %s, falling back to FFmpeg; %s",
+ self._name,
+ error,
+ )
+
+ image = await self.hass.async_add_job(fetch)
+
+ if image is None:
+ # Don't keep trying the snapshot URL
+ self._snapshot = None
+
+ ffmpeg = ImageFrame(self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop)
+ image = await asyncio.shield(
+ ffmpeg.get_image(
+ self._input,
+ output_format=IMAGE_JPEG,
+ extra_cmd=self._ffmpeg_arguments,
+ )
)
- )
+
return image
async def handle_async_mjpeg_stream(self, request):
diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json
index 40ab3a8a7ed..0ba1ad6c9e3 100644
--- a/homeassistant/components/opencv/manifest.json
+++ b/homeassistant/components/opencv/manifest.json
@@ -2,7 +2,10 @@
"domain": "opencv",
"name": "OpenCV",
"documentation": "https://www.home-assistant.io/integrations/opencv",
- "requirements": ["numpy==1.18.1", "opencv-python-headless==4.1.2.30"],
+ "requirements": [
+ "numpy==1.18.1",
+ "opencv-python-headless==4.2.0.32"
+ ],
"dependencies": [],
"codeowners": []
-}
+}
\ No newline at end of file
diff --git a/homeassistant/components/opentherm_gw/.translations/bg.json b/homeassistant/components/opentherm_gw/.translations/bg.json
index cd109579f64..fe9a611f115 100644
--- a/homeassistant/components/opentherm_gw/.translations/bg.json
+++ b/homeassistant/components/opentherm_gw/.translations/bg.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "\u041f\u044a\u0442 \u0438\u043b\u0438 URL \u0430\u0434\u0440\u0435\u0441",
- "floor_temperature": "\u0422\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 \u043d\u0430 \u043f\u043e\u0434\u0430",
"id": "ID",
- "name": "\u0418\u043c\u0435",
- "precision": "\u041f\u0440\u0435\u0446\u0438\u0437\u043d\u043e\u0441\u0442 \u043d\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430\u0442\u0430 \u043d\u0430 \u043a\u043b\u0438\u043c\u0430\u0442\u0430"
+ "name": "\u0418\u043c\u0435"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/ca.json b/homeassistant/components/opentherm_gw/.translations/ca.json
index 07567149063..4d39dec3662 100644
--- a/homeassistant/components/opentherm_gw/.translations/ca.json
+++ b/homeassistant/components/opentherm_gw/.translations/ca.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Ruta o URL",
- "floor_temperature": "Temperatura del pis",
"id": "ID",
- "name": "Nom",
- "precision": "Precisi\u00f3 de la temperatura"
+ "name": "Nom"
},
"title": "Passarel\u00b7la d'OpenTherm"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/da.json b/homeassistant/components/opentherm_gw/.translations/da.json
index 743adb715f6..bbdec393ab0 100644
--- a/homeassistant/components/opentherm_gw/.translations/da.json
+++ b/homeassistant/components/opentherm_gw/.translations/da.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Sti eller webadresse",
- "floor_temperature": "Gulvklima-temperatur",
"id": "Id",
- "name": "Navn",
- "precision": "Klimatemperatur-pr\u00e6cision"
+ "name": "Navn"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/de.json b/homeassistant/components/opentherm_gw/.translations/de.json
index c29be320d20..92217c51c04 100644
--- a/homeassistant/components/opentherm_gw/.translations/de.json
+++ b/homeassistant/components/opentherm_gw/.translations/de.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Pfad oder URL",
- "floor_temperature": "Boden-Temperatur",
"id": "ID",
- "name": "Name",
- "precision": "Genauigkeit der Temperatur"
+ "name": "Name"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/en.json b/homeassistant/components/opentherm_gw/.translations/en.json
index a7e143505a8..5ba5d232bfc 100644
--- a/homeassistant/components/opentherm_gw/.translations/en.json
+++ b/homeassistant/components/opentherm_gw/.translations/en.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Path or URL",
- "floor_temperature": "Floor climate temperature",
"id": "ID",
- "name": "Name",
- "precision": "Climate temperature precision"
+ "name": "Name"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/es.json b/homeassistant/components/opentherm_gw/.translations/es.json
index bb8a8b20f36..9acfbb4bf67 100644
--- a/homeassistant/components/opentherm_gw/.translations/es.json
+++ b/homeassistant/components/opentherm_gw/.translations/es.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Ruta o URL",
- "floor_temperature": "Temperatura del suelo",
"id": "ID",
- "name": "Nombre",
- "precision": "Precisi\u00f3n de la temperatura clim\u00e1tica"
+ "name": "Nombre"
},
"title": "Gateway OpenTherm"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/fr.json b/homeassistant/components/opentherm_gw/.translations/fr.json
index edde63d62b4..7508612580d 100644
--- a/homeassistant/components/opentherm_gw/.translations/fr.json
+++ b/homeassistant/components/opentherm_gw/.translations/fr.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Chemin ou URL",
- "floor_temperature": "Temp\u00e9rature du sol",
"id": "ID",
- "name": "Nom",
- "precision": "Pr\u00e9cision de la temp\u00e9rature climatique"
+ "name": "Nom"
},
"title": "Passerelle OpenTherm"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/hu.json b/homeassistant/components/opentherm_gw/.translations/hu.json
index 8a0780581fd..1a00570d324 100644
--- a/homeassistant/components/opentherm_gw/.translations/hu.json
+++ b/homeassistant/components/opentherm_gw/.translations/hu.json
@@ -10,10 +10,8 @@
"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"
+ "name": "N\u00e9v"
},
"title": "OpenTherm \u00e1tj\u00e1r\u00f3"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/it.json b/homeassistant/components/opentherm_gw/.translations/it.json
index 73c3a8db970..c1392fdd077 100644
--- a/homeassistant/components/opentherm_gw/.translations/it.json
+++ b/homeassistant/components/opentherm_gw/.translations/it.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Percorso o URL",
- "floor_temperature": "Temperatura climatica del pavimento",
"id": "ID",
- "name": "Nome",
- "precision": "Precisione della temperatura climatica"
+ "name": "Nome"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/ko.json b/homeassistant/components/opentherm_gw/.translations/ko.json
index f370427625d..a51efdb197b 100644
--- a/homeassistant/components/opentherm_gw/.translations/ko.json
+++ b/homeassistant/components/opentherm_gw/.translations/ko.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "\uacbd\ub85c \ub610\ub294 URL",
- "floor_temperature": "\uc2e4\ub0b4\uc628\ub3c4 \uc18c\uc218\uc810 \ubc84\ub9bc",
"id": "ID",
- "name": "\uc774\ub984",
- "precision": "\uc2e4\ub0b4\uc628\ub3c4 \uc815\ubc00\ub3c4"
+ "name": "\uc774\ub984"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/lb.json b/homeassistant/components/opentherm_gw/.translations/lb.json
index 505815dcb4d..3a057ec4e3b 100644
--- a/homeassistant/components/opentherm_gw/.translations/lb.json
+++ b/homeassistant/components/opentherm_gw/.translations/lb.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Pfad oder URL",
- "floor_temperature": "Buedem Klima Temperatur",
"id": "ID",
- "name": "Numm",
- "precision": "Klima Temperatur Prezisioun"
+ "name": "Numm"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/nl.json b/homeassistant/components/opentherm_gw/.translations/nl.json
index dbed3326b4a..331307d3bca 100644
--- a/homeassistant/components/opentherm_gw/.translations/nl.json
+++ b/homeassistant/components/opentherm_gw/.translations/nl.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Pad of URL",
- "floor_temperature": "Vloertemperatuur",
"id": "ID",
- "name": "Naam",
- "precision": "Klimaattemperatuur precisie"
+ "name": "Naam"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/no.json b/homeassistant/components/opentherm_gw/.translations/no.json
index 9eb4444cbf1..6b30b85931d 100644
--- a/homeassistant/components/opentherm_gw/.translations/no.json
+++ b/homeassistant/components/opentherm_gw/.translations/no.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Bane eller URL-adresse",
- "floor_temperature": "Gulv klimatemperatur",
- "id": "ID",
- "name": "Navn",
- "precision": "Klima temperaturpresisjon"
+ "id": "",
+ "name": "Navn"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/pl.json b/homeassistant/components/opentherm_gw/.translations/pl.json
index 88791781e3f..9d945eac27e 100644
--- a/homeassistant/components/opentherm_gw/.translations/pl.json
+++ b/homeassistant/components/opentherm_gw/.translations/pl.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "\u015acie\u017cka lub adres URL",
- "floor_temperature": "Zaokr\u0105glanie warto\u015bci w d\u00f3\u0142",
"id": "Identyfikator",
- "name": "Nazwa",
- "precision": "Precyzja temperatury"
+ "name": "Nazwa"
},
"title": "Bramka OpenTherm"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/ru.json b/homeassistant/components/opentherm_gw/.translations/ru.json
index 0719857a7d3..6ad69e23c23 100644
--- a/homeassistant/components/opentherm_gw/.translations/ru.json
+++ b/homeassistant/components/opentherm_gw/.translations/ru.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "\u041f\u0443\u0442\u044c \u0438\u043b\u0438 URL-\u0430\u0434\u0440\u0435\u0441",
- "floor_temperature": "\u0422\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 \u043f\u043e\u043b\u0430",
"id": "ID",
- "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
- "precision": "\u0422\u043e\u0447\u043d\u043e\u0441\u0442\u044c \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b"
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435"
},
"title": "OpenTherm"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/sl.json b/homeassistant/components/opentherm_gw/.translations/sl.json
index bba6421ed3d..8eabe6839bb 100644
--- a/homeassistant/components/opentherm_gw/.translations/sl.json
+++ b/homeassistant/components/opentherm_gw/.translations/sl.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "Pot ali URL",
- "floor_temperature": "Temperatura nadstropja",
"id": "ID",
- "name": "Ime",
- "precision": "Natan\u010dnost temperature"
+ "name": "Ime"
},
"title": "OpenTherm Prehod"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/sv.json b/homeassistant/components/opentherm_gw/.translations/sv.json
index 89ce4d75674..61562b9562f 100644
--- a/homeassistant/components/opentherm_gw/.translations/sv.json
+++ b/homeassistant/components/opentherm_gw/.translations/sv.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "S\u00f6kv\u00e4g eller URL",
- "floor_temperature": "Golvtemperatur",
"id": "ID",
- "name": "Namn",
- "precision": "Klimatemperaturprecision"
+ "name": "Namn"
},
"title": "OpenTherm Gateway"
}
diff --git a/homeassistant/components/opentherm_gw/.translations/zh-Hant.json b/homeassistant/components/opentherm_gw/.translations/zh-Hant.json
index 0d2842ce767..6c6db948156 100644
--- a/homeassistant/components/opentherm_gw/.translations/zh-Hant.json
+++ b/homeassistant/components/opentherm_gw/.translations/zh-Hant.json
@@ -10,10 +10,8 @@
"init": {
"data": {
"device": "\u8def\u5f91\u6216 URL",
- "floor_temperature": "\u6a13\u5c64\u6eab\u5ea6",
"id": "ID",
- "name": "\u540d\u7a31",
- "precision": "\u6eab\u63a7\u7cbe\u6e96\u5ea6"
+ "name": "\u540d\u7a31"
},
"title": "OpenTherm \u9598\u9053\u5668"
}
diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py
index f130872da5f..008b46d96f2 100644
--- a/homeassistant/components/openuv/__init__.py
+++ b/homeassistant/components/openuv/__init__.py
@@ -16,9 +16,13 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_SENSORS,
)
+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
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.service import verify_domain_control
@@ -104,7 +108,6 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up OpenUV as config entry."""
-
_verify_domain_control = verify_domain_control(hass, DOMAIN)
try:
@@ -230,6 +233,7 @@ class OpenUvEntity(Entity):
def __init__(self, openuv):
"""Initialize."""
+ self._async_unsub_dispatcher_connect = None
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
self._available = True
self._name = None
@@ -249,3 +253,28 @@ class OpenUvEntity(Entity):
def name(self):
"""Return the name of the entity."""
return self._name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+
+ @callback
+ def update():
+ """Update the state."""
+ self.update_from_latest_data()
+ self.async_write_ha_state()
+
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, TOPIC_UPDATE, update
+ )
+
+ self.update_from_latest_data()
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
+ self._async_unsub_dispatcher_connect = None
+
+ def update_from_latest_data(self):
+ """Update the sensor using the latest data."""
+ raise NotImplementedError
diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py
index 6bd3dda13fd..6e403a59b43 100644
--- a/homeassistant/components/openuv/binary_sensor.py
+++ b/homeassistant/components/openuv/binary_sensor.py
@@ -3,14 +3,12 @@ import logging
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.dt import as_local, parse_datetime, utcnow
from . import (
DATA_OPENUV_CLIENT,
DATA_PROTECTION_WINDOW,
DOMAIN,
- TOPIC_UPDATE,
TYPE_PROTECTION_WINDOW,
OpenUvEntity,
)
@@ -75,24 +73,8 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._latitude}_{self._longitude}_{self._sensor_type}"
- 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()
-
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Update the state."""
data = self.openuv.data[DATA_PROTECTION_WINDOW]
diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py
index a375cfa10d7..0d4a8b73a08 100644
--- a/homeassistant/components/openuv/sensor.py
+++ b/homeassistant/components/openuv/sensor.py
@@ -3,14 +3,12 @@ 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
from . import (
DATA_OPENUV_CLIENT,
DATA_UV,
DOMAIN,
- TOPIC_UPDATE,
TYPE_CURRENT_OZONE_LEVEL,
TYPE_CURRENT_UV_INDEX,
TYPE_CURRENT_UV_LEVEL,
@@ -135,24 +133,8 @@ class OpenUvSensor(OpenUvEntity):
"""Return the unit the value is expressed in."""
return self._unit
- 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()
-
- async def async_update(self):
+ @callback
+ def update_from_latest_data(self):
"""Update the state."""
data = self.openuv.data[DATA_UV].get("result")
diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py
index ce32458f640..ac85eff6794 100644
--- a/homeassistant/components/openweathermap/sensor.py
+++ b/homeassistant/components/openweathermap/sensor.py
@@ -163,8 +163,9 @@ class OpenWeatherMapSensor(Entity):
elif self.type == "clouds":
self._state = data.get_clouds()
elif self.type == "rain":
- if data.get_rain():
- self._state = round(data.get_rain()["3h"], 0)
+ rain = data.get_rain()
+ if "3h" in rain:
+ self._state = round(rain["3h"], 0)
self._unit_of_measurement = "mm"
else:
self._state = "not raining"
diff --git a/homeassistant/components/owntracks/.translations/no.json b/homeassistant/components/owntracks/.translations/no.json
index 5838dcad30b..aba620541ec 100644
--- a/homeassistant/components/owntracks/.translations/no.json
+++ b/homeassistant/components/owntracks/.translations/no.json
@@ -12,6 +12,6 @@
"title": "Sett opp OwnTracks"
}
},
- "title": "OwnTracks"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py
index 0311bd4d30d..54e252a97a5 100644
--- a/homeassistant/components/persistent_notification/__init__.py
+++ b/homeassistant/components/persistent_notification/__init__.py
@@ -158,7 +158,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
if entity_id not in persistent_notifications:
return
- hass.states.async_remove(entity_id)
+ hass.states.async_remove(entity_id, call.context)
del persistent_notifications[entity_id]
hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json
index 2f93929d8aa..5d8f8557099 100644
--- a/homeassistant/components/pi_hole/manifest.json
+++ b/homeassistant/components/pi_hole/manifest.json
@@ -2,7 +2,7 @@
"domain": "pi_hole",
"name": "Pi-hole",
"documentation": "https://www.home-assistant.io/integrations/pi_hole",
- "requirements": ["hole==0.5.0"],
+ "requirements": ["hole==0.5.1"],
"dependencies": [],
"codeowners": ["@fabaff", "@johnluetke"]
}
diff --git a/homeassistant/components/plex/.translations/bg.json b/homeassistant/components/plex/.translations/bg.json
index adfdd98ebaf..53d15e1205e 100644
--- a/homeassistant/components/plex/.translations/bg.json
+++ b/homeassistant/components/plex/.translations/bg.json
@@ -4,7 +4,6 @@
"all_configured": "\u0412\u0441\u0438\u0447\u043a\u0438 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u0438 \u0441\u044a\u0440\u0432\u044a\u0440\u0438 \u0432\u0435\u0447\u0435 \u0441\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0438",
"already_configured": "\u0422\u043e\u0437\u0438 Plex \u0441\u044a\u0440\u0432\u044a\u0440 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d",
"already_in_progress": "Plex \u0441\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430",
- "discovery_no_file": "\u041d\u0435 \u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d \u0441\u0442\u0430\u0440 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u043e\u043d\u0435\u043d \u0444\u0430\u0439\u043b",
"invalid_import": "\u0418\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430",
"non-interactive": "\u041d\u0435\u0438\u043d\u0442\u0435\u0440\u0430\u043a\u0442\u0438\u0432\u0435\u043d \u0438\u043c\u043f\u043e\u0440\u0442",
"token_request_timeout": "\u0418\u0437\u0442\u0435\u0447\u0435 \u0432\u0440\u0435\u043c\u0435\u0442\u043e \u0437\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f",
"no_servers": "\u041d\u044f\u043c\u0430 \u0441\u044a\u0440\u0432\u044a\u0440\u0438, \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u0438 \u0441 \u0442\u043e\u0437\u0438 \u0430\u043a\u0430\u0443\u043d\u0442",
- "no_token": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u043e\u043d\u0435\u043d \u043a\u043e\u0434 \u0438\u043b\u0438 \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0440\u044a\u0447\u043d\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430",
"not_found": "Plex \u0441\u044a\u0440\u0432\u044a\u0440\u044a\u0442 \u043d\u0435 \u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "\u0410\u0434\u0440\u0435\u0441",
- "port": "\u041f\u043e\u0440\u0442",
- "ssl": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 SSL",
- "token": "\u041a\u043e\u0434 (\u0430\u043a\u043e \u0441\u0435 \u0438\u0437\u0438\u0441\u043a\u0432\u0430)",
- "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 SSL \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442"
- },
- "title": "Plex \u0441\u044a\u0440\u0432\u044a\u0440"
- },
"select_server": {
"data": {
"server": "\u0421\u044a\u0440\u0432\u044a\u0440"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "\u041f\u0440\u043e\u0434\u044a\u043b\u0436\u0435\u0442\u0435 \u0441 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 plex.tv.",
"title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 Plex \u0441\u044a\u0440\u0432\u044a\u0440"
- },
- "user": {
- "data": {
- "manual_setup": "\u0420\u044a\u0447\u043d\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430",
- "token": "Plex \u043a\u043e\u0434"
- },
- "description": "\u041f\u0440\u043e\u0434\u044a\u043b\u0436\u0435\u0442\u0435 \u0441 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 plex.tv \u0438\u043b\u0438 \u0440\u044a\u0447\u043d\u043e \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0441\u044a\u0440\u0432\u044a\u0440.",
- "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 Plex \u0441\u044a\u0440\u0432\u044a\u0440"
}
},
"title": "Plex"
@@ -53,7 +33,6 @@
"step": {
"plex_mp_settings": {
"data": {
- "show_all_controls": "\u041f\u043e\u043a\u0430\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0432\u0441\u0438\u0447\u043a\u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0438",
"use_episode_art": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u043f\u043b\u0430\u043a\u0430\u0442 \u0437\u0430 \u0435\u043f\u0438\u0437\u043e\u0434\u0430"
},
"description": "\u041e\u043f\u0446\u0438\u0438 \u0437\u0430 Plex Media Players"
diff --git a/homeassistant/components/plex/.translations/ca.json b/homeassistant/components/plex/.translations/ca.json
index d562d62b602..46b7759a04d 100644
--- a/homeassistant/components/plex/.translations/ca.json
+++ b/homeassistant/components/plex/.translations/ca.json
@@ -4,7 +4,6 @@
"all_configured": "Tots els servidors enlla\u00e7ats ja estan configurats",
"already_configured": "Aquest servidor Plex ja est\u00e0 configurat",
"already_in_progress": "S\u2019est\u00e0 configurant Plex",
- "discovery_no_file": "No s'ha trobat cap fitxer de configuraci\u00f3 heretat",
"invalid_import": "La configuraci\u00f3 importada \u00e9s inv\u00e0lida",
"non-interactive": "Importaci\u00f3 no interactiva",
"token_request_timeout": "S'ha acabat el temps d'espera durant l'obtenci\u00f3 del testimoni.",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Ha fallat l'autoritzaci\u00f3",
"no_servers": "No hi ha servidors enlla\u00e7ats amb el compte",
- "no_token": "Proporciona un testimoni d'autenticaci\u00f3 o selecciona configuraci\u00f3 manual",
"not_found": "No s'ha trobat el servidor Plex"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Amfitri\u00f3",
- "port": "Port",
- "ssl": "Utilitza SSL",
- "token": "Testimoni d'autenticaci\u00f3 (si \u00e9s necessari)",
- "verify_ssl": "Verifica el certificat SSL"
- },
- "title": "Servidor Plex"
- },
"select_server": {
"data": {
"server": "Servidor"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Continua l'autoritzaci\u00f3 a plex.tv.",
"title": "Connexi\u00f3 amb el servidor Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Configuraci\u00f3 manual",
- "token": "Testimoni d'autenticaci\u00f3 Plex"
- },
- "description": "Introdueix un testimoni d'autenticaci\u00f3 Plex per configurar-ho autom\u00e0ticament.",
- "title": "Connexi\u00f3 amb el servidor Plex"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Opcions dels reproductors multim\u00e8dia Plex"
diff --git a/homeassistant/components/plex/.translations/cs.json b/homeassistant/components/plex/.translations/cs.json
index e033cd5c514..dc84548da7f 100644
--- a/homeassistant/components/plex/.translations/cs.json
+++ b/homeassistant/components/plex/.translations/cs.json
@@ -1,8 +1,5 @@
{
"config": {
- "abort": {
- "discovery_no_file": "Nebyl nalezen \u017e\u00e1dn\u00fd star\u0161\u00ed konfigura\u010dn\u00ed soubor"
- },
"step": {
"start_website_auth": {
"description": "Pokra\u010dujte v autorizaci na plex.tv.",
diff --git a/homeassistant/components/plex/.translations/da.json b/homeassistant/components/plex/.translations/da.json
index 9b80373727d..7bfdda60b37 100644
--- a/homeassistant/components/plex/.translations/da.json
+++ b/homeassistant/components/plex/.translations/da.json
@@ -4,7 +4,6 @@
"all_configured": "Alle linkede servere er allerede konfigureret",
"already_configured": "Denne Plex-server er allerede konfigureret",
"already_in_progress": "Plex konfigureres",
- "discovery_no_file": "Der blev ikke fundet nogen \u00e6ldre konfigurationsfil",
"invalid_import": "Importeret konfiguration er ugyldig",
"non-interactive": "Ikke-interaktiv import",
"token_request_timeout": "Timeout ved hentning af token",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Godkendelse mislykkedes",
"no_servers": "Ingen servere knyttet til konto",
- "no_token": "Angiv et token eller v\u00e6lg manuel ops\u00e6tning",
"not_found": "Plex-server ikke fundet"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "V\u00e6rt",
- "port": "Port",
- "ssl": "Brug SSL",
- "token": "Token (hvis n\u00f8dvendigt)",
- "verify_ssl": "Bekr\u00e6ft SSL-certifikat"
- },
- "title": "Plex-server"
- },
"select_server": {
"data": {
"server": "Server"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Forts\u00e6t for at godkende p\u00e5 plex.tv.",
"title": "Forbind Plex-server"
- },
- "user": {
- "data": {
- "manual_setup": "Manuel ops\u00e6tning",
- "token": "Plex-token"
- },
- "description": "Indtast et Plex-token til automatisk ops\u00e6tning eller konfigurerer en server manuelt.",
- "title": "Tilslut Plex-server"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Indstillinger for Plex-medieafspillere"
diff --git a/homeassistant/components/plex/.translations/de.json b/homeassistant/components/plex/.translations/de.json
index ea8f4b60de4..c86ffb97d3a 100644
--- a/homeassistant/components/plex/.translations/de.json
+++ b/homeassistant/components/plex/.translations/de.json
@@ -4,7 +4,6 @@
"all_configured": "Alle verkn\u00fcpften Server sind bereits konfiguriert",
"already_configured": "Dieser Plex-Server ist bereits konfiguriert",
"already_in_progress": "Plex wird konfiguriert",
- "discovery_no_file": "Es wurde keine alte Konfigurationsdatei gefunden",
"invalid_import": "Die importierte Konfiguration ist ung\u00fcltig",
"non-interactive": "Nicht interaktiver Import",
"token_request_timeout": "Zeit\u00fcberschreitung beim Erhalt des Tokens",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Autorisation fehlgeschlagen",
"no_servers": "Keine Server sind mit dem Konto verbunden",
- "no_token": "Bereitstellen eines Tokens oder Ausw\u00e4hlen der manuellen Einrichtung",
"not_found": "Plex-Server nicht gefunden"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Port",
- "ssl": "SSL verwenden",
- "token": "Token (falls erforderlich)",
- "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen"
- },
- "title": "Plex Server"
- },
"select_server": {
"data": {
"server": "Server"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Weiter zur Autorisierung unter plex.tv.",
"title": "Plex Server verbinden"
- },
- "user": {
- "data": {
- "manual_setup": "Manuelle Einrichtung",
- "token": "Plex Token"
- },
- "description": "Fahre mit der Autorisierung unter plex.tv fort oder konfiguriere einen Server manuell.",
- "title": "Plex Server verbinden"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Optionen f\u00fcr Plex-Media-Player"
diff --git a/homeassistant/components/plex/.translations/en.json b/homeassistant/components/plex/.translations/en.json
index 4567171af77..b9ca9b355ee 100644
--- a/homeassistant/components/plex/.translations/en.json
+++ b/homeassistant/components/plex/.translations/en.json
@@ -4,7 +4,6 @@
"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 configuration file found",
"invalid_import": "Imported configuration is invalid",
"non-interactive": "Non-interactive import",
"token_request_timeout": "Timed out obtaining token",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Authorization failed",
"no_servers": "No servers linked to account",
- "no_token": "Provide a token or select manual setup",
"not_found": "Plex server not found"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Port",
- "ssl": "Use SSL",
- "token": "Token (if required)",
- "verify_ssl": "Verify SSL certificate"
- },
- "title": "Plex server"
- },
"select_server": {
"data": {
"server": "Server"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Continue to authorize at plex.tv.",
"title": "Connect Plex server"
- },
- "user": {
- "data": {
- "manual_setup": "Manual setup",
- "token": "Plex token"
- },
- "description": "Continue to authorize at plex.tv or manually configure a server.",
- "title": "Connect Plex server"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Options for Plex Media Players"
diff --git a/homeassistant/components/plex/.translations/es-419.json b/homeassistant/components/plex/.translations/es-419.json
index 2fc98a70ead..0546fcd7adf 100644
--- a/homeassistant/components/plex/.translations/es-419.json
+++ b/homeassistant/components/plex/.translations/es-419.json
@@ -11,33 +11,15 @@
"error": {
"faulty_credentials": "Autorizaci\u00f3n fallida",
"no_servers": "No hay servidores vinculados a la cuenta",
- "no_token": "Proporcione un token o seleccione la configuraci\u00f3n manual",
"not_found": "Servidor Plex no encontrado"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Puerto",
- "ssl": "Usar SSL",
- "token": "Token (si es necesario)",
- "verify_ssl": "Verificar el certificado SSL"
- },
- "title": "Servidor Plex"
- },
"select_server": {
"data": {
"server": "Servidor"
},
"description": "M\u00faltiples servidores disponibles, seleccione uno:",
"title": "Seleccionar servidor Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Configuraci\u00f3n manual",
- "token": "Token Plex"
- },
- "title": "Conectar servidor Plex"
}
},
"title": "Plex"
@@ -45,9 +27,6 @@
"options": {
"step": {
"plex_mp_settings": {
- "data": {
- "show_all_controls": "Mostrar todos los controles"
- },
"description": "Opciones para reproductores multimedia Plex"
}
}
diff --git a/homeassistant/components/plex/.translations/es.json b/homeassistant/components/plex/.translations/es.json
index 24127a7332c..3de562db21d 100644
--- a/homeassistant/components/plex/.translations/es.json
+++ b/homeassistant/components/plex/.translations/es.json
@@ -4,7 +4,6 @@
"all_configured": "Todos los servidores vinculados ya configurados",
"already_configured": "Este servidor Plex ya est\u00e1 configurado",
"already_in_progress": "Plex se est\u00e1 configurando",
- "discovery_no_file": "No se ha encontrado ning\u00fan archivo de configuraci\u00f3n antiguo",
"invalid_import": "La configuraci\u00f3n importada no es v\u00e1lida",
"non-interactive": "Importaci\u00f3n no interactiva",
"token_request_timeout": "Tiempo de espera agotado para la obtenci\u00f3n del token",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Error en la autorizaci\u00f3n",
"no_servers": "No hay servidores vinculados a la cuenta",
- "no_token": "Proporcione un token o seleccione la configuraci\u00f3n manual",
"not_found": "No se ha encontrado el servidor Plex"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Puerto",
- "ssl": "Usar SSL",
- "token": "Token (es necesario)",
- "verify_ssl": "Verificar certificado SSL"
- },
- "title": "Servidor Plex"
- },
"select_server": {
"data": {
"server": "Servidor"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Contin\u00fae en plex.tv para autorizar",
"title": "Conectar servidor Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Configuraci\u00f3n manual",
- "token": "Token Plex"
- },
- "description": "Introduzca un token Plex para la configuraci\u00f3n autom\u00e1tica o configure manualmente un servidor.",
- "title": "Conectar servidor Plex"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Opciones para reproductores multimedia Plex"
diff --git a/homeassistant/components/plex/.translations/fr.json b/homeassistant/components/plex/.translations/fr.json
index bcd53d2ffae..354a5eaecf9 100644
--- a/homeassistant/components/plex/.translations/fr.json
+++ b/homeassistant/components/plex/.translations/fr.json
@@ -4,7 +4,6 @@
"all_configured": "Tous les serveurs li\u00e9s sont d\u00e9j\u00e0 configur\u00e9s",
"already_configured": "Ce serveur Plex est d\u00e9j\u00e0 configur\u00e9",
"already_in_progress": "Plex en cours de configuration",
- "discovery_no_file": "Aucun fichier de configuration h\u00e9rit\u00e9 trouv\u00e9",
"invalid_import": "La configuration import\u00e9e est invalide",
"non-interactive": "Importation non interactive",
"token_request_timeout": "D\u00e9lai d'obtention du jeton",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "L'autorisation \u00e0 \u00e9chou\u00e9e",
"no_servers": "Aucun serveur li\u00e9 au compte",
- "no_token": "Fournir un jeton ou s\u00e9lectionner l'installation manuelle",
"not_found": "Serveur Plex introuvable"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "H\u00f4te",
- "port": "Port",
- "ssl": "Utiliser SSL",
- "token": "Jeton (si n\u00e9cessaire)",
- "verify_ssl": "V\u00e9rifier le certificat SSL"
- },
- "title": "Serveur Plex"
- },
"select_server": {
"data": {
"server": "Serveur"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Continuer d'autoriser sur plex.tv.",
"title": "Connecter un serveur Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Installation manuelle",
- "token": "Jeton plex"
- },
- "description": "Continuez pour autoriser plex.tv ou configurez manuellement un serveur.",
- "title": "Connecter un serveur Plex"
}
},
"title": "Plex"
@@ -53,7 +33,8 @@
"step": {
"plex_mp_settings": {
"data": {
- "show_all_controls": "Afficher tous les contr\u00f4les",
+ "ignore_new_shared_users": "Ignorer les nouveaux utilisateurs g\u00e9r\u00e9s/partag\u00e9s",
+ "monitored_users": "Utilisateurs surveill\u00e9s",
"use_episode_art": "Utiliser l'art de l'\u00e9pisode"
},
"description": "Options pour lecteurs multim\u00e9dia Plex"
diff --git a/homeassistant/components/plex/.translations/hu.json b/homeassistant/components/plex/.translations/hu.json
index 4712fb37b55..c59e31a3b95 100644
--- a/homeassistant/components/plex/.translations/hu.json
+++ b/homeassistant/components/plex/.translations/hu.json
@@ -4,7 +4,6 @@
"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",
@@ -16,12 +15,6 @@
"not_found": "A Plex szerver nem tal\u00e1lhat\u00f3"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Kiszolg\u00e1l\u00f3",
- "port": "Port"
- }
- },
"select_server": {
"data": {
"server": "szerver"
@@ -32,13 +25,6 @@
"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"
@@ -47,7 +33,6 @@
"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"
diff --git a/homeassistant/components/plex/.translations/it.json b/homeassistant/components/plex/.translations/it.json
index e5ff4e01dc0..bb48d95bc51 100644
--- a/homeassistant/components/plex/.translations/it.json
+++ b/homeassistant/components/plex/.translations/it.json
@@ -4,7 +4,6 @@
"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": "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",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Autorizzazione non riuscita",
"no_servers": "Nessun server collegato all'account",
- "no_token": "Fornire un token o selezionare la configurazione manuale",
"not_found": "Server Plex non trovato"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Porta",
- "ssl": "Usa SSL",
- "token": "Token (se richiesto)",
- "verify_ssl": "Verificare il certificato SSL"
- },
- "title": "Server Plex"
- },
"select_server": {
"data": {
"server": "Server"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Continuare ad autorizzare su plex.tv.",
"title": "Collegare il server Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Configurazione manuale",
- "token": "Token Plex"
- },
- "description": "Continuare ad autorizzare plex.tv o configurare manualmente un server.",
- "title": "Collegare il server Plex"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Opzioni per i lettori multimediali Plex"
diff --git a/homeassistant/components/plex/.translations/ko.json b/homeassistant/components/plex/.translations/ko.json
index 3292fab0a8e..5cb49836f4d 100644
--- a/homeassistant/components/plex/.translations/ko.json
+++ b/homeassistant/components/plex/.translations/ko.json
@@ -4,7 +4,6 @@
"all_configured": "\uc774\ubbf8 \uad6c\uc131\ub41c \ubaa8\ub4e0 \uc5f0\uacb0\ub41c \uc11c\ubc84",
"already_configured": "\uc774 Plex \uc11c\ubc84\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
"already_in_progress": "Plex \ub97c \uad6c\uc131 \uc911\uc785\ub2c8\ub2e4",
- "discovery_no_file": "\ub808\uac70\uc2dc \uad6c\uc131 \ud30c\uc77c\uc744 \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
"invalid_import": "\uac00\uc838\uc628 \uad6c\uc131 \ub0b4\uc6a9\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
"non-interactive": "\ube44 \ub300\ud654\ud615 \uac00\uc838\uc624\uae30",
"token_request_timeout": "\ud1a0\ud070 \ud68d\ub4dd \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "\uc778\uc99d\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4",
"no_servers": "\uacc4\uc815\uc5d0 \uc5f0\uacb0\ub41c \uc11c\ubc84\uac00 \uc5c6\uc2b5\ub2c8\ub2e4",
- "no_token": "\ud1a0\ud070\uc744 \uc785\ub825\ud558\uac70\ub098 \uc218\ub3d9 \uc124\uc815\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694",
"not_found": "Plex \uc11c\ubc84\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "\ud638\uc2a4\ud2b8",
- "port": "\ud3ec\ud2b8",
- "ssl": "SSL \uc0ac\uc6a9",
- "token": "\ud1a0\ud070 (\ud544\uc694\ud55c \uacbd\uc6b0)",
- "verify_ssl": "SSL \uc778\uc99d\uc11c \uac80\uc99d"
- },
- "title": "Plex \uc11c\ubc84"
- },
"select_server": {
"data": {
"server": "\uc11c\ubc84"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "plex.tv \uc5d0\uc11c \uc778\uc99d\uc744 \uc9c4\ud589\ud574\uc8fc\uc138\uc694.",
"title": "Plex \uc11c\ubc84 \uc5f0\uacb0"
- },
- "user": {
- "data": {
- "manual_setup": "\uc218\ub3d9 \uc124\uc815",
- "token": "Plex \ud1a0\ud070"
- },
- "description": "plex.tv \uc5d0\uc11c \uc778\uc99d\uc744 \uc9c4\ud589\ud558\uac70\ub098 \uc11c\ubc84\ub97c \uc218\ub3d9\uc73c\ub85c \uc124\uc815\ud574\uc8fc\uc138\uc694.",
- "title": "Plex \uc11c\ubc84 \uc5f0\uacb0"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Plex \ubbf8\ub514\uc5b4 \ud50c\ub808\uc774\uc5b4 \uc635\uc158"
diff --git a/homeassistant/components/plex/.translations/lb.json b/homeassistant/components/plex/.translations/lb.json
index 6ed9d372fc1..c8b910b6dc5 100644
--- a/homeassistant/components/plex/.translations/lb.json
+++ b/homeassistant/components/plex/.translations/lb.json
@@ -4,7 +4,6 @@
"all_configured": "All verbonne Server sinn scho konfigur\u00e9iert",
"already_configured": "D\u00ebse Plex Server ass scho konfigur\u00e9iert",
"already_in_progress": "Plex g\u00ebtt konfigur\u00e9iert",
- "discovery_no_file": "Kee Konfiguratioun Fichier am ale Format fonnt.",
"invalid_import": "D\u00e9i importiert Konfiguratioun ass ong\u00eblteg",
"non-interactive": "Net interaktiven Import",
"token_request_timeout": "Z\u00e4it Iwwerschreidung beim kr\u00e9ien vum Jeton",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Feeler beider Autorisatioun",
"no_servers": "Kee Server as mam Kont verbonnen",
- "no_token": "Gitt en Token un oder wielt manuelle Setup",
"not_found": "Kee Plex Server fonnt"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Apparat",
- "port": "Port",
- "ssl": "SSL benotzen",
- "token": "Jeton (falls n\u00e9ideg)",
- "verify_ssl": "SSL Zertifikat iwwerpr\u00e9iwen"
- },
- "title": "Plex Server"
- },
"select_server": {
"data": {
"server": "Server"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Weiderfueren op plex.tv fir d'Autorisatioun.",
"title": "Plex Server verbannen"
- },
- "user": {
- "data": {
- "manual_setup": "Manuell Konfiguratioun",
- "token": "Jeton fir de Plex"
- },
- "description": "Gitt een Jeton fir de Plex un fir eng automatesch Konfiguratioun",
- "title": "Plex Server verbannen"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Optioune fir Plex Medie Spiller"
diff --git a/homeassistant/components/plex/.translations/lv.json b/homeassistant/components/plex/.translations/lv.json
index 23cda3fce4b..39d4b3d7096 100644
--- a/homeassistant/components/plex/.translations/lv.json
+++ b/homeassistant/components/plex/.translations/lv.json
@@ -7,14 +7,6 @@
"not_found": "Plex serveris nav atrasts"
},
"step": {
- "manual_setup": {
- "data": {
- "port": "Ports",
- "ssl": "Izmantot SSL",
- "verify_ssl": "P\u0101rbaud\u012bt SSL sertifik\u0101tu"
- },
- "title": "Plex serveris"
- },
"select_server": {
"data": {
"server": "Serveris"
diff --git a/homeassistant/components/plex/.translations/nl.json b/homeassistant/components/plex/.translations/nl.json
index 515ee8798c7..79ae6506d86 100644
--- a/homeassistant/components/plex/.translations/nl.json
+++ b/homeassistant/components/plex/.translations/nl.json
@@ -4,7 +4,6 @@
"all_configured": "Alle gekoppelde servers zijn al geconfigureerd",
"already_configured": "Deze Plex-server is al geconfigureerd",
"already_in_progress": "Plex wordt geconfigureerd",
- "discovery_no_file": "Geen legacy configuratiebestand gevonden",
"invalid_import": "Ge\u00efmporteerde configuratie is ongeldig",
"non-interactive": "Niet-interactieve import",
"token_request_timeout": "Time-out verkrijgen van token",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Autorisatie mislukt",
"no_servers": "Geen servers gekoppeld aan account",
- "no_token": "Geef een token op of selecteer handmatige installatie",
"not_found": "Plex-server niet gevonden"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Poort",
- "ssl": "Gebruik SSL",
- "token": "Token (indien nodig)",
- "verify_ssl": "Controleer SSL-certificaat"
- },
- "title": "Plex server"
- },
"select_server": {
"data": {
"server": "Server"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Ga verder met autoriseren bij plex.tv.",
"title": "Verbind de Plex server"
- },
- "user": {
- "data": {
- "manual_setup": "Handmatig setup",
- "token": "Plex token"
- },
- "description": "Ga verder met autoriseren bij plex.tv of configureer een server.",
- "title": "Verbind de Plex server"
}
},
"title": "Plex"
@@ -53,7 +33,6 @@
"step": {
"plex_mp_settings": {
"data": {
- "show_all_controls": "Toon alle bedieningselementen",
"use_episode_art": "Gebruik aflevering kunst"
},
"description": "Opties voor Plex-mediaspelers"
diff --git a/homeassistant/components/plex/.translations/no.json b/homeassistant/components/plex/.translations/no.json
index c80ba5f2e06..be76411d8ac 100644
--- a/homeassistant/components/plex/.translations/no.json
+++ b/homeassistant/components/plex/.translations/no.json
@@ -4,7 +4,6 @@
"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 funnet",
"invalid_import": "Den importerte konfigurasjonen er ugyldig",
"non-interactive": "Ikke-interaktiv import",
"token_request_timeout": "Tidsavbrudd ved innhenting av token",
@@ -13,23 +12,12 @@
"error": {
"faulty_credentials": "Autorisasjonen mislyktes",
"no_servers": "Ingen servere koblet til kontoen",
- "no_token": "Angi et token eller velg manuelt oppsett",
"not_found": "Plex-server ikke funnet"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Vert",
- "port": "Port",
- "ssl": "Bruk SSL",
- "token": "Token (hvis n\u00f8dvendig)",
- "verify_ssl": "Verifisere SSL-sertifikat"
- },
- "title": "Plex-server"
- },
"select_server": {
"data": {
- "server": "Server"
+ "server": ""
},
"description": "Flere servere tilgjengelig, velg en:",
"title": "Velg Plex-server"
@@ -37,17 +25,9 @@
"start_website_auth": {
"description": "Fortsett \u00e5 autorisere p\u00e5 plex.tv.",
"title": "Koble til Plex-server"
- },
- "user": {
- "data": {
- "manual_setup": "Manuelt oppsett",
- "token": "Plex token"
- },
- "description": "Fortsett \u00e5 autorisere p\u00e5 plex.tv eller manuelt konfigurere en server.",
- "title": "Koble til Plex-server"
}
},
- "title": "Plex"
+ "title": ""
},
"options": {
"step": {
@@ -55,7 +35,6 @@
"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"
},
"description": "Alternativer for Plex Media Players"
diff --git a/homeassistant/components/plex/.translations/pl.json b/homeassistant/components/plex/.translations/pl.json
index 6531b552000..8b21562a87e 100644
--- a/homeassistant/components/plex/.translations/pl.json
+++ b/homeassistant/components/plex/.translations/pl.json
@@ -4,7 +4,6 @@
"all_configured": "Wszystkie znalezione serwery s\u0105 ju\u017c skonfigurowane.",
"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",
"non-interactive": "Nieinteraktywny import",
"token_request_timeout": "Przekroczono limit czasu na uzyskanie tokena.",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Autoryzacja nie powiod\u0142a si\u0119",
"no_servers": "Brak serwer\u00f3w po\u0142\u0105czonych z kontem",
- "no_token": "Wprowad\u017a token lub wybierz konfiguracj\u0119 r\u0119czn\u0105",
"not_found": "Nie znaleziono serwera Plex"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Host",
- "port": "Port",
- "ssl": "U\u017cyj SSL",
- "token": "Token (je\u015bli wymagany)",
- "verify_ssl": "Weryfikacja certyfikatu SSL"
- },
- "title": "Serwer Plex"
- },
"select_server": {
"data": {
"server": "Serwer"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Kontynuuj, by dokona\u0107 autoryzacji w plex.tv.",
"title": "Po\u0142\u0105cz z serwerem Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Konfiguracja r\u0119czna",
- "token": "Token Plex"
- },
- "description": "Wprowad\u017a token Plex do automatycznej konfiguracji.",
- "title": "Po\u0142\u0105cz z serwerem Plex"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Opcje dla odtwarzaczy multimedialnych Plex"
diff --git a/homeassistant/components/plex/.translations/pt-BR.json b/homeassistant/components/plex/.translations/pt-BR.json
index be97c7fdcb7..0248fc94857 100644
--- a/homeassistant/components/plex/.translations/pt-BR.json
+++ b/homeassistant/components/plex/.translations/pt-BR.json
@@ -8,7 +8,6 @@
"step": {
"plex_mp_settings": {
"data": {
- "show_all_controls": "Mostrar todos os controles",
"use_episode_art": "Usar arte epis\u00f3dio"
},
"description": "Op\u00e7\u00f5es para Plex Media Players"
diff --git a/homeassistant/components/plex/.translations/ru.json b/homeassistant/components/plex/.translations/ru.json
index 2da10b1e8c4..851a2f16ae1 100644
--- a/homeassistant/components/plex/.translations/ru.json
+++ b/homeassistant/components/plex/.translations/ru.json
@@ -4,7 +4,6 @@
"all_configured": "\u0412\u0441\u0435 \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.",
"already_configured": "\u042d\u0442\u043e\u0442 \u0441\u0435\u0440\u0432\u0435\u0440 Plex \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d.",
"already_in_progress": "\u0412\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430.",
- "discovery_no_file": "\u0421\u0442\u0430\u0440\u044b\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0439 \u0444\u0430\u0439\u043b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d.",
"invalid_import": "\u0418\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0435\u0432\u0435\u0440\u043d\u0430.",
"non-interactive": "\u041d\u0435\u0438\u043d\u0442\u0435\u0440\u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0439 \u0438\u043c\u043f\u043e\u0440\u0442.",
"token_request_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0442\u043e\u043a\u0435\u043d\u0430.",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
"no_servers": "\u041d\u0435\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e.",
- "no_token": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u043e\u043a\u0435\u043d \u0438\u043b\u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0440\u0443\u0447\u043d\u0443\u044e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443.",
"not_found": "\u0421\u0435\u0440\u0432\u0435\u0440 Plex \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d."
},
"step": {
- "manual_setup": {
- "data": {
- "host": "\u0425\u043e\u0441\u0442",
- "port": "\u041f\u043e\u0440\u0442",
- "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL",
- "token": "\u0422\u043e\u043a\u0435\u043d (\u0435\u0441\u043b\u0438 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f)",
- "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL"
- },
- "title": "\u0421\u0435\u0440\u0432\u0435\u0440 Plex"
- },
"select_server": {
"data": {
"server": "\u0421\u0435\u0440\u0432\u0435\u0440"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "\u041f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044e \u043d\u0430 plex.tv.",
"title": "Plex"
- },
- "user": {
- "data": {
- "manual_setup": "\u0420\u0443\u0447\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430",
- "token": "\u0422\u043e\u043a\u0435\u043d"
- },
- "description": "\u041f\u0440\u043e\u0434\u043e\u043b\u0436\u0430\u0439\u0442\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044e \u043d\u0430 plex.tv \u0438\u043b\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 \u0432\u0440\u0443\u0447\u043d\u0443\u044e.",
- "title": "Plex"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b"
diff --git a/homeassistant/components/plex/.translations/sl.json b/homeassistant/components/plex/.translations/sl.json
index 1ff93cff650..20ad2ca0a02 100644
--- a/homeassistant/components/plex/.translations/sl.json
+++ b/homeassistant/components/plex/.translations/sl.json
@@ -4,7 +4,6 @@
"all_configured": "Vsi povezani stre\u017eniki so \u017ee konfigurirani",
"already_configured": "Ta stre\u017enik Plex je \u017ee konfiguriran",
"already_in_progress": "Plex se konfigurira",
- "discovery_no_file": "Podatkovne konfiguracijske datoteke ni bilo mogo\u010de najti",
"invalid_import": "Uvo\u017eena konfiguracija ni veljavna",
"non-interactive": "Neinteraktivni uvoz",
"token_request_timeout": "Potekla \u010dasovna omejitev za pridobitev \u017eetona",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "Avtorizacija ni uspela",
"no_servers": "Ni stre\u017enikov povezanih z ra\u010dunom",
- "no_token": "Vnesite \u017eeton ali izberite ro\u010dno nastavitev",
"not_found": "Plex stre\u017enika ni mogo\u010de najti"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "Gostitelj",
- "port": "Vrata",
- "ssl": "Uporaba SSL",
- "token": "\u017deton (po potrebi)",
- "verify_ssl": "Preverite SSL potrdilo"
- },
- "title": "Plex stre\u017enik"
- },
"select_server": {
"data": {
"server": "Stre\u017enik"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "Nadaljujte z avtorizacijo na plex.tv.",
"title": "Pove\u017eite stre\u017enik Plex"
- },
- "user": {
- "data": {
- "manual_setup": "Ro\u010dna nastavitev",
- "token": "Plex \u017eeton"
- },
- "description": "Nadaljujte z avtorizacijo na plex.tv ali ro\u010dno konfigurirajte stre\u017enik.",
- "title": "Pove\u017eite stre\u017enik Plex"
}
},
"title": "Plex"
@@ -53,7 +33,8 @@
"step": {
"plex_mp_settings": {
"data": {
- "show_all_controls": "Poka\u017ei vse kontrole",
+ "ignore_new_shared_users": "Ignorirajte nove upravljane/deljene uporabnike",
+ "monitored_users": "Nadzorovani uporabniki",
"use_episode_art": "Uporabi naslovno sliko epizode"
},
"description": "Mo\u017enosti za predvajalnike Plex"
diff --git a/homeassistant/components/plex/.translations/sv.json b/homeassistant/components/plex/.translations/sv.json
index 25152e9dc81..42afc3eeaa9 100644
--- a/homeassistant/components/plex/.translations/sv.json
+++ b/homeassistant/components/plex/.translations/sv.json
@@ -4,7 +4,6 @@
"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",
@@ -13,20 +12,9 @@
"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"
@@ -37,14 +25,6 @@
"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"
@@ -53,7 +33,6 @@
"step": {
"plex_mp_settings": {
"data": {
- "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-Hant.json b/homeassistant/components/plex/.translations/zh-Hant.json
index 436333b0a79..6d46b8bc154 100644
--- a/homeassistant/components/plex/.translations/zh-Hant.json
+++ b/homeassistant/components/plex/.translations/zh-Hant.json
@@ -4,7 +4,6 @@
"all_configured": "\u6240\u6709\u7d81\u5b9a\u4f3a\u670d\u5668\u90fd\u5df2\u8a2d\u5b9a\u5b8c\u6210",
"already_configured": "Plex \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
"already_in_progress": "Plex \u5df2\u7d93\u8a2d\u5b9a",
- "discovery_no_file": "\u627e\u4e0d\u5230\u820a\u7248\u8a2d\u5b9a\u6a94\u6848",
"invalid_import": "\u532f\u5165\u4e4b\u8a2d\u5b9a\u7121\u6548",
"non-interactive": "\u7121\u4e92\u52d5\u532f\u5165",
"token_request_timeout": "\u53d6\u5f97\u5bc6\u9470\u903e\u6642",
@@ -13,20 +12,9 @@
"error": {
"faulty_credentials": "\u9a57\u8b49\u5931\u6557",
"no_servers": "\u6b64\u5e33\u865f\u672a\u7d81\u5b9a\u4f3a\u670d\u5668",
- "no_token": "\u63d0\u4f9b\u5bc6\u9470\u6216\u9078\u64c7\u624b\u52d5\u8a2d\u5b9a",
"not_found": "\u627e\u4e0d\u5230 Plex \u4f3a\u670d\u5668"
},
"step": {
- "manual_setup": {
- "data": {
- "host": "\u4e3b\u6a5f\u7aef",
- "port": "\u901a\u8a0a\u57e0",
- "ssl": "\u4f7f\u7528 SSL",
- "token": "\u5bc6\u9470\uff08\u5982\u679c\u9700\u8981\uff09",
- "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49"
- },
- "title": "Plex \u4f3a\u670d\u5668"
- },
"select_server": {
"data": {
"server": "\u4f3a\u670d\u5668"
@@ -37,14 +25,6 @@
"start_website_auth": {
"description": "\u7e7c\u7e8c\u65bc Plex.tv \u9032\u884c\u8a8d\u8b49\u3002",
"title": "\u9023\u7dda\u81f3 Plex \u4f3a\u670d\u5668"
- },
- "user": {
- "data": {
- "manual_setup": "\u624b\u52d5\u8a2d\u5b9a",
- "token": "Plex \u5bc6\u9470"
- },
- "description": "\u7e7c\u7e8c\u65bc Plex.tv \u9032\u884c\u8a8d\u8b49\u6216\u624b\u52d5\u8a2d\u5b9a\u4f3a\u670d\u5668\u3002",
- "title": "\u9023\u7dda\u81f3 Plex \u4f3a\u670d\u5668"
}
},
"title": "Plex"
@@ -55,7 +35,6 @@
"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"
},
"description": "Plex \u64ad\u653e\u5668\u9078\u9805"
diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py
index 9d74ed8cb75..ff36f4f5c32 100644
--- a/homeassistant/components/plex/__init__.py
+++ b/homeassistant/components/plex/__init__.py
@@ -46,6 +46,7 @@ from .const import (
SERVERS,
WEBSOCKETS,
)
+from .errors import ShouldUpdateConfigEntry
from .server import PlexServer
MEDIA_PLAYER_SCHEMA = vol.All(
@@ -129,9 +130,20 @@ async def async_setup_entry(hass, entry):
)
hass.config_entries.async_update_entry(entry, options=options)
- plex_server = PlexServer(hass, server_config, entry.options)
+ plex_server = PlexServer(
+ hass, server_config, entry.data[CONF_SERVER_IDENTIFIER], entry.options
+ )
try:
await hass.async_add_executor_job(plex_server.connect)
+ except ShouldUpdateConfigEntry:
+ new_server_data = {
+ **entry.data[PLEX_SERVER_CONFIG],
+ CONF_URL: plex_server.url_in_use,
+ CONF_SERVER: plex_server.friendly_name,
+ }
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, PLEX_SERVER_CONFIG: new_server_data}
+ )
except requests.exceptions.ConnectionError as error:
_LOGGER.error(
"Plex server (%s) could not be reached: [%s]",
@@ -163,7 +175,7 @@ async def async_setup_entry(hass, entry):
unsub = async_dispatcher_connect(
hass,
PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id),
- plex_server.update_platforms,
+ plex_server.async_update_platforms,
)
hass.data[PLEX_DOMAIN][DISPATCHERS].setdefault(server_id, [])
hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub)
diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py
index d5cb3db3aba..126c6eb313a 100644
--- a/homeassistant/components/plex/const.py
+++ b/homeassistant/components/plex/const.py
@@ -9,6 +9,7 @@ DEFAULT_PORT = 32400
DEFAULT_SSL = False
DEFAULT_VERIFY_SSL = True
+DEBOUNCE_TIMEOUT = 1
DISPATCHERS = "dispatchers"
PLATFORMS = frozenset(["media_player", "sensor"])
PLATFORMS_COMPLETED = "platforms_completed"
@@ -38,3 +39,6 @@ X_PLEX_DEVICE_NAME = "Home Assistant"
X_PLEX_PLATFORM = "Home Assistant"
X_PLEX_PRODUCT = "Home Assistant"
X_PLEX_VERSION = __version__
+
+COMMAND_MEDIA_TYPE_MUSIC = "music"
+COMMAND_MEDIA_TYPE_VIDEO = "video"
diff --git a/homeassistant/components/plex/errors.py b/homeassistant/components/plex/errors.py
index 11c15404f45..534c553d45e 100644
--- a/homeassistant/components/plex/errors.py
+++ b/homeassistant/components/plex/errors.py
@@ -12,3 +12,7 @@ class NoServersFound(PlexException):
class ServerNotSpecified(PlexException):
"""Multiple servers linked to account without choice provided."""
+
+
+class ShouldUpdateConfigEntry(PlexException):
+ """Config entry data is out of date and should be updated."""
diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json
index a106c230ae4..1c89bf2582a 100644
--- a/homeassistant/components/plex/manifest.json
+++ b/homeassistant/components/plex/manifest.json
@@ -3,7 +3,7 @@
"name": "Plex Media Server",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/plex",
- "requirements": ["plexapi==3.3.0", "plexauth==0.0.5", "plexwebsocket==0.0.6"],
+ "requirements": ["plexapi==3.3.0", "plexauth==0.0.5", "plexwebsocket==0.0.7"],
"dependencies": ["http"],
"codeowners": ["@jjlawren"]
}
diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py
index 1be06876baf..e09244739e9 100644
--- a/homeassistant/components/plex/media_player.py
+++ b/homeassistant/components/plex/media_player.py
@@ -27,6 +27,8 @@ from homeassistant.helpers.entity_registry import async_get_registry
from homeassistant.util import dt as dt_util
from .const import (
+ COMMAND_MEDIA_TYPE_MUSIC,
+ COMMAND_MEDIA_TYPE_VIDEO,
COMMON_PLAYERS,
CONF_SERVER_IDENTIFIER,
DISPATCHERS,
@@ -500,7 +502,6 @@ class PlexMediaPlayer(MediaPlayerDevice):
if self.device and "playback" in self._device_protocol_capabilities:
self.device.setVolume(int(volume * 100), self._active_media_plexapi_type)
self._volume_level = volume # store since we can't retrieve
- self.plex_server.update_platforms()
@property
def volume_level(self):
@@ -539,31 +540,26 @@ class PlexMediaPlayer(MediaPlayerDevice):
"""Send play command."""
if self.device and "playback" in self._device_protocol_capabilities:
self.device.play(self._active_media_plexapi_type)
- self.plex_server.update_platforms()
def media_pause(self):
"""Send pause command."""
if self.device and "playback" in self._device_protocol_capabilities:
self.device.pause(self._active_media_plexapi_type)
- self.plex_server.update_platforms()
def media_stop(self):
"""Send stop command."""
if self.device and "playback" in self._device_protocol_capabilities:
self.device.stop(self._active_media_plexapi_type)
- self.plex_server.update_platforms()
def media_next_track(self):
"""Send next track command."""
if self.device and "playback" in self._device_protocol_capabilities:
self.device.skipNext(self._active_media_plexapi_type)
- self.plex_server.update_platforms()
def media_previous_track(self):
"""Send previous track command."""
if self.device and "playback" in self._device_protocol_capabilities:
self.device.skipPrevious(self._active_media_plexapi_type)
- self.plex_server.update_platforms()
def play_media(self, media_type, media_id, **kwargs):
"""Play a piece of media."""
@@ -575,9 +571,11 @@ class PlexMediaPlayer(MediaPlayerDevice):
shuffle = src.get("shuffle", 0)
media = None
+ command_media_type = COMMAND_MEDIA_TYPE_VIDEO
if media_type == "MUSIC":
media = self._get_music_media(library, src)
+ command_media_type = COMMAND_MEDIA_TYPE_MUSIC
elif media_type == "EPISODE":
media = self._get_tv_media(library, src)
elif media_type == "PLAYLIST":
@@ -591,15 +589,13 @@ class PlexMediaPlayer(MediaPlayerDevice):
playqueue = self.plex_server.create_playqueue(media, shuffle=shuffle)
try:
- self.device.playMedia(playqueue)
+ self.device.playMedia(playqueue, type=command_media_type)
except ParseError:
# Temporary workaround for Plexamp / plexapi issue
pass
except requests.exceptions.ConnectTimeout:
_LOGGER.error("Timed out playing on %s", self.name)
- self.plex_server.update_platforms()
-
def _get_music_media(self, library_name, src):
"""Find music media and return a Plex media object."""
artist_name = src["artist_name"]
diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py
index b1e93aec8c0..6fcfd39d192 100644
--- a/homeassistant/components/plex/sensor.py
+++ b/homeassistant/components/plex/sensor.py
@@ -2,14 +2,19 @@
import logging
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_call_later
from .const import (
CONF_SERVER_IDENTIFIER,
DISPATCHERS,
DOMAIN as PLEX_DOMAIN,
NAME_FORMAT,
+ PLEX_UPDATE_PLATFORMS_SIGNAL,
PLEX_UPDATE_SENSOR_SIGNAL,
SERVERS,
)
@@ -55,11 +60,71 @@ class PlexSensor(Entity):
)
self.hass.data[PLEX_DOMAIN][DISPATCHERS][server_id].append(unsub)
- @callback
- def async_refresh_sensor(self, sessions):
+ async def async_refresh_sensor(self, sessions):
"""Set instance object and trigger an entity state update."""
+ _LOGGER.debug("Refreshing sensor [%s]", self.unique_id)
+
self.sessions = sessions
- self.async_schedule_update_ha_state(True)
+ update_failed = False
+
+ @callback
+ def update_plex(_):
+ async_dispatcher_send(
+ self.hass,
+ PLEX_UPDATE_PLATFORMS_SIGNAL.format(self._server.machine_identifier),
+ )
+
+ now_playing = []
+ for sess in self.sessions:
+ if sess.TYPE == "photo":
+ _LOGGER.debug("Photo session detected, skipping: %s", sess)
+ continue
+ if not sess.usernames:
+ _LOGGER.debug(
+ "Session temporarily incomplete, will try again: %s", sess
+ )
+ update_failed = True
+ continue
+ user = sess.usernames[0]
+ device = sess.players[0].title
+ now_playing_user = f"{user} - {device}"
+ now_playing_title = ""
+
+ if sess.TYPE in ["clip", "episode"]:
+ # example:
+ # "Supernatural (2005) - s01e13 - Route 666"
+ season_title = sess.grandparentTitle
+ show = await self.hass.async_add_executor_job(sess.show)
+ if show.year is not None:
+ season_title += f" ({show.year!s})"
+ season_episode = sess.seasonEpisode
+ episode_title = sess.title
+ now_playing_title = (
+ f"{season_title} - {season_episode} - {episode_title}"
+ )
+ elif sess.TYPE == "track":
+ # example:
+ # "Billy Talent - Afraid of Heights - Afraid of Heights"
+ track_artist = sess.grandparentTitle
+ track_album = sess.parentTitle
+ track_title = sess.title
+ now_playing_title = f"{track_artist} - {track_album} - {track_title}"
+ else:
+ # example:
+ # "picture_of_last_summer_camp (2015)"
+ # "The Incredible Hulk (2008)"
+ now_playing_title = sess.title
+ if sess.year is not None:
+ now_playing_title += f" ({sess.year})"
+
+ now_playing.append((now_playing_user, now_playing_title))
+ self._state = len(self.sessions)
+ self._now_playing = now_playing
+
+ self.async_write_ha_state()
+
+ if update_failed:
+ async_call_later(self.hass, 5, update_plex)
@property
def name(self):
@@ -96,49 +161,6 @@ class PlexSensor(Entity):
"""Return the state attributes."""
return {content[0]: content[1] for content in self._now_playing}
- def update(self):
- """Update method for Plex sensor."""
- _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 in ["clip", "episode"]:
- # example:
- # "Supernatural (2005) - s01e13 - Route 666"
- season_title = sess.grandparentTitle
- if sess.show().year is not None:
- season_title += f" ({sess.show().year!s})"
- season_episode = sess.seasonEpisode
- episode_title = sess.title
- now_playing_title = (
- f"{season_title} - {season_episode} - {episode_title}"
- )
- elif sess.TYPE == "track":
- # example:
- # "Billy Talent - Afraid of Heights - Afraid of Heights"
- track_artist = sess.grandparentTitle
- track_album = sess.parentTitle
- track_title = sess.title
- now_playing_title = f"{track_artist} - {track_album} - {track_title}"
- else:
- # example:
- # "picture_of_last_summer_camp (2015)"
- # "The Incredible Hulk (2008)"
- now_playing_title = sess.title
- if sess.year is not None:
- now_playing_title += f" ({sess.year})"
-
- 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."""
diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py
index 54a248309b6..4134ad4e32b 100644
--- a/homeassistant/components/plex/server.py
+++ b/homeassistant/components/plex/server.py
@@ -1,5 +1,8 @@
"""Shared class to maintain Plex server instances."""
+from functools import partial, wraps
import logging
+import ssl
+from urllib.parse import urlparse
import plexapi.myplex
import plexapi.playqueue
@@ -9,7 +12,9 @@ import requests.exceptions
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL
-from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_call_later
from .const import (
CONF_CLIENT_IDENTIFIER,
@@ -17,6 +22,7 @@ from .const import (
CONF_MONITORED_USERS,
CONF_SERVER,
CONF_USE_EPISODE_ART,
+ DEBOUNCE_TIMEOUT,
DEFAULT_VERIFY_SSL,
PLEX_NEW_MP_SIGNAL,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
@@ -26,7 +32,7 @@ from .const import (
X_PLEX_PRODUCT,
X_PLEX_VERSION,
)
-from .errors import NoServersFound, ServerNotSpecified
+from .errors import NoServersFound, ServerNotSpecified, ShouldUpdateConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -37,12 +43,37 @@ plexapi.X_PLEX_PRODUCT = X_PLEX_PRODUCT
plexapi.X_PLEX_VERSION = X_PLEX_VERSION
+def debounce(func):
+ """Decorate function to debounce callbacks from Plex websocket."""
+
+ unsub = None
+
+ async def call_later_listener(self, _):
+ """Handle call_later callback."""
+ nonlocal unsub
+ unsub = None
+ await func(self)
+
+ @wraps(func)
+ async def wrapper(self):
+ """Schedule async callback."""
+ nonlocal unsub
+ if unsub:
+ _LOGGER.debug("Throttling update of %s", self.friendly_name)
+ unsub() # pylint: disable=not-callable
+ unsub = async_call_later(
+ self.hass, DEBOUNCE_TIMEOUT, partial(call_later_listener, self),
+ )
+
+ return wrapper
+
+
class PlexServer:
"""Manages a single Plex server connection."""
- def __init__(self, hass, server_config, options=None):
+ def __init__(self, hass, server_config, known_server_id=None, options=None):
"""Initialize a Plex server instance."""
- self._hass = hass
+ self.hass = hass
self._plex_server = None
self._known_clients = set()
self._known_idle = set()
@@ -50,6 +81,7 @@ class PlexServer:
self._token = server_config.get(CONF_TOKEN)
self._server_name = server_config.get(CONF_SERVER)
self._verify_ssl = server_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
+ self._server_id = known_server_id
self.options = options
self.server_choice = None
self._accounts = []
@@ -64,6 +96,7 @@ class PlexServer:
def connect(self):
"""Connect to a Plex server directly, obtaining direct URL if necessary."""
+ config_entry_update_needed = False
def _connect_with_token():
account = plexapi.myplex.MyPlexAccount(token=self._token)
@@ -92,8 +125,33 @@ class PlexServer:
self._url, self._token, session
)
+ def _update_plexdirect_hostname():
+ account = plexapi.myplex.MyPlexAccount(token=self._token)
+ matching_server = [
+ x.name
+ for x in account.resources()
+ if x.clientIdentifier == self._server_id
+ ][0]
+ self._plex_server = account.resource(matching_server).connect(timeout=10)
+
if self._url:
- _connect_with_url()
+ try:
+ _connect_with_url()
+ except requests.exceptions.SSLError as error:
+ while error and not isinstance(error, ssl.SSLCertVerificationError):
+ error = error.__context__ # pylint: disable=no-member
+ if isinstance(error, ssl.SSLCertVerificationError):
+ domain = urlparse(self._url).netloc.split(":")[0]
+ if domain.endswith("plex.direct") and error.args[0].startswith(
+ f"hostname '{domain}' doesn't match"
+ ):
+ _LOGGER.warning(
+ "Plex SSL certificate's hostname changed, updating."
+ )
+ _update_plexdirect_hostname()
+ config_entry_update_needed = True
+ else:
+ raise
else:
_connect_with_token()
@@ -102,6 +160,7 @@ class PlexServer:
for account in self._plex_server.systemAccounts()
if account.name
]
+ _LOGGER.debug("Linked accounts: %s", self.accounts)
owner_account = [
account.name
@@ -110,21 +169,31 @@ class PlexServer:
]
if owner_account:
self._owner_username = owner_account[0]
+ _LOGGER.debug("Server owner found: '%s'", self._owner_username)
self._version = self._plex_server.version
- def refresh_entity(self, machine_identifier, device, session):
+ if config_entry_update_needed:
+ raise ShouldUpdateConfigEntry
+
+ @callback
+ def async_refresh_entity(self, machine_identifier, device, session):
"""Forward refresh dispatch to media_player."""
unique_id = f"{self.machine_identifier}:{machine_identifier}"
_LOGGER.debug("Refreshing %s", unique_id)
- dispatcher_send(
- self._hass,
+ async_dispatcher_send(
+ self.hass,
PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(unique_id),
device,
session,
)
- def update_platforms(self):
+ def _fetch_platform_data(self):
+ """Fetch all data from the Plex server in a single method."""
+ return (self._plex_server.clients(), self._plex_server.sessions())
+
+ @debounce
+ async def async_update_platforms(self):
"""Update the platform entities."""
_LOGGER.debug("Updating devices")
@@ -146,13 +215,14 @@ class PlexServer:
monitored_users.add(new_user)
try:
- devices = self._plex_server.clients()
- sessions = self._plex_server.sessions()
- except plexapi.exceptions.BadRequest:
- _LOGGER.exception("Error requesting Plex client data from server")
- return
- except requests.exceptions.RequestException as ex:
- _LOGGER.warning(
+ devices, sessions = await self.hass.async_add_executor_job(
+ self._fetch_platform_data
+ )
+ except (
+ plexapi.exceptions.BadRequest,
+ requests.exceptions.RequestException,
+ ) as ex:
+ _LOGGER.error(
"Could not connect to Plex server: %s (%s)", self.friendly_name, ex
)
return
@@ -171,9 +241,11 @@ class PlexServer:
continue
session_username = session.usernames[0]
for player in session.players:
- if session_username not in monitored_users:
+ if session_username and session_username not in monitored_users:
ignored_clients.add(player.machineIdentifier)
- _LOGGER.debug("Ignoring Plex client owned by %s", session_username)
+ _LOGGER.debug(
+ "Ignoring Plex client owned by '%s'", session_username
+ )
continue
self._known_idle.discard(player.machineIdentifier)
available_clients.setdefault(
@@ -192,7 +264,7 @@ class PlexServer:
if client_id in new_clients:
new_entity_configs.append(client_data)
else:
- self.refresh_entity(
+ self.async_refresh_entity(
client_id, client_data["device"], client_data.get("session")
)
@@ -202,18 +274,18 @@ class PlexServer:
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.async_refresh_entity(client_id, None, None)
self._known_idle.add(client_id)
if new_entity_configs:
- dispatcher_send(
- self._hass,
+ async_dispatcher_send(
+ self.hass,
PLEX_NEW_MP_SIGNAL.format(self.machine_identifier),
new_entity_configs,
)
- dispatcher_send(
- self._hass,
+ async_dispatcher_send(
+ self.hass,
PLEX_UPDATE_SENSOR_SIGNAL.format(self.machine_identifier),
sessions,
)
diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py
index 63fa67f4da5..1ce76d9dc5f 100644
--- a/homeassistant/components/plum_lightpad/light.py
+++ b/homeassistant/components/plum_lightpad/light.py
@@ -74,7 +74,7 @@ class PlumLight(Light):
"""Flag supported features."""
if self._load.dimmable:
return SUPPORT_BRIGHTNESS
- return None
+ return 0
async def async_turn_on(self, **kwargs):
"""Turn the light on."""
@@ -97,7 +97,7 @@ class GlowRing(Light):
self._name = f"{lightpad.friendly_name} Glow Ring"
self._state = lightpad.glow_enabled
- self._brightness = lightpad.glow_intensity * 255.0
+ self._glow_intensity = lightpad.glow_intensity
self._red = lightpad.glow_color["red"]
self._green = lightpad.glow_color["green"]
@@ -112,7 +112,7 @@ class GlowRing(Light):
config = event["changes"]
self._state = config["glowEnabled"]
- self._brightness = config["glowIntensity"] * 255.0
+ self._glow_intensity = config["glowIntensity"]
self._red = config["glowColor"]["red"]
self._green = config["glowColor"]["green"]
@@ -138,12 +138,12 @@ class GlowRing(Light):
@property
def brightness(self) -> int:
"""Return the brightness of this switch between 0..255."""
- return self._brightness
+ return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255)
@property
def glow_intensity(self):
"""Brightness in float form."""
- return self._brightness / 255.0
+ return self._glow_intensity
@property
def is_on(self) -> bool:
@@ -163,7 +163,8 @@ class GlowRing(Light):
async def async_turn_on(self, **kwargs):
"""Turn the light on."""
if ATTR_BRIGHTNESS in kwargs:
- await self._lightpad.set_config({"glowIntensity": kwargs[ATTR_BRIGHTNESS]})
+ brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0
+ await self._lightpad.set_config({"glowIntensity": brightness_pct})
elif ATTR_HS_COLOR in kwargs:
hs_color = kwargs[ATTR_HS_COLOR]
red, green, blue = color_util.color_hs_to_RGB(*hs_color)
@@ -174,6 +175,7 @@ class GlowRing(Light):
async def async_turn_off(self, **kwargs):
"""Turn the light off."""
if ATTR_BRIGHTNESS in kwargs:
- await self._lightpad.set_config({"glowIntensity": kwargs[ATTR_BRIGHTNESS]})
+ brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0
+ await self._lightpad.set_config({"glowIntensity": brightness_pct})
else:
await self._lightpad.set_config({"glowEnabled": False})
diff --git a/homeassistant/components/plum_lightpad/manifest.json b/homeassistant/components/plum_lightpad/manifest.json
index e22f301bf38..1063d4b439e 100644
--- a/homeassistant/components/plum_lightpad/manifest.json
+++ b/homeassistant/components/plum_lightpad/manifest.json
@@ -4,5 +4,5 @@
"documentation": "https://www.home-assistant.io/integrations/plum_lightpad",
"requirements": ["plumlightpad==0.0.11"],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@ColinHarrington"]
}
diff --git a/homeassistant/components/point/.translations/no.json b/homeassistant/components/point/.translations/no.json
index c87c1a702c8..1448b56d848 100644
--- a/homeassistant/components/point/.translations/no.json
+++ b/homeassistant/components/point/.translations/no.json
@@ -27,6 +27,6 @@
"title": "Godkjenningsleverand\u00f8r"
}
},
- "title": "Minut Point"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/ca.json b/homeassistant/components/powerwall/.translations/ca.json
new file mode 100644
index 00000000000..6b375c93ad8
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/ca.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El Powerwall ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Adre\u00e7a IP"
+ },
+ "title": "Connexi\u00f3 amb el Powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/de.json b/homeassistant/components/powerwall/.translations/de.json
new file mode 100644
index 00000000000..1a442e7fbb6
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/de.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Die Powerwall ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP-Adresse"
+ },
+ "title": "Stellen Sie eine Verbindung zur Powerwall her"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/en.json b/homeassistant/components/powerwall/.translations/en.json
new file mode 100644
index 00000000000..583a88e5623
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/en.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "The powerwall is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP Address"
+ },
+ "title": "Connect to the powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/es.json b/homeassistant/components/powerwall/.translations/es.json
new file mode 100644
index 00000000000..f0d0c6dab6c
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/es.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El powerwall ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar, por favor int\u00e9ntelo de nuevo",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Direcci\u00f3n IP"
+ },
+ "title": "Conectarse al powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/fr.json b/homeassistant/components/powerwall/.translations/fr.json
new file mode 100644
index 00000000000..b907b5d429c
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/fr.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le Powerwall est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Adresse IP"
+ },
+ "title": "Connectez-vous au Powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/it.json b/homeassistant/components/powerwall/.translations/it.json
new file mode 100644
index 00000000000..0031ea5a9e2
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/it.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il Powerwall \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Indirizzo IP"
+ },
+ "title": "Connessione al Powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/ko.json b/homeassistant/components/powerwall/.translations/ko.json
new file mode 100644
index 00000000000..d7fcd8bfe76
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/ko.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "powerwall \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP \uc8fc\uc18c"
+ },
+ "title": "powerwall \uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/lb.json b/homeassistant/components/powerwall/.translations/lb.json
new file mode 100644
index 00000000000..c86cf73ba18
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/lb.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Powerwall ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP Adresse"
+ },
+ "title": "Mat der Powerwall verbannen"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/no.json b/homeassistant/components/powerwall/.translations/no.json
new file mode 100644
index 00000000000..63ce7b0da30
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/no.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Powerwall er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP adresse"
+ },
+ "title": "Koble til powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/ru.json b/homeassistant/components/powerwall/.translations/ru.json
new file mode 100644
index 00000000000..4b162ed8c55
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/ru.json
@@ -0,0 +1,20 @@
+{
+ "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, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441"
+ },
+ "title": "Tesla Powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/.translations/zh-Hant.json b/homeassistant/components/powerwall/.translations/zh-Hant.json
new file mode 100644
index 00000000000..b85ce09eff1
--- /dev/null
+++ b/homeassistant/components/powerwall/.translations/zh-Hant.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Powerwall \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "IP \u4f4d\u5740"
+ },
+ "title": "\u9023\u7dda\u81f3 Powerwall"
+ }
+ },
+ "title": "Tesla Powerwall"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py
new file mode 100644
index 00000000000..d5c7a534180
--- /dev/null
+++ b/homeassistant/components/powerwall/__init__.py
@@ -0,0 +1,146 @@
+"""The Tesla Powerwall integration."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import requests
+from tesla_powerwall import (
+ ApiError,
+ MetersResponse,
+ PowerWall,
+ PowerWallUnreachableError,
+)
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_IP_ADDRESS
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import (
+ DOMAIN,
+ POWERWALL_API_CHARGE,
+ POWERWALL_API_DEVICE_TYPE,
+ POWERWALL_API_GRID_STATUS,
+ POWERWALL_API_METERS,
+ POWERWALL_API_SITE_INFO,
+ POWERWALL_API_SITEMASTER,
+ POWERWALL_API_STATUS,
+ POWERWALL_COORDINATOR,
+ POWERWALL_HTTP_SESSION,
+ POWERWALL_OBJECT,
+ UPDATE_INTERVAL,
+)
+
+CONFIG_SCHEMA = vol.Schema(
+ {DOMAIN: vol.Schema({vol.Required(CONF_IP_ADDRESS): cv.string})},
+ extra=vol.ALLOW_EXTRA,
+)
+
+PLATFORMS = ["binary_sensor", "sensor"]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Tesla Powerwall 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,
+ )
+ )
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Tesla Powerwall from a config entry."""
+
+ entry_id = entry.entry_id
+
+ hass.data[DOMAIN].setdefault(entry_id, {})
+ http_session = requests.Session()
+ power_wall = PowerWall(entry.data[CONF_IP_ADDRESS], http_session=http_session)
+ try:
+ powerwall_data = await hass.async_add_executor_job(call_base_info, power_wall)
+ except (PowerWallUnreachableError, ApiError, ConnectionError):
+ http_session.close()
+ raise ConfigEntryNotReady
+
+ async def async_update_data():
+ """Fetch data from API endpoint."""
+ return await hass.async_add_executor_job(_fetch_powerwall_data, power_wall)
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name="Powerwall site",
+ update_method=async_update_data,
+ update_interval=timedelta(seconds=UPDATE_INTERVAL),
+ )
+
+ hass.data[DOMAIN][entry.entry_id] = powerwall_data
+ hass.data[DOMAIN][entry.entry_id].update(
+ {
+ POWERWALL_OBJECT: power_wall,
+ POWERWALL_COORDINATOR: coordinator,
+ POWERWALL_HTTP_SESSION: http_session,
+ }
+ )
+
+ await coordinator.async_refresh()
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+def call_base_info(power_wall):
+ """Wrap powerwall properties to be a callable."""
+ return {
+ POWERWALL_API_SITE_INFO: power_wall.site_info,
+ POWERWALL_API_STATUS: power_wall.status,
+ POWERWALL_API_DEVICE_TYPE: power_wall.device_type,
+ }
+
+
+def _fetch_powerwall_data(power_wall):
+ """Process and update powerwall data."""
+ meters = power_wall.meters
+ return {
+ POWERWALL_API_CHARGE: power_wall.charge,
+ POWERWALL_API_SITEMASTER: power_wall.sitemaster,
+ POWERWALL_API_METERS: {
+ meter: MetersResponse(meters[meter]) for meter in meters
+ },
+ POWERWALL_API_GRID_STATUS: power_wall.grid_status,
+ }
+
+
+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
+ ]
+ )
+ )
+
+ hass.data[DOMAIN][entry.entry_id][POWERWALL_HTTP_SESSION].close()
+
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py
new file mode 100644
index 00000000000..329b26221b8
--- /dev/null
+++ b/homeassistant/components/powerwall/binary_sensor.py
@@ -0,0 +1,135 @@
+"""Support for August sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_CONNECTIVITY,
+ BinarySensorDevice,
+)
+from homeassistant.const import DEVICE_CLASS_POWER
+
+from .const import (
+ ATTR_GRID_CODE,
+ ATTR_NOMINAL_SYSTEM_POWER,
+ ATTR_REGION,
+ DOMAIN,
+ POWERWALL_API_DEVICE_TYPE,
+ POWERWALL_API_GRID_STATUS,
+ POWERWALL_API_SITE_INFO,
+ POWERWALL_API_SITEMASTER,
+ POWERWALL_API_STATUS,
+ POWERWALL_CONNECTED_KEY,
+ POWERWALL_COORDINATOR,
+ POWERWALL_GRID_ONLINE,
+ POWERWALL_RUNNING_KEY,
+ SITE_INFO_GRID_CODE,
+ SITE_INFO_NOMINAL_SYSTEM_POWER_KW,
+ SITE_INFO_REGION,
+)
+from .entity import PowerWallEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the August sensors."""
+ powerwall_data = hass.data[DOMAIN][config_entry.entry_id]
+
+ coordinator = powerwall_data[POWERWALL_COORDINATOR]
+ site_info = powerwall_data[POWERWALL_API_SITE_INFO]
+ device_type = powerwall_data[POWERWALL_API_DEVICE_TYPE]
+ status = powerwall_data[POWERWALL_API_STATUS]
+
+ entities = []
+ for sensor_class in (
+ PowerWallRunningSensor,
+ PowerWallGridStatusSensor,
+ PowerWallConnectedSensor,
+ ):
+ entities.append(sensor_class(coordinator, site_info, status, device_type))
+
+ async_add_entities(entities, True)
+
+
+class PowerWallRunningSensor(PowerWallEntity, BinarySensorDevice):
+ """Representation of an Powerwall running sensor."""
+
+ @property
+ def name(self):
+ """Device Name."""
+ return "Powerwall Status"
+
+ @property
+ def device_class(self):
+ """Device Class."""
+ return DEVICE_CLASS_POWER
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self.base_unique_id}_running"
+
+ @property
+ def is_on(self):
+ """Get the powerwall running state."""
+ return self._coordinator.data[POWERWALL_API_SITEMASTER][POWERWALL_RUNNING_KEY]
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ return {
+ ATTR_REGION: self._site_info[SITE_INFO_REGION],
+ ATTR_GRID_CODE: self._site_info[SITE_INFO_GRID_CODE],
+ ATTR_NOMINAL_SYSTEM_POWER: self._site_info[
+ SITE_INFO_NOMINAL_SYSTEM_POWER_KW
+ ],
+ }
+
+
+class PowerWallConnectedSensor(PowerWallEntity, BinarySensorDevice):
+ """Representation of an Powerwall connected sensor."""
+
+ @property
+ def name(self):
+ """Device Name."""
+ return "Powerwall Connected to Tesla"
+
+ @property
+ def device_class(self):
+ """Device Class."""
+ return DEVICE_CLASS_CONNECTIVITY
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self.base_unique_id}_connected_to_tesla"
+
+ @property
+ def is_on(self):
+ """Get the powerwall connected to tesla state."""
+ return self._coordinator.data[POWERWALL_API_SITEMASTER][POWERWALL_CONNECTED_KEY]
+
+
+class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorDevice):
+ """Representation of an Powerwall grid status sensor."""
+
+ @property
+ def name(self):
+ """Device Name."""
+ return "Grid Status"
+
+ @property
+ def device_class(self):
+ """Device Class."""
+ return DEVICE_CLASS_POWER
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self.base_unique_id}_grid_status"
+
+ @property
+ def is_on(self):
+ """Get the current value in kWh."""
+ return (
+ self._coordinator.data[POWERWALL_API_GRID_STATUS] == POWERWALL_GRID_ONLINE
+ )
diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py
new file mode 100644
index 00000000000..7e1b3eb3fb1
--- /dev/null
+++ b/homeassistant/components/powerwall/config_flow.py
@@ -0,0 +1,76 @@
+"""Config flow for Tesla Powerwall integration."""
+import logging
+
+from tesla_powerwall import ApiError, PowerWall, PowerWallUnreachableError
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_IP_ADDRESS
+
+from .const import DOMAIN # pylint:disable=unused-import
+from .const import POWERWALL_SITE_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str})
+
+
+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.
+ """
+
+ power_wall = PowerWall(data[CONF_IP_ADDRESS])
+
+ try:
+ site_info = await hass.async_add_executor_job(call_site_info, power_wall)
+ except (PowerWallUnreachableError, ApiError, ConnectionError):
+ raise CannotConnect
+
+ # Return info that you want to store in the config entry.
+ return {"title": site_info[POWERWALL_SITE_NAME]}
+
+
+def call_site_info(power_wall):
+ """Wrap site_info to be a callable."""
+ return power_wall.site_info
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Tesla Powerwall."""
+
+ 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 CannotConnect:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ if "base" not in errors:
+ await self.async_set_unique_id(user_input[CONF_IP_ADDRESS])
+ 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
+ )
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ await self.async_set_unique_id(user_input[CONF_IP_ADDRESS])
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_user(user_input)
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py
new file mode 100644
index 00000000000..2e9c3739c48
--- /dev/null
+++ b/homeassistant/components/powerwall/const.py
@@ -0,0 +1,48 @@
+"""Constants for the Tesla Powerwall integration."""
+
+DOMAIN = "powerwall"
+
+POWERWALL_SITE_NAME = "site_name"
+
+POWERWALL_OBJECT = "powerwall"
+POWERWALL_COORDINATOR = "coordinator"
+
+UPDATE_INTERVAL = 60
+
+ATTR_REGION = "region"
+ATTR_GRID_CODE = "grid_code"
+ATTR_FREQUENCY = "frequency"
+ATTR_ENERGY_EXPORTED = "energy_exported"
+ATTR_ENERGY_IMPORTED = "energy_imported"
+ATTR_INSTANT_AVERAGE_VOLTAGE = "instant_average_voltage"
+ATTR_NOMINAL_SYSTEM_POWER = "nominal_system_power_kW"
+
+SITE_INFO_UTILITY = "utility"
+SITE_INFO_GRID_CODE = "grid_code"
+SITE_INFO_NOMINAL_SYSTEM_POWER_KW = "nominal_system_power_kW"
+SITE_INFO_NOMINAL_SYSTEM_ENERGY_KWH = "nominal_system_energy_kWh"
+SITE_INFO_REGION = "region"
+
+DEVICE_TYPE_DEVICE_TYPE = "device_type"
+
+STATUS_VERSION = "version"
+
+POWERWALL_SITE_NAME = "site_name"
+
+POWERWALL_API_METERS = "meters"
+POWERWALL_API_CHARGE = "charge"
+POWERWALL_API_GRID_STATUS = "grid_status"
+POWERWALL_API_SITEMASTER = "sitemaster"
+POWERWALL_API_STATUS = "status"
+POWERWALL_API_DEVICE_TYPE = "device_type"
+POWERWALL_API_SITE_INFO = "site_info"
+
+POWERWALL_HTTP_SESSION = "http_session"
+
+POWERWALL_GRID_ONLINE = "SystemGridConnected"
+POWERWALL_CONNECTED_KEY = "connected_to_tesla"
+POWERWALL_RUNNING_KEY = "running"
+
+
+MODEL = "PowerWall 2"
+MANUFACTURER = "Tesla"
diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py
new file mode 100644
index 00000000000..c09a1aca612
--- /dev/null
+++ b/homeassistant/components/powerwall/entity.py
@@ -0,0 +1,75 @@
+"""The Tesla Powerwall integration base entity."""
+
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ DEVICE_TYPE_DEVICE_TYPE,
+ DOMAIN,
+ MANUFACTURER,
+ MODEL,
+ POWERWALL_SITE_NAME,
+ SITE_INFO_GRID_CODE,
+ SITE_INFO_NOMINAL_SYSTEM_ENERGY_KWH,
+ SITE_INFO_UTILITY,
+ STATUS_VERSION,
+)
+
+
+class PowerWallEntity(Entity):
+ """Base class for powerwall entities."""
+
+ def __init__(self, coordinator, site_info, status, device_type):
+ """Initialize the sensor."""
+ super().__init__()
+ self._coordinator = coordinator
+ self._site_info = site_info
+ self._device_type = device_type.get(DEVICE_TYPE_DEVICE_TYPE)
+ self._version = status.get(STATUS_VERSION)
+ # This group of properties will be unique to to the site
+ unique_group = (
+ site_info[SITE_INFO_UTILITY],
+ site_info[SITE_INFO_GRID_CODE],
+ str(site_info[SITE_INFO_NOMINAL_SYSTEM_ENERGY_KWH]),
+ )
+ self.base_unique_id = "_".join(unique_group)
+
+ @property
+ def device_info(self):
+ """Powerwall device info."""
+ device_info = {
+ "identifiers": {(DOMAIN, self.base_unique_id)},
+ "name": self._site_info[POWERWALL_SITE_NAME],
+ "manufacturer": MANUFACTURER,
+ }
+ model = MODEL
+ if self._device_type:
+ model += f" ({self._device_type})"
+ device_info["model"] = model
+ if self._version:
+ device_info["sw_version"] = self._version
+ return device_info
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._coordinator.last_update_success
+
+ @property
+ def should_poll(self):
+ """Return False, updates are controlled via coordinator."""
+ return False
+
+ async def async_update(self):
+ """Update the entity.
+
+ Only used by the generic entity update service.
+ """
+ await self._coordinator.async_request_refresh()
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ self._coordinator.async_remove_listener(self.async_write_ha_state)
diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json
new file mode 100644
index 00000000000..951ad960e14
--- /dev/null
+++ b/homeassistant/components/powerwall/manifest.json
@@ -0,0 +1,16 @@
+{
+ "domain": "powerwall",
+ "name": "Tesla Powerwall",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/powerwall",
+ "requirements": [
+ "tesla-powerwall==0.1.3"
+ ],
+ "ssdp": [],
+ "zeroconf": [],
+ "homekit": {},
+ "dependencies": [],
+ "codeowners": [
+ "@bdraco"
+ ]
+}
diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py
new file mode 100644
index 00000000000..cf49b36a570
--- /dev/null
+++ b/homeassistant/components/powerwall/sensor.py
@@ -0,0 +1,122 @@
+"""Support for August sensors."""
+import logging
+
+from homeassistant.const import (
+ DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_POWER,
+ ENERGY_KILO_WATT_HOUR,
+ UNIT_PERCENTAGE,
+)
+
+from .const import (
+ ATTR_ENERGY_EXPORTED,
+ ATTR_ENERGY_IMPORTED,
+ ATTR_FREQUENCY,
+ ATTR_INSTANT_AVERAGE_VOLTAGE,
+ DOMAIN,
+ POWERWALL_API_CHARGE,
+ POWERWALL_API_DEVICE_TYPE,
+ POWERWALL_API_METERS,
+ POWERWALL_API_SITE_INFO,
+ POWERWALL_API_STATUS,
+ POWERWALL_COORDINATOR,
+)
+from .entity import PowerWallEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the August sensors."""
+ powerwall_data = hass.data[DOMAIN][config_entry.entry_id]
+ _LOGGER.debug("Powerwall_data: %s", powerwall_data)
+
+ coordinator = powerwall_data[POWERWALL_COORDINATOR]
+ site_info = powerwall_data[POWERWALL_API_SITE_INFO]
+ device_type = powerwall_data[POWERWALL_API_DEVICE_TYPE]
+ status = powerwall_data[POWERWALL_API_STATUS]
+
+ entities = []
+ for meter in coordinator.data[POWERWALL_API_METERS]:
+ entities.append(
+ PowerWallEnergySensor(meter, coordinator, site_info, status, device_type)
+ )
+
+ entities.append(PowerWallChargeSensor(coordinator, site_info, status, device_type))
+
+ async_add_entities(entities, True)
+
+
+class PowerWallChargeSensor(PowerWallEntity):
+ """Representation of an Powerwall charge sensor."""
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return UNIT_PERCENTAGE
+
+ @property
+ def name(self):
+ """Device Name."""
+ return "Powerwall Charge"
+
+ @property
+ def device_class(self):
+ """Device Class."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self.base_unique_id}_charge"
+
+ @property
+ def state(self):
+ """Get the current value in percentage."""
+ return round(self._coordinator.data[POWERWALL_API_CHARGE], 3)
+
+
+class PowerWallEnergySensor(PowerWallEntity):
+ """Representation of an Powerwall Energy sensor."""
+
+ def __init__(self, meter, coordinator, site_info, status, device_type):
+ """Initialize the sensor."""
+ super().__init__(coordinator, site_info, status, device_type)
+ self._meter = meter
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return ENERGY_KILO_WATT_HOUR
+
+ @property
+ def name(self):
+ """Device Name."""
+ return f"Powerwall {self._meter.title()} Now"
+
+ @property
+ def device_class(self):
+ """Device Class."""
+ return DEVICE_CLASS_POWER
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self.base_unique_id}_{self._meter}_instant_power"
+
+ @property
+ def state(self):
+ """Get the current value in kWh."""
+ meter = self._coordinator.data[POWERWALL_API_METERS][self._meter]
+ return round(float(meter.instant_power / 1000), 3)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ meter = self._coordinator.data[POWERWALL_API_METERS][self._meter]
+ return {
+ ATTR_FREQUENCY: meter.frequency,
+ ATTR_ENERGY_EXPORTED: meter.energy_exported,
+ ATTR_ENERGY_IMPORTED: meter.energy_imported,
+ ATTR_INSTANT_AVERAGE_VOLTAGE: meter.instant_average_voltage,
+ }
diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json
new file mode 100644
index 00000000000..92f0fd19464
--- /dev/null
+++ b/homeassistant/components/powerwall/strings.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "title": "Tesla Powerwall",
+ "step": {
+ "user": {
+ "title": "Connect to the powerwall",
+ "data": {
+ "ip_address": "IP Address"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "The powerwall is already configured"
+ }
+ }
+}
diff --git a/homeassistant/components/ps4/.translations/no.json b/homeassistant/components/ps4/.translations/no.json
index 3608a5534ab..b5db81356d0 100644
--- a/homeassistant/components/ps4/.translations/no.json
+++ b/homeassistant/components/ps4/.translations/no.json
@@ -15,8 +15,8 @@
},
"step": {
"creds": {
- "description": "Legitimasjon n\u00f8dvendig. Trykk \"Send\" og deretter i PS4-ens andre skjerm app, kan du oppdatere enheter, og velg \"Home-Assistant' enheten for \u00e5 fortsette.",
- "title": "PlayStation 4"
+ "description": "Legitimasjon n\u00f8dvendig. Trykk 'Send' og deretter i PS4-ens andre skjerm app, kan du oppdatere enheter, og velg 'Home-Assistant' enheten for \u00e5 fortsette.",
+ "title": ""
},
"link": {
"data": {
@@ -26,7 +26,7 @@
"region": "Region"
},
"description": "Skriv inn PlayStation 4-informasjonen. For 'PIN', naviger til 'Innstillinger' p\u00e5 PlayStation 4-konsollen. Naviger deretter til 'Mobile App Connection Settings' og velg 'Add Device'. Tast inn PIN-koden som vises. Se [dokumentasjonen] (https://www.home-assistant.io/components/ps4/) for mer informasjon.",
- "title": "PlayStation 4"
+ "title": ""
},
"mode": {
"data": {
@@ -34,9 +34,9 @@
"mode": "Konfigureringsmodus"
},
"description": "Velg modus for konfigurasjon. Feltet IP-adresse kan st\u00e5 tomt dersom du velger Auto Discovery, da enheter vil bli oppdaget automatisk.",
- "title": "PlayStation 4"
+ "title": ""
}
},
- "title": "PlayStation 4"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/ca.json b/homeassistant/components/pvpc_hourly_pricing/.translations/ca.json
new file mode 100644
index 00000000000..0a7af79fab3
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Integraci\u00f3 ja configurada amb un sensor amb aquesta tarifa"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Nom del sensor",
+ "tariff": "Tarifa contractada (1, 2 o 3 per\u00edodes)"
+ },
+ "description": "Aquest sensor utilitza l'API oficial de la xarxa el\u00e8ctrica espanyola (REE) per obtenir els [preus per hora de l\u2019electricitat (PVPC)](https://www.esios.ree.es/es/pvpc) a Espanya.\nPer a m\u00e9s informaci\u00f3, consulta la [documentaci\u00f3 de la integraci\u00f3](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\nSelecciona la tarifa contractada, en funci\u00f3 del nombre de per\u00edodes que t\u00e9: \n - 1 per\u00edode: normal (sense discriminaci\u00f3)\n - 2 per\u00edodes: discriminaci\u00f3 (tarifa nocturna) \n - 3 per\u00edodes: cotxe el\u00e8ctric (tarifa nocturna de 3 per\u00edodes)",
+ "title": "Selecci\u00f3 de tarifa"
+ }
+ },
+ "title": "Preu per hora de l'electricitat a Espanya (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/de.json b/homeassistant/components/pvpc_hourly_pricing/.translations/de.json
new file mode 100644
index 00000000000..2e80e3da6e6
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Die Integration ist bereits mit einem vorhandenen Sensor mit diesem Tarif konfiguriert"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Sensorname",
+ "tariff": "Vertragstarif (1, 2 oder 3 Perioden)"
+ },
+ "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. \nWeitere Informationen finden Sie in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\nW\u00e4hlen Sie den vertraglich vereinbarten Tarif basierend auf der Anzahl der Abrechnungsperioden pro Tag aus: \n - 1 Periode: Normal \n - 2 Perioden: Diskriminierung (Nachttarif) \n - 3 Perioden: Elektroauto (Nachttarif von 3 Perioden)",
+ "title": "Tarifauswahl"
+ }
+ },
+ "title": "St\u00fcndlicher Strompreis in Spanien (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/en.json b/homeassistant/components/pvpc_hourly_pricing/.translations/en.json
new file mode 100644
index 00000000000..86aaf15c0f1
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Integration is already configured with an existing sensor with that tariff"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Sensor Name",
+ "tariff": "Contracted tariff (1, 2, or 3 periods)"
+ },
+ "description": "This sensor uses official API to get [hourly pricing of electricity (PVPC)](https://www.esios.ree.es/es/pvpc) in Spain.\nFor more precise explanation visit the [integration docs](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nSelect the contracted rate based on the number of billing periods per day:\n- 1 period: normal\n- 2 periods: discrimination (nightly rate)\n- 3 periods: electric car (nightly rate of 3 periods)",
+ "title": "Tariff selection"
+ }
+ },
+ "title": "Hourly price of electricity in Spain (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/es.json b/homeassistant/components/pvpc_hourly_pricing/.translations/es.json
new file mode 100644
index 00000000000..8951c46b75d
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "La integraci\u00f3n ya est\u00e1 configurada con un sensor existente con esa tarifa"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Nombre del sensor",
+ "tariff": "Tarifa contratada (1, 2 o 3 per\u00edodos)"
+ },
+ "description": "Este sensor utiliza la API oficial para obtener [el precio horario de la electricidad (PVPC)](https://www.esios.ree.es/es/pvpc) en Espa\u00f1a.\nPara obtener una explicaci\u00f3n m\u00e1s precisa, visita los [documentos de la integraci\u00f3n](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nSelecciona la tarifa contratada en funci\u00f3n del n\u00famero de per\u00edodos de facturaci\u00f3n por d\u00eda:\n- 1 per\u00edodo: normal\n- 2 per\u00edodos: discriminaci\u00f3n (tarifa nocturna)\n- 3 per\u00edodos: coche el\u00e9ctrico (tarifa nocturna de 3 per\u00edodos)",
+ "title": "Selecci\u00f3n de tarifa"
+ }
+ },
+ "title": "Precio por hora de la electricidad en Espa\u00f1a (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/fr.json b/homeassistant/components/pvpc_hourly_pricing/.translations/fr.json
new file mode 100644
index 00000000000..5c615c5f757
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/fr.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'int\u00e9gration est d\u00e9j\u00e0 configur\u00e9e avec un capteur existant avec ce tarif"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Nom du capteur"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/it.json b/homeassistant/components/pvpc_hourly_pricing/.translations/it.json
new file mode 100644
index 00000000000..5e0c6acef50
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'integrazione \u00e8 gi\u00e0 configurata con un sensore esistente con quella tariffa"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Nome del sensore",
+ "tariff": "Tariffa contrattuale (1, 2 o 3 periodi)"
+ },
+ "description": "Questo sensore utilizza l'API ufficiale per ottenere [prezzi orari dell'elettricit\u00e0 (PVPC)](https://www.esios.ree.es/es/pvpc) in Spagna.\nPer una spiegazione pi\u00f9 precisa, visitare la [documentazione di integrazione](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nSelezionare la tariffa contrattuale in base al numero di periodi di fatturazione al giorno:\n- 1 periodo: normale\n- 2 periodi: discriminazione (tariffa notturna)\n- 3 periodi: auto elettrica (tariffa notturna di 3 periodi)",
+ "title": "Selezione della tariffa"
+ }
+ },
+ "title": "Prezzo orario dell'elettricit\u00e0 in Spagna (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/ko.json b/homeassistant/components/pvpc_hourly_pricing/.translations/ko.json
new file mode 100644
index 00000000000..e2bd6caaa8d
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \uc774\ubbf8 \ud574\ub2f9 \uc694\uae08\uc81c \uc13c\uc11c\ub85c \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "\uc13c\uc11c \uc774\ub984",
+ "tariff": "\uacc4\uc57d \uc694\uae08\uc81c (1, 2 \ub610\ub294 3 \uad6c\uac04)"
+ },
+ "description": "\uc774 \uc13c\uc11c\ub294 \uacf5\uc2dd API \ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2a4\ud398\uc778\uc758 [\uc2dc\uac04\ub2f9 \uc804\uae30 \uc694\uae08 (PVPC)](https://www.esios.ree.es/es/pvpc) \uc744 \uac00\uc838\uc635\ub2c8\ub2e4.\n\ubcf4\ub2e4 \uc790\uc138\ud55c \uc124\uba85\uc740 [\uc548\ub0b4](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/)\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.\n\n1\uc77c\ub2f9 \uccad\uad6c \uad6c\uac04\uc5d0 \ub530\ub77c \uacc4\uc57d \uc694\uae08\uc81c\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.\n - 1 \uad6c\uac04: \uc77c\ubc18 \uc694\uae08\uc81c\n - 2 \uad6c\uac04: \ucc28\ub4f1 \uc694\uae08\uc81c (\uc57c\uac04 \uc694\uae08) \n - 3 \uad6c\uac04: \uc804\uae30\uc790\ub3d9\ucc28 (3 \uad6c\uac04 \uc57c\uac04 \uc694\uae08)",
+ "title": "\uc694\uae08\uc81c \uc120\ud0dd"
+ }
+ },
+ "title": "\uc2a4\ud398\uc778 \uc2dc\uac04\ub2f9 \uc804\uae30\uc694\uae08 (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/lb.json b/homeassistant/components/pvpc_hourly_pricing/.translations/lb.json
new file mode 100644
index 00000000000..bed6af70e13
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Integratioun ass scho konfigur\u00e9iert mat engem Sensor mat deem Tarif"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Numm vum Sensor",
+ "tariff": "Kontraktuellen Tarif (1, 2 oder 3 Perioden)"
+ },
+ "description": "D\u00ebse Sensor benotzt d\u00e9i offiziell API fir de [Stonne Pr\u00e4is fir Elektrizit\u00e9it a Spuenien (PVPC)](https://www.esios.ree.es/es/pvpc) ze kr\u00e9ien. Fir m\u00e9i pr\u00e4zise Erkl\u00e4runge kuck [Dokumentatioun vun der Integratioun](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nWiel den Taux bas\u00e9ierend op der Unzuel vun de Rechnungsz\u00e4ite pro Dag aus:\n- 1 Period: Normal\n- 2 perioden: Nuets Tarif\n- 3 Perioden: Elektreschen Auto (Nuets Tarif fir 3 Perioden)",
+ "title": "Auswiel vum Tarif"
+ }
+ },
+ "title": "Stonne Pr\u00e4is fir Elektrizit\u00e9it a Spuenien (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/no.json b/homeassistant/components/pvpc_hourly_pricing/.translations/no.json
new file mode 100644
index 00000000000..0a7f93dda8a
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Integrasjon er allerede konfigurert med en eksisterende sensor med den tariffen"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "Sensornavn",
+ "tariff": "Avtaletariff (1, 2 eller 3 perioder)"
+ },
+ "description": "Denne sensoren bruker offisiell API for \u00e5 f\u00e5 [timeprising av elektrisitet (PVPC)](https://www.esios.ree.es/es/pvpc) i Spania.\nFor mer presis forklaring, bes\u00f8k [integrasjonsdokumenter](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nVelg den avtalte satsen basert p\u00e5 antall faktureringsperioder per dag:\n- 1 periode: normal\n- 2 perioder: diskriminering (nattlig rate)\n- 3 perioder: elbil (per natt rate p\u00e5 3 perioder)",
+ "title": "Tariffvalg"
+ }
+ },
+ "title": "Timepris p\u00e5 elektrisitet i Spania (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/ru.json b/homeassistant/components/pvpc_hourly_pricing/.translations/ru.json
new file mode 100644
index 00000000000..aaa10fa21b7
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\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."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
+ "tariff": "\u041a\u043e\u043d\u0442\u0440\u0430\u043a\u0442\u043d\u044b\u0439 \u0442\u0430\u0440\u0438\u0444 (1, 2 \u0438\u043b\u0438 3 \u043f\u0435\u0440\u0438\u043e\u0434\u0430)"
+ },
+ "description": "\u042d\u0442\u043e\u0442 \u0441\u0435\u043d\u0441\u043e\u0440 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u043e\u0444\u0438\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0439 API \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f [\u043f\u043e\u0447\u0430\u0441\u043e\u0432\u043e\u0439 \u0446\u0435\u043d\u044b \u0437\u0430 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u044d\u043d\u0435\u0440\u0433\u0438\u044e (PVPC)](https://www.esios.ree.es/es/pvpc) \u0432 \u0418\u0441\u043f\u0430\u043d\u0438\u0438.\n\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, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\n\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0430\u0440\u0438\u0444, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u043d\u0430 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0435 \u0440\u0430\u0441\u0447\u0435\u0442\u043d\u044b\u0445 \u043f\u0435\u0440\u0438\u043e\u0434\u043e\u0432 \u0432 \u0434\u0435\u043d\u044c:\n- 1 \u043f\u0435\u0440\u0438\u043e\u0434: normal\n- 2 \u043f\u0435\u0440\u0438\u043e\u0434\u0430: discrimination (nightly rate)\n- 3 \u043f\u0435\u0440\u0438\u043e\u0434\u0430: electric car (nightly rate of 3 periods)",
+ "title": "\u0412\u044b\u0431\u043e\u0440 \u0442\u0430\u0440\u0438\u0444\u0430"
+ }
+ },
+ "title": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u044d\u043d\u0435\u0440\u0433\u0438\u044f \u0432 \u0418\u0441\u043f\u0430\u043d\u0438\u0438 (PVPC)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/zh-Hant.json b/homeassistant/components/pvpc_hourly_pricing/.translations/zh-Hant.json
new file mode 100644
index 00000000000..a10c499dd59
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6574\u5408\u5df2\u7d93\u8a2d\u5b9a\u4e26\u6709\u73fe\u6709\u50b3\u611f\u5668\u4f7f\u7528\u76f8\u540c\u8cbb\u7387"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "\u50b3\u611f\u5668\u540d\u7a31",
+ "tariff": "\u5408\u7d04\u8cbb\u7387\uff081\u30012 \u6216 3 \u9031\u671f\uff09"
+ },
+ "description": "\u6b64\u50b3\u611f\u5668\u4f7f\u7528\u4e86\u975e\u5b98\u65b9 API \u4ee5\u53d6\u5f97\u897f\u73ed\u7259 [\u8a08\u6642\u96fb\u50f9\uff08PVPC\uff09](https://www.esios.ree.es/es/pvpc)\u3002\n\u95dc\u65bc\u66f4\u8a73\u7d30\u7684\u8aaa\u660e\uff0c\u8acb\u53c3\u95b1 [\u6574\u5408\u6587\u4ef6](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/)\u3002\n\n\u57fa\u65bc\u6bcf\u5929\u7684\u5e33\u55ae\u9031\u671f\u9078\u64c7\u5408\u7d04\u8cbb\u7387\uff1a\n- 1 \u9031\u671f\uff1a\u4e00\u822c\n- 2 \u9031\u671f\uff1a\u5dee\u5225\u8cbb\u7387\uff08\u591c\u9593\u8cbb\u7387\uff09\n- 3 \u9031\u671f\uff1a\u96fb\u52d5\u8eca\uff08\u591c\u9593\u8cbb\u7387 3 \u9031\u671f\uff09",
+ "title": "\u8cbb\u7387\u9078\u64c7"
+ }
+ },
+ "title": "\u897f\u73ed\u7259\u6642\u8a08\u96fb\u50f9\uff08PVPC\uff09"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py
new file mode 100644
index 00000000000..5930da52313
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py
@@ -0,0 +1,56 @@
+"""The pvpc_hourly_pricing integration to collect Spain official electric prices."""
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_NAME
+from homeassistant.core import HomeAssistant
+import homeassistant.helpers.config_validation as cv
+
+from .const import ATTR_TARIFF, DEFAULT_NAME, DEFAULT_TARIFF, DOMAIN, PLATFORM, TARIFFS
+
+UI_CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
+ vol.Required(ATTR_TARIFF, default=DEFAULT_TARIFF): vol.In(TARIFFS),
+ }
+)
+CONFIG_SCHEMA = vol.Schema(
+ {DOMAIN: cv.ensure_list(UI_CONFIG_SCHEMA)}, extra=vol.ALLOW_EXTRA
+)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """
+ Set up the electricity price sensor from configuration.yaml.
+
+ ```yaml
+ pvpc_hourly_pricing:
+ - name: PVPC manual ve
+ tariff: electric_car
+ - name: PVPC manual nocturna
+ tariff: discrimination
+ timeout: 3
+ ```
+ """
+ for conf in config.get(DOMAIN, []):
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, data=conf, context={"source": config_entries.SOURCE_IMPORT}
+ )
+ )
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry):
+ """Set up pvpc hourly pricing from a config entry."""
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, PLATFORM)
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry):
+ """Unload a config entry."""
+ return await hass.config_entries.async_forward_entry_unload(entry, PLATFORM)
diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py
new file mode 100644
index 00000000000..10591e5b82c
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py
@@ -0,0 +1,27 @@
+"""Config flow for pvpc_hourly_pricing."""
+from homeassistant import config_entries
+
+from . import CONF_NAME, UI_CONFIG_SCHEMA
+from .const import ATTR_TARIFF, DOMAIN
+
+_DOMAIN_NAME = DOMAIN
+
+
+class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=_DOMAIN_NAME):
+ """Handle a config flow for `pvpc_hourly_pricing` to select the tariff."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ if user_input is not None:
+ await self.async_set_unique_id(user_input[ATTR_TARIFF])
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
+
+ return self.async_show_form(step_id="user", data_schema=UI_CONFIG_SCHEMA)
+
+ async def async_step_import(self, import_info):
+ """Handle import from config file."""
+ return await self.async_step_user(import_info)
diff --git a/homeassistant/components/pvpc_hourly_pricing/const.py b/homeassistant/components/pvpc_hourly_pricing/const.py
new file mode 100644
index 00000000000..d75ad9fe35c
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/const.py
@@ -0,0 +1,8 @@
+"""Constant values for pvpc_hourly_pricing."""
+from aiopvpc import TARIFFS
+
+DOMAIN = "pvpc_hourly_pricing"
+PLATFORM = "sensor"
+ATTR_TARIFF = "tariff"
+DEFAULT_NAME = "PVPC"
+DEFAULT_TARIFF = TARIFFS[1]
diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json
new file mode 100644
index 00000000000..a2f6ec12941
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pvpc_hourly_pricing",
+ "name": "Spain electricity hourly pricing (PVPC)",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing",
+ "requirements": ["aiopvpc==1.0.2"],
+ "dependencies": [],
+ "codeowners": ["@azogue"],
+ "quality_scale": "platinum"
+}
diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py
new file mode 100644
index 00000000000..199e20d3e22
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py
@@ -0,0 +1,156 @@
+"""Sensor to collect the reference daily prices of electricity ('PVPC') in Spain."""
+import logging
+from random import randint
+from typing import Optional
+
+from aiopvpc import PVPCData
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_NAME
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.event import async_call_later, async_track_time_change
+from homeassistant.helpers.restore_state import RestoreEntity
+import homeassistant.util.dt as dt_util
+
+from .const import ATTR_TARIFF
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_PRICE = "price"
+ICON = "mdi:currency-eur"
+UNIT = "€/kWh"
+
+_DEFAULT_TIMEOUT = 10
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: config_entries.ConfigEntry, async_add_entities
+):
+ """Set up the electricity price sensor from config_entry."""
+ name = config_entry.data[CONF_NAME]
+ pvpc_data_handler = PVPCData(
+ tariff=config_entry.data[ATTR_TARIFF],
+ local_timezone=hass.config.time_zone,
+ websession=async_get_clientsession(hass),
+ logger=_LOGGER,
+ timeout=_DEFAULT_TIMEOUT,
+ )
+ async_add_entities(
+ [ElecPriceSensor(name, config_entry.unique_id, pvpc_data_handler)], False
+ )
+
+
+class ElecPriceSensor(RestoreEntity):
+ """Class to hold the prices of electricity as a sensor."""
+
+ unit_of_measurement = UNIT
+ icon = ICON
+ should_poll = False
+
+ def __init__(self, name, unique_id, pvpc_data_handler):
+ """Initialize the sensor object."""
+ self._name = name
+ self._unique_id = unique_id
+ self._pvpc_data = pvpc_data_handler
+ self._num_retries = 0
+
+ self._hourly_tracker = None
+ self._price_tracker = None
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Cancel listeners for sensor updates."""
+ self._hourly_tracker()
+ self._price_tracker()
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if state:
+ self._pvpc_data.state = state.state
+
+ # Update 'state' value in hour changes
+ self._hourly_tracker = async_track_time_change(
+ self.hass, self.update_current_price, second=[0], minute=[0]
+ )
+ # Update prices at random time, 2 times/hour (don't want to upset API)
+ random_minute = randint(1, 29)
+ mins_update = [random_minute, random_minute + 30]
+ self._price_tracker = async_track_time_change(
+ self.hass, self.async_update_prices, second=[0], minute=mins_update,
+ )
+ _LOGGER.debug(
+ "Setup of price sensor %s (%s) with tariff '%s', "
+ "updating prices each hour at %s min",
+ self.name,
+ self.entity_id,
+ self._pvpc_data.tariff,
+ mins_update,
+ )
+ await self.async_update_prices(dt_util.utcnow())
+ self.update_current_price(dt_util.utcnow())
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._pvpc_data.state
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._pvpc_data.state_available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._pvpc_data.attributes
+
+ @callback
+ def update_current_price(self, now):
+ """Update the sensor state, by selecting the current price for this hour."""
+ self._pvpc_data.process_state_and_attributes(now)
+ self.async_write_ha_state()
+
+ async def async_update_prices(self, now):
+ """Update electricity prices from the ESIOS API."""
+ prices = await self._pvpc_data.async_update_prices(now)
+ if not prices and self._pvpc_data.source_available:
+ self._num_retries += 1
+ if self._num_retries > 2:
+ _LOGGER.warning(
+ "%s: repeated bad data update, mark component as unavailable source",
+ self.entity_id,
+ )
+ self._pvpc_data.source_available = False
+ return
+
+ retry_delay = 2 * self._num_retries * self._pvpc_data.timeout
+ _LOGGER.debug(
+ "%s: Bad update[retry:%d], will try again in %d s",
+ self.entity_id,
+ self._num_retries,
+ retry_delay,
+ )
+ async_call_later(self.hass, retry_delay, self.async_update_prices)
+ return
+
+ if not prices:
+ _LOGGER.debug("%s: data source is not yet available", self.entity_id)
+ return
+
+ self._num_retries = 0
+ if not self._pvpc_data.source_available:
+ self._pvpc_data.source_available = True
+ _LOGGER.warning("%s: component has recovered data access", self.entity_id)
+ self.update_current_price(now)
diff --git a/homeassistant/components/pvpc_hourly_pricing/strings.json b/homeassistant/components/pvpc_hourly_pricing/strings.json
new file mode 100644
index 00000000000..bff5dc2e68f
--- /dev/null
+++ b/homeassistant/components/pvpc_hourly_pricing/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "Hourly price of electricity in Spain (PVPC)",
+ "step": {
+ "user": {
+ "title": "Tariff selection",
+ "description": "This sensor uses official API to get [hourly pricing of electricity (PVPC)](https://www.esios.ree.es/es/pvpc) in Spain.\nFor more precise explanation visit the [integration docs](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nSelect the contracted rate based on the number of billing periods per day:\n- 1 period: normal\n- 2 periods: discrimination (nightly rate)\n- 3 periods: electric car (nightly rate of 3 periods)",
+ "data": {
+ "name": "Sensor Name",
+ "tariff": "Contracted tariff (1, 2, or 3 periods)"
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "Integration is already configured with an existing sensor with that tariff"
+ }
+ }
+}
diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py
index f2840d49299..3e10191e48b 100644
--- a/homeassistant/components/qvr_pro/__init__.py
+++ b/homeassistant/components/qvr_pro/__init__.py
@@ -4,10 +4,11 @@ import logging
from pyqvrpro import Client
from pyqvrpro.client import AuthenticationError, InsufficientPermissionsError
+from requests.exceptions import ConnectionError as RequestsConnectionError
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
@@ -29,6 +30,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_EXCLUDE_CHANNELS, default=[]): vol.All(
cv.ensure_list_csv, [cv.positive_int]
),
@@ -49,10 +51,11 @@ def setup(hass, config):
user = conf[CONF_USERNAME]
password = conf[CONF_PASSWORD]
host = conf[CONF_HOST]
+ port = conf.get(CONF_PORT)
excluded_channels = conf[CONF_EXCLUDE_CHANNELS]
try:
- qvrpro = Client(user, password, host)
+ qvrpro = Client(user, password, host, port=port)
channel_resp = qvrpro.get_channel_list()
@@ -62,6 +65,9 @@ def setup(hass, config):
except AuthenticationError:
_LOGGER.error("Authentication failed")
return False
+ except RequestsConnectionError:
+ _LOGGER.error("Error connecting to QVR server")
+ return False
channels = []
diff --git a/homeassistant/components/rachio/.translations/ca.json b/homeassistant/components/rachio/.translations/ca.json
new file mode 100644
index 00000000000..468ab0b3f5c
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/ca.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clau API del compte Rachio."
+ },
+ "description": "Necessitar\u00e0s la clau API de https://app.rach.io/. Selecciona 'Configuraci\u00f3 del compte' (Account Settings) i, a continuaci\u00f3, clica 'Obtenir clau API' (GET API KEY).",
+ "title": "Connexi\u00f3 amb dispositiu Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Durant quant de temps (en minuts) mantenir engegada una estaci\u00f3 quan l\u2019interruptor s'activa."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/de.json b/homeassistant/components/rachio/.translations/de.json
new file mode 100644
index 00000000000..05bf5fbe4dd
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/de.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
+ "invalid_auth": "Ung\u00fcltige Authentifizierung",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Der API-Schl\u00fcssel f\u00fcr das Rachio-Konto."
+ },
+ "description": "Sie ben\u00f6tigen den API-Schl\u00fcssel von https://app.rach.io/. W\u00e4hlen Sie \"Kontoeinstellungen\" und klicken Sie dann auf \"API-SCHL\u00dcSSEL ERHALTEN\".",
+ "title": "Stellen Sie eine Verbindung zu Ihrem Rachio-Ger\u00e4t her"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Wie lange, in Minuten, um eine Station einzuschalten, wenn der Schalter aktiviert ist."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/en.json b/homeassistant/components/rachio/.translations/en.json
new file mode 100644
index 00000000000..bc87c370068
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/en.json
@@ -0,0 +1,31 @@
+{
+ "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": {
+ "api_key": "The API key for the Rachio account."
+ },
+ "description": "You will need the API Key from https://app.rach.io/. Select 'Account Settings, and then click on 'GET API KEY'.",
+ "title": "Connect to your Rachio device"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "For how long, in minutes, to turn on a station when the switch is enabled."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/es.json b/homeassistant/components/rachio/.translations/es.json
new file mode 100644
index 00000000000..e938c78677e
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/es.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "La clave API para la cuenta Rachio."
+ },
+ "description": "Necesitar\u00e1s la clave API de https://app.rach.io/. Selecciona 'Account Settings' y luego haz clic en 'GET API KEY'.",
+ "title": "Conectar a tu dispositivo Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Durante cu\u00e1nto tiempo, en minutos, permanece encendida una estaci\u00f3n cuando el interruptor est\u00e1 activado."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/fr.json b/homeassistant/components/rachio/.translations/fr.json
new file mode 100644
index 00000000000..a7fd606b310
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/fr.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "invalid_auth": "Authentification non valide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "La cl\u00e9 API pour le compte Rachio."
+ },
+ "description": "Vous aurez besoin de la cl\u00e9 API de https://app.rach.io/. S\u00e9lectionnez \"Param\u00e8tres du compte, puis cliquez sur \"GET API KEY \".",
+ "title": "Connectez-vous \u00e0 votre appareil Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Le temps, en minutes, n\u00e9cessaire pour allumer une station lorsque l'interrupteur est activ\u00e9."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/it.json b/homeassistant/components/rachio/.translations/it.json
new file mode 100644
index 00000000000..fe05d236e8a
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/it.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Chiave API per l'account Rachio."
+ },
+ "description": "\u00c8 necessaria la chiave API di https://app.rach.io/. Selezionare 'Impostazioni Account', quindi fare clic su 'GET API KEY'.",
+ "title": "Connettiti al tuo dispositivo Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Per quanto tempo, in minuti, accendere una stazione quando l'interruttore \u00e8 abilitato."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/ko.json b/homeassistant/components/rachio/.translations/ko.json
new file mode 100644
index 00000000000..d52aac4bf4a
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/ko.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \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": {
+ "api_key": "Rachio \uacc4\uc815\uc758 API \ud0a4."
+ },
+ "description": "https://app.rach.io/ \uc758 API \ud0a4\uac00 \ud544\uc694\ud569\ub2c8\ub2e4. \uacc4\uc815 \uc124\uc815\uc744 \uc120\ud0dd\ud55c \ub2e4\uc74c 'GET API KEY ' \ub97c \ud074\ub9ad\ud574\uc8fc\uc138\uc694.",
+ "title": "Rachio \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "\uc2a4\uc704\uce58\uac00 \ud65c\uc131\ud654\ub41c \uacbd\uc6b0 \uc2a4\ud14c\uc774\uc158\uc744 \ucf1c\ub294 \uc2dc\uac04(\ubd84) \uc785\ub2c8\ub2e4."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/lb.json b/homeassistant/components/rachio/.translations/lb.json
new file mode 100644
index 00000000000..d43d4d9a044
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/lb.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Schl\u00ebssel fir den Racchio Kont."
+ },
+ "description": "Du brauchs een API Schl\u00ebssel vun https://app.rach.io/. Wiel 'Account Settings', a klick dann op 'GET API KEY'.",
+ "title": "Mam Rachio Apparat verbannen"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Fir w\u00e9i laang, a Minutten, soll eng Statioun ugeschalt gi wann de Schalter ageschalt ass."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/no.json b/homeassistant/components/rachio/.translations/no.json
new file mode 100644
index 00000000000..8b063018879
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/no.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API-n\u00f8kkelen for Rachio-kontoen."
+ },
+ "description": "Du trenger API-n\u00f8kkelen fra https://app.rach.io/. Velg \"Kontoinnstillinger\", og klikk deretter p\u00e5 \"GET API KEY\".",
+ "title": "Koble til Rachio-enheten din"
+ }
+ },
+ "title": ""
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Hvor lenge, i minutter, for \u00e5 sl\u00e5 p\u00e5 en stasjon n\u00e5r bryteren er aktivert."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/pl.json b/homeassistant/components/rachio/.translations/pl.json
new file mode 100644
index 00000000000..3c07ea850c0
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/pl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane."
+ },
+ "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": {
+ "api_key": "Klucz API dla konta Rachio."
+ },
+ "description": "B\u0119dziesz potrzebowa\u0142 klucza API ze strony https://app.rach.io/. Wybierz 'Account Settings', a nast\u0119pnie kliknij 'GET API KEY'.",
+ "title": "Po\u0142\u0105czenie z urz\u0105dzeniem Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Jak d\u0142ugo, w minutach, nale\u017cy w\u0142\u0105czy\u0107 stacj\u0119, gdy prze\u0142\u0105cznik jest w\u0142\u0105czony."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/ru.json b/homeassistant/components/rachio/.translations/ru.json
new file mode 100644
index 00000000000..619f5d4bb0e
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/ru.json
@@ -0,0 +1,31 @@
+{
+ "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\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": {
+ "api_key": "\u041a\u043b\u044e\u0447 API \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Rachio."
+ },
+ "description": "\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0443\u0436\u0435\u043d \u043a\u043b\u044e\u0447 API \u043e\u0442 https://app.rach.io/. \u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 'Account Settings', \u0430 \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 'GET API KEY'.",
+ "title": "Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "\u041d\u0430 \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u043a\u043b\u044e\u0447\u0430\u0442\u044c \u0441\u0442\u0430\u043d\u0446\u0438\u044e, \u043a\u043e\u0433\u0434\u0430 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044c \u0432\u043a\u043b\u044e\u0447\u0435\u043d (\u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445)."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/sl.json b/homeassistant/components/rachio/.translations/sl.json
new file mode 100644
index 00000000000..80e5f3ff99c
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/sl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Naprava je \u017ee konfigurirana"
+ },
+ "error": {
+ "cannot_connect": "Povezava ni uspela, poskusite znova",
+ "invalid_auth": "Neveljavna avtentikacija",
+ "unknown": "Nepri\u010dakovana napaka"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Klju\u010d API za ra\u010dun Rachio."
+ },
+ "description": "Potrebovali boste API klju\u010d iz https://app.rach.io/. Izberite ' nastavitve ra\u010duna in kliknite 'get API KEY'.",
+ "title": "Pove\u017eite se z napravo Rachio"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "Kako dolgo, v minutah, da vklopite postajo, ko je stikalo omogo\u010deno."
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/.translations/zh-Hant.json b/homeassistant/components/rachio/.translations/zh-Hant.json
new file mode 100644
index 00000000000..0eabf0ed574
--- /dev/null
+++ b/homeassistant/components/rachio/.translations/zh-Hant.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Rachio \u5e33\u865f API \u91d1\u9470\u3002"
+ },
+ "description": "\u5c07\u6703\u9700\u8981\u7531 https://app.rach.io/ \u53d6\u5f97 App \u5bc6\u9470\u3002\u9078\u64c7\u5e33\u865f\u8a2d\u5b9a\uff08Account Settings\uff09\u3001\u4e26\u9078\u64c7\u7372\u5f97\u5bc6\u9470\uff08GET API KEY\uff09\u3002",
+ "title": "\u9023\u7dda\u81f3 Rachio \u8a2d\u5099"
+ }
+ },
+ "title": "Rachio"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "\u7576\u958b\u95dc\u958b\u555f\u5f8c\u3001\u5de5\u4f5c\u7ad9\u6240\u8981\u958b\u555f\u7684\u5206\u9418\u6578\u3002"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py
index 1b24f4e0071..8879bd6965c 100644
--- a/homeassistant/components/rachio/__init__.py
+++ b/homeassistant/components/rachio/__init__.py
@@ -2,28 +2,30 @@
import asyncio
import logging
import secrets
-from typing import Optional
-from aiohttp import web
from rachiopy import Rachio
import voluptuous as vol
-from homeassistant.components.http import HomeAssistantView
-from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API
-from homeassistant.helpers import config_validation as cv, discovery
-from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+
+from .const import (
+ CONF_CUSTOM_URL,
+ CONF_MANUAL_RUN_MINS,
+ DEFAULT_MANUAL_RUN_MINS,
+ DOMAIN,
+ RACHIO_API_EXCEPTIONS,
+)
+from .device import RachioPerson
+from .webhooks import WEBHOOK_PATH, RachioWebhookView
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "rachio"
-
SUPPORTED_DOMAINS = ["switch", "binary_sensor"]
-# Manual run length
-CONF_MANUAL_RUN_MINS = "manual_run_mins"
-DEFAULT_MANUAL_RUN_MINS = 10
-CONF_CUSTOM_URL = "hass_url_override"
-
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -39,91 +41,74 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-# Keys used in the API JSON
-KEY_DEVICE_ID = "deviceId"
-KEY_DEVICES = "devices"
-KEY_ENABLED = "enabled"
-KEY_EXTERNAL_ID = "externalId"
-KEY_ID = "id"
-KEY_NAME = "name"
-KEY_ON = "on"
-KEY_STATUS = "status"
-KEY_SUBTYPE = "subType"
-KEY_SUMMARY = "summary"
-KEY_TYPE = "type"
-KEY_URL = "url"
-KEY_USERNAME = "username"
-KEY_ZONE_ID = "zoneId"
-KEY_ZONE_NUMBER = "zoneNumber"
-KEY_ZONES = "zones"
-STATUS_ONLINE = "ONLINE"
-STATUS_OFFLINE = "OFFLINE"
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the rachio component from YAML."""
-# Device webhook values
-TYPE_CONTROLLER_STATUS = "DEVICE_STATUS"
-SUBTYPE_OFFLINE = "OFFLINE"
-SUBTYPE_ONLINE = "ONLINE"
-SUBTYPE_OFFLINE_NOTIFICATION = "OFFLINE_NOTIFICATION"
-SUBTYPE_COLD_REBOOT = "COLD_REBOOT"
-SUBTYPE_SLEEP_MODE_ON = "SLEEP_MODE_ON"
-SUBTYPE_SLEEP_MODE_OFF = "SLEEP_MODE_OFF"
-SUBTYPE_BROWNOUT_VALVE = "BROWNOUT_VALVE"
-SUBTYPE_RAIN_SENSOR_DETECTION_ON = "RAIN_SENSOR_DETECTION_ON"
-SUBTYPE_RAIN_SENSOR_DETECTION_OFF = "RAIN_SENSOR_DETECTION_OFF"
-SUBTYPE_RAIN_DELAY_ON = "RAIN_DELAY_ON"
-SUBTYPE_RAIN_DELAY_OFF = "RAIN_DELAY_OFF"
+ conf = config.get(DOMAIN)
+ hass.data.setdefault(DOMAIN, {})
-# Schedule webhook values
-TYPE_SCHEDULE_STATUS = "SCHEDULE_STATUS"
-SUBTYPE_SCHEDULE_STARTED = "SCHEDULE_STARTED"
-SUBTYPE_SCHEDULE_STOPPED = "SCHEDULE_STOPPED"
-SUBTYPE_SCHEDULE_COMPLETED = "SCHEDULE_COMPLETED"
-SUBTYPE_WEATHER_NO_SKIP = "WEATHER_INTELLIGENCE_NO_SKIP"
-SUBTYPE_WEATHER_SKIP = "WEATHER_INTELLIGENCE_SKIP"
-SUBTYPE_WEATHER_CLIMATE_SKIP = "WEATHER_INTELLIGENCE_CLIMATE_SKIP"
-SUBTYPE_WEATHER_FREEZE = "WEATHER_INTELLIGENCE_FREEZE"
+ if not conf:
+ return True
-# Zone webhook values
-TYPE_ZONE_STATUS = "ZONE_STATUS"
-SUBTYPE_ZONE_STARTED = "ZONE_STARTED"
-SUBTYPE_ZONE_STOPPED = "ZONE_STOPPED"
-SUBTYPE_ZONE_COMPLETED = "ZONE_COMPLETED"
-SUBTYPE_ZONE_CYCLING = "ZONE_CYCLING"
-SUBTYPE_ZONE_CYCLING_COMPLETED = "ZONE_CYCLING_COMPLETED"
-
-# Webhook callbacks
-LISTEN_EVENT_TYPES = ["DEVICE_STATUS_EVENT", "ZONE_STATUS_EVENT"]
-WEBHOOK_CONST_ID = "homeassistant.rachio:"
-WEBHOOK_PATH = URL_API + DOMAIN
-SIGNAL_RACHIO_UPDATE = DOMAIN + "_update"
-SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + "_controller"
-SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + "_zone"
-SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule"
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
+ )
+ )
+ return True
-def setup(hass, config) -> bool:
- """Set up the Rachio component."""
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in SUPPORTED_DOMAINS
+ ]
+ )
+ )
- # Listen for incoming webhook connections
- hass.http.register_view(RachioWebhookView())
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up the Rachio config entry."""
+
+ config = entry.data
+ options = entry.options
+
+ # CONF_MANUAL_RUN_MINS can only come from a yaml import
+ if not options.get(CONF_MANUAL_RUN_MINS) and config.get(CONF_MANUAL_RUN_MINS):
+ options_copy = options.copy()
+ options_copy[CONF_MANUAL_RUN_MINS] = config[CONF_MANUAL_RUN_MINS]
+ hass.config_entries.async_update_entry(entry, options=options_copy)
# Configure API
- api_key = config[DOMAIN].get(CONF_API_KEY)
+ api_key = config[CONF_API_KEY]
rachio = Rachio(api_key)
# Get the URL of this server
- custom_url = config[DOMAIN].get(CONF_CUSTOM_URL)
+ custom_url = config.get(CONF_CUSTOM_URL)
hass_url = hass.config.api.base_url if custom_url is None else custom_url
rachio.webhook_auth = secrets.token_hex()
- rachio.webhook_url = hass_url + WEBHOOK_PATH
+ webhook_url_path = f"{WEBHOOK_PATH}-{entry.entry_id}"
+ rachio.webhook_url = f"{hass_url}{webhook_url_path}"
+
+ person = RachioPerson(rachio, entry)
# Get the API user
try:
- person = RachioPerson(hass, rachio, config[DOMAIN])
- except AssertionError as error:
+ await hass.async_add_executor_job(person.setup, hass)
+ # Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
+ # and there is not a reasonable timeout here so it can block for a long time
+ except RACHIO_API_EXCEPTIONS as error:
_LOGGER.error("Could not reach the Rachio API: %s", error)
- return False
+ raise ConfigEntryNotReady
# Check for Rachio controller devices
if not person.controllers:
@@ -132,173 +117,14 @@ def setup(hass, config) -> bool:
_LOGGER.info("%d Rachio device(s) found", len(person.controllers))
# Enable component
- hass.data[DOMAIN] = person
+ hass.data[DOMAIN][entry.entry_id] = person
+
+ # Listen for incoming webhook connections after the data is there
+ hass.http.register_view(RachioWebhookView(entry.entry_id, webhook_url_path))
- # Load platforms
for component in SUPPORTED_DOMAINS:
- discovery.load_platform(hass, component, DOMAIN, {}, config)
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
return True
-
-
-class RachioPerson:
- """Represent a Rachio user."""
-
- def __init__(self, hass, rachio, config):
- """Create an object from the provided API instance."""
- # Use API token to get user ID
- self._hass = hass
- self.rachio = rachio
- self.config = config
-
- response = rachio.person.getInfo()
- assert int(response[0][KEY_STATUS]) == 200, "API key error"
- self._id = response[1][KEY_ID]
-
- # Use user ID to get user data
- data = rachio.person.get(self._id)
- assert int(data[0][KEY_STATUS]) == 200, "User ID error"
- self.username = data[1][KEY_USERNAME]
- self._controllers = [
- RachioIro(self._hass, self.rachio, controller)
- for controller in data[1][KEY_DEVICES]
- ]
- _LOGGER.info('Using Rachio API as user "%s"', self.username)
-
- @property
- def user_id(self) -> str:
- """Get the user ID as defined by the Rachio API."""
- return self._id
-
- @property
- def controllers(self) -> list:
- """Get a list of controllers managed by this account."""
- return self._controllers
-
-
-class RachioIro:
- """Represent a Rachio Iro."""
-
- def __init__(self, hass, rachio, data):
- """Initialize a Rachio device."""
- self.hass = hass
- self.rachio = rachio
- self._id = data[KEY_ID]
- self._name = data[KEY_NAME]
- self._zones = data[KEY_ZONES]
- self._init_data = data
- _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id)
-
- # Listen for all updates
- self._init_webhooks()
-
- def _init_webhooks(self) -> None:
- """Start getting updates from the Rachio API."""
- current_webhook_id = None
-
- # First delete any old webhooks that may have stuck around
- def _deinit_webhooks(event) -> None:
- """Stop getting updates from the Rachio API."""
- webhooks = self.rachio.notification.getDeviceWebhook(self.controller_id)[1]
- for webhook in webhooks:
- if (
- webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID)
- or webhook[KEY_ID] == current_webhook_id
- ):
- self.rachio.notification.deleteWebhook(webhook[KEY_ID])
-
- _deinit_webhooks(None)
-
- # Choose which events to listen for and get their IDs
- event_types = []
- for event_type in self.rachio.notification.getWebhookEventType()[1]:
- if event_type[KEY_NAME] in LISTEN_EVENT_TYPES:
- event_types.append({"id": event_type[KEY_ID]})
-
- # Register to listen to these events from the device
- url = self.rachio.webhook_url
- auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth
- new_webhook = self.rachio.notification.postWebhook(
- self.controller_id, auth, url, event_types
- )
- # Save ID for deletion at shutdown
- current_webhook_id = new_webhook[1][KEY_ID]
- self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks)
-
- def __str__(self) -> str:
- """Display the controller as a string."""
- return f'Rachio controller "{self.name}"'
-
- @property
- def controller_id(self) -> str:
- """Return the Rachio API controller ID."""
- return self._id
-
- @property
- def name(self) -> str:
- """Return the user-defined name of the controller."""
- return self._name
-
- @property
- def current_schedule(self) -> str:
- """Return the schedule that the device is running right now."""
- return self.rachio.device.getCurrentSchedule(self.controller_id)[1]
-
- @property
- def init_data(self) -> dict:
- """Return the information used to set up the controller."""
- return self._init_data
-
- def list_zones(self, include_disabled=False) -> list:
- """Return a list of the zone dicts connected to the device."""
- # All zones
- if include_disabled:
- return self._zones
-
- # Only enabled zones
- return [z for z in self._zones if z[KEY_ENABLED]]
-
- def get_zone(self, zone_id) -> Optional[dict]:
- """Return the zone with the given ID."""
- for zone in self.list_zones(include_disabled=True):
- if zone[KEY_ID] == zone_id:
- return zone
-
- return None
-
- def stop_watering(self) -> None:
- """Stop watering all zones connected to this controller."""
- self.rachio.device.stopWater(self.controller_id)
- _LOGGER.info("Stopped watering of all zones on %s", str(self))
-
-
-class RachioWebhookView(HomeAssistantView):
- """Provide a page for the server to call."""
-
- SIGNALS = {
- TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE,
- TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE,
- TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE,
- }
-
- requires_auth = False # Handled separately
- url = WEBHOOK_PATH
- name = url[1:].replace("/", ":")
-
- @asyncio.coroutine
- async def post(self, request) -> web.Response:
- """Handle webhook calls from the server."""
- hass = request.app["hass"]
- data = await request.json()
-
- try:
- auth = data.get(KEY_EXTERNAL_ID, str()).split(":")[1]
- assert auth == hass.data[DOMAIN].rachio.webhook_auth
- except (AssertionError, IndexError):
- return web.Response(status=web.HTTPForbidden.status_code)
-
- update_type = data[KEY_TYPE]
- if update_type in self.SIGNALS:
- async_dispatcher_send(hass, self.SIGNALS[update_type], data)
-
- return web.Response(status=web.HTTPNoContent.status_code)
diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py
index f13eba59ac9..ab3a0b91276 100644
--- a/homeassistant/components/rachio/binary_sensor.py
+++ b/homeassistant/components/rachio/binary_sensor.py
@@ -2,10 +2,13 @@
from abc import abstractmethod
import logging
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.helpers.dispatcher import dispatcher_connect
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_CONNECTIVITY,
+ BinarySensorDevice,
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from . import (
+from .const import (
DOMAIN as DOMAIN_RACHIO,
KEY_DEVICE_ID,
KEY_STATUS,
@@ -13,44 +16,39 @@ from . import (
SIGNAL_RACHIO_CONTROLLER_UPDATE,
STATUS_OFFLINE,
STATUS_ONLINE,
- SUBTYPE_OFFLINE,
- SUBTYPE_ONLINE,
)
+from .entity import RachioDevice
+from .webhooks import SUBTYPE_OFFLINE, SUBTYPE_ONLINE
_LOGGER = logging.getLogger(__name__)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Rachio binary sensors."""
- devices = []
- for controller in hass.data[DOMAIN_RACHIO].controllers:
- devices.append(RachioControllerOnlineBinarySensor(hass, controller))
-
- add_entities(devices)
- _LOGGER.info("%d Rachio binary sensor(s) added", len(devices))
+ entities = await hass.async_add_executor_job(_create_entities, hass, config_entry)
+ async_add_entities(entities)
+ _LOGGER.info("%d Rachio binary sensor(s) added", len(entities))
-class RachioControllerBinarySensor(BinarySensorDevice):
+def _create_entities(hass, config_entry):
+ entities = []
+ for controller in hass.data[DOMAIN_RACHIO][config_entry.entry_id].controllers:
+ entities.append(RachioControllerOnlineBinarySensor(controller))
+ return entities
+
+
+class RachioControllerBinarySensor(RachioDevice, BinarySensorDevice):
"""Represent a binary sensor that reflects a Rachio state."""
- def __init__(self, hass, controller, poll=True):
+ def __init__(self, controller, poll=True):
"""Set up a new Rachio controller binary sensor."""
- self._controller = controller
-
+ super().__init__(controller)
+ self._undo_dispatcher = None
if poll:
self._state = self._poll_update()
else:
self._state = None
- dispatcher_connect(
- hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update
- )
-
- @property
- def should_poll(self) -> bool:
- """Declare that this entity pushes its state to HA."""
- return False
-
@property
def is_on(self) -> bool:
"""Return whether the sensor has a 'true' value."""
@@ -68,20 +66,29 @@ class RachioControllerBinarySensor(BinarySensorDevice):
@abstractmethod
def _poll_update(self, data=None) -> bool:
"""Request the state from the API."""
- pass
@abstractmethod
def _handle_update(self, *args, **kwargs) -> None:
"""Handle an update to the state of this sensor."""
- pass
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._undo_dispatcher = async_dispatcher_connect(
+ self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe from updates."""
+ if self._undo_dispatcher:
+ self._undo_dispatcher()
class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor):
"""Represent a binary sensor that reflects if the controller is online."""
- def __init__(self, hass, controller):
+ def __init__(self, controller):
"""Set up a new Rachio controller online binary sensor."""
- super().__init__(hass, controller, poll=False)
+ super().__init__(controller, poll=False)
self._state = self._poll_update(controller.init_data)
@property
@@ -97,7 +104,7 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor):
@property
def device_class(self) -> str:
"""Return the class of this device, from component DEVICE_CLASSES."""
- return "connectivity"
+ return DEVICE_CLASS_CONNECTIVITY
@property
def icon(self) -> str:
diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py
new file mode 100644
index 00000000000..64e78a24f4a
--- /dev/null
+++ b/homeassistant/components/rachio/config_flow.py
@@ -0,0 +1,135 @@
+"""Config flow for Rachio integration."""
+import logging
+
+from rachiopy import Rachio
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import callback
+
+from .const import (
+ CONF_MANUAL_RUN_MINS,
+ DEFAULT_MANUAL_RUN_MINS,
+ KEY_ID,
+ KEY_STATUS,
+ KEY_USERNAME,
+ RACHIO_API_EXCEPTIONS,
+)
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}, extra=vol.ALLOW_EXTRA)
+
+
+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.
+ """
+ rachio = Rachio(data[CONF_API_KEY])
+ username = None
+ try:
+ data = await hass.async_add_executor_job(rachio.person.getInfo)
+ _LOGGER.debug("rachio.person.getInfo: %s", data)
+ if int(data[0][KEY_STATUS]) != 200:
+ raise InvalidAuth
+
+ rachio_id = data[1][KEY_ID]
+ data = await hass.async_add_executor_job(rachio.person.get, rachio_id)
+ _LOGGER.debug("rachio.person.get: %s", data)
+ if int(data[0][KEY_STATUS]) != 200:
+ raise CannotConnect
+
+ username = data[1][KEY_USERNAME]
+ # Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
+ except RACHIO_API_EXCEPTIONS as error:
+ _LOGGER.error("Could not reach the Rachio API: %s", error)
+ raise CannotConnect
+
+ # Return info that you want to store in the config entry.
+ return {"title": username}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Rachio."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ await self.async_set_unique_id(user_input[CONF_API_KEY])
+ self._abort_if_unique_id_configured()
+ try:
+ info = await validate_input(self.hass, user_input)
+ return self.async_create_entry(title=info["title"], data=user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_homekit(self, homekit_info):
+ """Handle HomeKit discovery."""
+ if self._async_current_entries():
+ # We can see rachio on the network to tell them to configure
+ # it, but since the device will not give up the account it is
+ # bound to and there can be multiple rachio systems on a single
+ # account, we avoid showing the device as discovered once
+ # they already have one configured as they can always
+ # add a new one via "+"
+ return self.async_abort(reason="already_configured")
+ return await self.async_step_user()
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ return await self.async_step_user(user_input)
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler(config_entry)
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a option flow for Rachio."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry):
+ """Initialize options flow."""
+ self.config_entry = config_entry
+
+ async def async_step_init(self, user_input=None):
+ """Handle options flow."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ data_schema = vol.Schema(
+ {
+ vol.Optional(
+ CONF_MANUAL_RUN_MINS,
+ default=self.config_entry.options.get(
+ CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS
+ ),
+ ): int
+ }
+ )
+ return self.async_show_form(step_id="init", data_schema=data_schema)
+
+
+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/rachio/const.py b/homeassistant/components/rachio/const.py
new file mode 100644
index 00000000000..13e8029b512
--- /dev/null
+++ b/homeassistant/components/rachio/const.py
@@ -0,0 +1,56 @@
+"""Constants for rachio."""
+
+import http.client
+import ssl
+
+DEFAULT_NAME = "Rachio"
+
+DOMAIN = "rachio"
+
+CONF_CUSTOM_URL = "hass_url_override"
+# Manual run length
+CONF_MANUAL_RUN_MINS = "manual_run_mins"
+DEFAULT_MANUAL_RUN_MINS = 10
+
+# Keys used in the API JSON
+KEY_DEVICE_ID = "deviceId"
+KEY_IMAGE_URL = "imageUrl"
+KEY_DEVICES = "devices"
+KEY_ENABLED = "enabled"
+KEY_EXTERNAL_ID = "externalId"
+KEY_ID = "id"
+KEY_NAME = "name"
+KEY_MODEL = "model"
+KEY_ON = "on"
+KEY_STATUS = "status"
+KEY_SUBTYPE = "subType"
+KEY_SUMMARY = "summary"
+KEY_SERIAL_NUMBER = "serialNumber"
+KEY_MAC_ADDRESS = "macAddress"
+KEY_TYPE = "type"
+KEY_URL = "url"
+KEY_USERNAME = "username"
+KEY_ZONE_ID = "zoneId"
+KEY_ZONE_NUMBER = "zoneNumber"
+KEY_ZONES = "zones"
+KEY_CUSTOM_SHADE = "customShade"
+KEY_CUSTOM_CROP = "customCrop"
+
+ATTR_ZONE_TYPE = "type"
+ATTR_ZONE_SHADE = "shade"
+
+# Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
+RACHIO_API_EXCEPTIONS = (
+ http.client.HTTPException,
+ ssl.SSLError,
+ OSError,
+ AssertionError,
+)
+
+STATUS_ONLINE = "ONLINE"
+STATUS_OFFLINE = "OFFLINE"
+
+SIGNAL_RACHIO_UPDATE = DOMAIN + "_update"
+SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + "_controller"
+SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + "_zone"
+SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule"
diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py
new file mode 100644
index 00000000000..949957ae8ec
--- /dev/null
+++ b/homeassistant/components/rachio/device.py
@@ -0,0 +1,180 @@
+"""Adapter to wrap the rachiopy api for home assistant."""
+
+import logging
+from typing import Optional
+
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+
+from .const import (
+ KEY_DEVICES,
+ KEY_ENABLED,
+ KEY_EXTERNAL_ID,
+ KEY_ID,
+ KEY_MAC_ADDRESS,
+ KEY_MODEL,
+ KEY_NAME,
+ KEY_SERIAL_NUMBER,
+ KEY_STATUS,
+ KEY_USERNAME,
+ KEY_ZONES,
+)
+from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class RachioPerson:
+ """Represent a Rachio user."""
+
+ def __init__(self, rachio, config_entry):
+ """Create an object from the provided API instance."""
+ # Use API token to get user ID
+ self.rachio = rachio
+ self.config_entry = config_entry
+ self.username = None
+ self._id = None
+ self._controllers = []
+
+ def setup(self, hass):
+ """Rachio device setup."""
+ response = self.rachio.person.getInfo()
+ assert int(response[0][KEY_STATUS]) == 200, "API key error"
+ self._id = response[1][KEY_ID]
+
+ # Use user ID to get user data
+ data = self.rachio.person.get(self._id)
+ assert int(data[0][KEY_STATUS]) == 200, "User ID error"
+ self.username = data[1][KEY_USERNAME]
+ devices = data[1][KEY_DEVICES]
+ for controller in devices:
+ webhooks = self.rachio.notification.getDeviceWebhook(controller[KEY_ID])[1]
+ # The API does not provide a way to tell if a controller is shared
+ # or if they are the owner. To work around this problem we fetch the webooks
+ # before we setup the device so we can skip it instead of failing.
+ # webhooks are normally a list, however if there is an error
+ # rachio hands us back a dict
+ if isinstance(webhooks, dict):
+ _LOGGER.error(
+ "Failed to add rachio controller '%s' because of an error: %s",
+ controller[KEY_NAME],
+ webhooks.get("error", "Unknown Error"),
+ )
+ continue
+
+ rachio_iro = RachioIro(hass, self.rachio, controller, webhooks)
+ rachio_iro.setup()
+ self._controllers.append(rachio_iro)
+ _LOGGER.info('Using Rachio API as user "%s"', self.username)
+
+ @property
+ def user_id(self) -> str:
+ """Get the user ID as defined by the Rachio API."""
+ return self._id
+
+ @property
+ def controllers(self) -> list:
+ """Get a list of controllers managed by this account."""
+ return self._controllers
+
+
+class RachioIro:
+ """Represent a Rachio Iro."""
+
+ def __init__(self, hass, rachio, data, webhooks):
+ """Initialize a Rachio device."""
+ self.hass = hass
+ self.rachio = rachio
+ self._id = data[KEY_ID]
+ self.name = data[KEY_NAME]
+ self.serial_number = data[KEY_SERIAL_NUMBER]
+ self.mac_address = data[KEY_MAC_ADDRESS]
+ self.model = data[KEY_MODEL]
+ self._zones = data[KEY_ZONES]
+ self._init_data = data
+ self._webhooks = webhooks
+ _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id)
+
+ def setup(self):
+ """Rachio Iro setup for webhooks."""
+ # Listen for all updates
+ self._init_webhooks()
+
+ def _init_webhooks(self) -> None:
+ """Start getting updates from the Rachio API."""
+ current_webhook_id = None
+
+ # First delete any old webhooks that may have stuck around
+ def _deinit_webhooks(_) -> None:
+ """Stop getting updates from the Rachio API."""
+ if not self._webhooks:
+ # We fetched webhooks when we created the device, however if we call _init_webhooks
+ # again we need to fetch again
+ self._webhooks = self.rachio.notification.getDeviceWebhook(
+ self.controller_id
+ )[1]
+ for webhook in self._webhooks:
+ if (
+ webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID)
+ or webhook[KEY_ID] == current_webhook_id
+ ):
+ self.rachio.notification.deleteWebhook(webhook[KEY_ID])
+ self._webhooks = None
+
+ _deinit_webhooks(None)
+
+ # Choose which events to listen for and get their IDs
+ event_types = []
+ for event_type in self.rachio.notification.getWebhookEventType()[1]:
+ if event_type[KEY_NAME] in LISTEN_EVENT_TYPES:
+ event_types.append({"id": event_type[KEY_ID]})
+
+ # Register to listen to these events from the device
+ url = self.rachio.webhook_url
+ auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth
+ new_webhook = self.rachio.notification.postWebhook(
+ self.controller_id, auth, url, event_types
+ )
+ # Save ID for deletion at shutdown
+ current_webhook_id = new_webhook[1][KEY_ID]
+ self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks)
+
+ def __str__(self) -> str:
+ """Display the controller as a string."""
+ return f'Rachio controller "{self.name}"'
+
+ @property
+ def controller_id(self) -> str:
+ """Return the Rachio API controller ID."""
+ return self._id
+
+ @property
+ def current_schedule(self) -> str:
+ """Return the schedule that the device is running right now."""
+ return self.rachio.device.getCurrentSchedule(self.controller_id)[1]
+
+ @property
+ def init_data(self) -> dict:
+ """Return the information used to set up the controller."""
+ return self._init_data
+
+ def list_zones(self, include_disabled=False) -> list:
+ """Return a list of the zone dicts connected to the device."""
+ # All zones
+ if include_disabled:
+ return self._zones
+
+ # Only enabled zones
+ return [z for z in self._zones if z[KEY_ENABLED]]
+
+ def get_zone(self, zone_id) -> Optional[dict]:
+ """Return the zone with the given ID."""
+ for zone in self.list_zones(include_disabled=True):
+ if zone[KEY_ID] == zone_id:
+ return zone
+
+ return None
+
+ def stop_watering(self) -> None:
+ """Stop watering all zones connected to this controller."""
+ self.rachio.device.stopWater(self.controller_id)
+ _LOGGER.info("Stopped watering of all zones on %s", str(self))
diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py
new file mode 100644
index 00000000000..379c4e785e5
--- /dev/null
+++ b/homeassistant/components/rachio/entity.py
@@ -0,0 +1,33 @@
+"""Adapter to wrap the rachiopy api for home assistant."""
+
+from homeassistant.helpers import device_registry
+from homeassistant.helpers.entity import Entity
+
+from .const import DEFAULT_NAME, DOMAIN
+
+
+class RachioDevice(Entity):
+ """Base class for rachio devices."""
+
+ def __init__(self, controller):
+ """Initialize a Rachio device."""
+ super().__init__()
+ self._controller = controller
+
+ @property
+ def should_poll(self) -> bool:
+ """Declare that this entity pushes its state to HA."""
+ return False
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ return {
+ "identifiers": {(DOMAIN, self._controller.serial_number,)},
+ "connections": {
+ (device_registry.CONNECTION_NETWORK_MAC, self._controller.mac_address,)
+ },
+ "name": self._controller.name,
+ "model": self._controller.model,
+ "manufacturer": DEFAULT_NAME,
+ }
diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json
index fae640f9262..9b293ee5df2 100644
--- a/homeassistant/components/rachio/manifest.json
+++ b/homeassistant/components/rachio/manifest.json
@@ -2,7 +2,17 @@
"domain": "rachio",
"name": "Rachio",
"documentation": "https://www.home-assistant.io/integrations/rachio",
- "requirements": ["rachiopy==0.1.3"],
- "dependencies": ["http"],
- "codeowners": []
+ "requirements": [
+ "rachiopy==0.1.3"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": ["@bdraco"],
+ "config_flow": true,
+ "homekit": {
+ "models": [
+ "Rachio"
+ ]
+ }
}
diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json
new file mode 100644
index 00000000000..391320289db
--- /dev/null
+++ b/homeassistant/components/rachio/strings.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "title": "Rachio",
+ "step": {
+ "user": {
+ "title": "Connect to your Rachio device",
+ "description" : "You will need the API Key from https://app.rach.io/. Select 'Account Settings, and then click on 'GET API KEY'.",
+ "data": {
+ "api_key": "The API key for the Rachio account."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "Device is already configured"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "manual_run_mins": "For how long, in minutes, to turn on a station when the switch is enabled."
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py
index a3a4f6bcca1..5df084a11a4 100644
--- a/homeassistant/components/rachio/switch.py
+++ b/homeassistant/components/rachio/switch.py
@@ -4,14 +4,20 @@ from datetime import timedelta
import logging
from homeassistant.components.switch import SwitchDevice
-from homeassistant.helpers.dispatcher import dispatcher_connect
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from . import (
+from .const import (
+ ATTR_ZONE_SHADE,
+ ATTR_ZONE_TYPE,
CONF_MANUAL_RUN_MINS,
+ DEFAULT_MANUAL_RUN_MINS,
DOMAIN as DOMAIN_RACHIO,
+ KEY_CUSTOM_CROP,
+ KEY_CUSTOM_SHADE,
KEY_DEVICE_ID,
KEY_ENABLED,
KEY_ID,
+ KEY_IMAGE_URL,
KEY_NAME,
KEY_ON,
KEY_SUBTYPE,
@@ -20,6 +26,9 @@ from . import (
KEY_ZONE_NUMBER,
SIGNAL_RACHIO_CONTROLLER_UPDATE,
SIGNAL_RACHIO_ZONE_UPDATE,
+)
+from .entity import RachioDevice
+from .webhooks import (
SUBTYPE_SLEEP_MODE_OFF,
SUBTYPE_SLEEP_MODE_ON,
SUBTYPE_ZONE_COMPLETED,
@@ -33,42 +42,42 @@ ATTR_ZONE_SUMMARY = "Summary"
ATTR_ZONE_NUMBER = "Zone number"
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Rachio switches."""
- manual_run_time = timedelta(
- minutes=hass.data[DOMAIN_RACHIO].config.get(CONF_MANUAL_RUN_MINS)
- )
- _LOGGER.info("Rachio run time is %s", str(manual_run_time))
-
# Add all zones from all controllers as switches
- devices = []
- for controller in hass.data[DOMAIN_RACHIO].controllers:
- devices.append(RachioStandbySwitch(hass, controller))
-
- for zone in controller.list_zones():
- devices.append(RachioZone(hass, controller, zone, manual_run_time))
-
- add_entities(devices)
- _LOGGER.info("%d Rachio switch(es) added", len(devices))
+ entities = await hass.async_add_executor_job(_create_entities, hass, config_entry)
+ async_add_entities(entities)
+ _LOGGER.info("%d Rachio switch(es) added", len(entities))
-class RachioSwitch(SwitchDevice):
+def _create_entities(hass, config_entry):
+ entities = []
+ person = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
+ # Fetch the schedule once at startup
+ # in order to avoid every zone doing it
+ for controller in person.controllers:
+ entities.append(RachioStandbySwitch(controller))
+ zones = controller.list_zones()
+ current_schedule = controller.current_schedule
+ _LOGGER.debug("Rachio setting up zones: %s", zones)
+ for zone in zones:
+ _LOGGER.debug("Rachio setting up zone: %s", zone)
+ entities.append(RachioZone(person, controller, zone, current_schedule))
+ return entities
+
+
+class RachioSwitch(RachioDevice, SwitchDevice):
"""Represent a Rachio state that can be toggled."""
def __init__(self, controller, poll=True):
"""Initialize a new Rachio switch."""
- self._controller = controller
+ super().__init__(controller)
if poll:
self._state = self._poll_update()
else:
self._state = None
- @property
- def should_poll(self) -> bool:
- """Declare that this entity pushes its state to HA."""
- return False
-
@property
def name(self) -> str:
"""Get a name for this switch."""
@@ -82,7 +91,6 @@ class RachioSwitch(SwitchDevice):
@abstractmethod
def _poll_update(self, data=None) -> bool:
"""Poll the API."""
- pass
def _handle_any_update(self, *args, **kwargs) -> None:
"""Determine whether an update event applies to this device."""
@@ -96,17 +104,13 @@ class RachioSwitch(SwitchDevice):
@abstractmethod
def _handle_update(self, *args, **kwargs) -> None:
"""Handle incoming webhook data."""
- pass
class RachioStandbySwitch(RachioSwitch):
"""Representation of a standby status/button."""
- def __init__(self, hass, controller):
+ def __init__(self, controller):
"""Instantiate a new Rachio standby mode switch."""
- dispatcher_connect(
- hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update
- )
super().__init__(controller, poll=True)
self._poll_update(controller.init_data)
@@ -149,22 +153,32 @@ class RachioStandbySwitch(RachioSwitch):
"""Resume controller functionality."""
self._controller.rachio.device.on(self._controller.controller_id)
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update
+ )
+
class RachioZone(RachioSwitch):
"""Representation of one zone of sprinklers connected to the Rachio Iro."""
- def __init__(self, hass, controller, data, manual_run_time):
+ def __init__(self, person, controller, data, current_schedule):
"""Initialize a new Rachio Zone."""
self._id = data[KEY_ID]
+ _LOGGER.debug("zone_data: %s", data)
self._zone_name = data[KEY_NAME]
self._zone_number = data[KEY_ZONE_NUMBER]
self._zone_enabled = data[KEY_ENABLED]
- self._manual_run_time = manual_run_time
+ self._entity_picture = data.get(KEY_IMAGE_URL)
+ self._person = person
+ self._shade_type = data.get(KEY_CUSTOM_SHADE, {}).get(KEY_NAME)
+ self._zone_type = data.get(KEY_CUSTOM_CROP, {}).get(KEY_NAME)
self._summary = str()
- super().__init__(controller)
-
- # Listen for all zone updates
- dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE, self._handle_update)
+ self._current_schedule = current_schedule
+ super().__init__(controller, poll=False)
+ self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID)
+ self._undo_dispatcher = None
def __str__(self):
"""Display the zone as a string."""
@@ -195,10 +209,20 @@ class RachioZone(RachioSwitch):
"""Return whether the zone is allowed to run."""
return self._zone_enabled
+ @property
+ def entity_picture(self):
+ """Return the entity picture to use in the frontend, if any."""
+ return self._entity_picture
+
@property
def state_attributes(self) -> dict:
"""Return the optional state attributes."""
- return {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary}
+ props = {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary}
+ if self._shade_type:
+ props[ATTR_ZONE_SHADE] = self._shade_type
+ if self._zone_type:
+ props[ATTR_ZONE_TYPE] = self._zone_type
+ return props
def turn_on(self, **kwargs) -> None:
"""Start watering this zone."""
@@ -206,8 +230,18 @@ class RachioZone(RachioSwitch):
self.turn_off()
# Start this zone
- self._controller.rachio.zone.start(self.zone_id, self._manual_run_time.seconds)
- _LOGGER.debug("Watering %s on %s", self.name, self._controller.name)
+ manual_run_time = timedelta(
+ minutes=self._person.config_entry.options.get(
+ CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS
+ )
+ )
+ self._controller.rachio.zone.start(self.zone_id, manual_run_time.seconds)
+ _LOGGER.debug(
+ "Watering %s on %s for %s",
+ self.name,
+ self._controller.name,
+ str(manual_run_time),
+ )
def turn_off(self, **kwargs) -> None:
"""Stop watering all zones."""
@@ -215,8 +249,8 @@ class RachioZone(RachioSwitch):
def _poll_update(self, data=None) -> bool:
"""Poll the API to check whether the zone is running."""
- schedule = self._controller.current_schedule
- return self.zone_id == schedule.get(KEY_ZONE_ID)
+ self._current_schedule = self._controller.current_schedule
+ return self.zone_id == self._current_schedule.get(KEY_ZONE_ID)
def _handle_update(self, *args, **kwargs) -> None:
"""Handle incoming webhook zone data."""
@@ -231,3 +265,14 @@ class RachioZone(RachioSwitch):
self._state = False
self.schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._undo_dispatcher = async_dispatcher_connect(
+ self.hass, SIGNAL_RACHIO_ZONE_UPDATE, self._handle_update
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe from updates."""
+ if self._undo_dispatcher:
+ self._undo_dispatcher()
diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py
new file mode 100644
index 00000000000..c12f2ccfd3e
--- /dev/null
+++ b/homeassistant/components/rachio/webhooks.py
@@ -0,0 +1,96 @@
+"""Webhooks used by rachio."""
+
+import logging
+
+from aiohttp import web
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import URL_API
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .const import (
+ DOMAIN,
+ KEY_EXTERNAL_ID,
+ KEY_TYPE,
+ SIGNAL_RACHIO_CONTROLLER_UPDATE,
+ SIGNAL_RACHIO_SCHEDULE_UPDATE,
+ SIGNAL_RACHIO_ZONE_UPDATE,
+)
+
+# Device webhook values
+TYPE_CONTROLLER_STATUS = "DEVICE_STATUS"
+SUBTYPE_OFFLINE = "OFFLINE"
+SUBTYPE_ONLINE = "ONLINE"
+SUBTYPE_OFFLINE_NOTIFICATION = "OFFLINE_NOTIFICATION"
+SUBTYPE_COLD_REBOOT = "COLD_REBOOT"
+SUBTYPE_SLEEP_MODE_ON = "SLEEP_MODE_ON"
+SUBTYPE_SLEEP_MODE_OFF = "SLEEP_MODE_OFF"
+SUBTYPE_BROWNOUT_VALVE = "BROWNOUT_VALVE"
+SUBTYPE_RAIN_SENSOR_DETECTION_ON = "RAIN_SENSOR_DETECTION_ON"
+SUBTYPE_RAIN_SENSOR_DETECTION_OFF = "RAIN_SENSOR_DETECTION_OFF"
+SUBTYPE_RAIN_DELAY_ON = "RAIN_DELAY_ON"
+SUBTYPE_RAIN_DELAY_OFF = "RAIN_DELAY_OFF"
+
+# Schedule webhook values
+TYPE_SCHEDULE_STATUS = "SCHEDULE_STATUS"
+SUBTYPE_SCHEDULE_STARTED = "SCHEDULE_STARTED"
+SUBTYPE_SCHEDULE_STOPPED = "SCHEDULE_STOPPED"
+SUBTYPE_SCHEDULE_COMPLETED = "SCHEDULE_COMPLETED"
+SUBTYPE_WEATHER_NO_SKIP = "WEATHER_INTELLIGENCE_NO_SKIP"
+SUBTYPE_WEATHER_SKIP = "WEATHER_INTELLIGENCE_SKIP"
+SUBTYPE_WEATHER_CLIMATE_SKIP = "WEATHER_INTELLIGENCE_CLIMATE_SKIP"
+SUBTYPE_WEATHER_FREEZE = "WEATHER_INTELLIGENCE_FREEZE"
+
+# Zone webhook values
+TYPE_ZONE_STATUS = "ZONE_STATUS"
+SUBTYPE_ZONE_STARTED = "ZONE_STARTED"
+SUBTYPE_ZONE_STOPPED = "ZONE_STOPPED"
+SUBTYPE_ZONE_COMPLETED = "ZONE_COMPLETED"
+SUBTYPE_ZONE_CYCLING = "ZONE_CYCLING"
+SUBTYPE_ZONE_CYCLING_COMPLETED = "ZONE_CYCLING_COMPLETED"
+
+# Webhook callbacks
+LISTEN_EVENT_TYPES = ["DEVICE_STATUS_EVENT", "ZONE_STATUS_EVENT"]
+WEBHOOK_CONST_ID = "homeassistant.rachio:"
+WEBHOOK_PATH = URL_API + DOMAIN
+
+SIGNAL_MAP = {
+ TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE,
+ TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE,
+ TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE,
+}
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class RachioWebhookView(HomeAssistantView):
+ """Provide a page for the server to call."""
+
+ requires_auth = False # Handled separately
+
+ def __init__(self, entry_id, webhook_url):
+ """Initialize the instance of the view."""
+ self._entry_id = entry_id
+ self.url = webhook_url
+ self.name = webhook_url[1:].replace("/", ":")
+ _LOGGER.debug(
+ "Initialize webhook at url: %s, with name %s", self.url, self.name
+ )
+
+ async def post(self, request) -> web.Response:
+ """Handle webhook calls from the server."""
+ hass = request.app["hass"]
+ data = await request.json()
+
+ try:
+ auth = data.get(KEY_EXTERNAL_ID, str()).split(":")[1]
+ assert auth == hass.data[DOMAIN][self._entry_id].rachio.webhook_auth
+ except (AssertionError, IndexError):
+ return web.Response(status=web.HTTPForbidden.status_code)
+
+ update_type = data[KEY_TYPE]
+ if update_type in SIGNAL_MAP:
+ async_dispatcher_send(hass, SIGNAL_MAP[update_type], data)
+
+ return web.Response(status=web.HTTPNoContent.status_code)
diff --git a/homeassistant/components/rainmachine/.translations/fr.json b/homeassistant/components/rainmachine/.translations/fr.json
index 64d8f582ad3..48ae0c049c2 100644
--- a/homeassistant/components/rainmachine/.translations/fr.json
+++ b/homeassistant/components/rainmachine/.translations/fr.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Ce contr\u00f4leur RainMachine est d\u00e9j\u00e0 configur\u00e9."
+ },
"error": {
"identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9",
"invalid_credentials": "Informations d'identification invalides"
diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json
index 980c2c693ce..34b74f56c49 100644
--- a/homeassistant/components/rainmachine/.translations/no.json
+++ b/homeassistant/components/rainmachine/.translations/no.json
@@ -12,7 +12,7 @@
"data": {
"ip_address": "Vertsnavn eller IP-adresse",
"password": "Passord",
- "port": "Port"
+ "port": ""
},
"title": "Fyll ut informasjonen din"
}
diff --git a/homeassistant/components/rainmachine/.translations/sl.json b/homeassistant/components/rainmachine/.translations/sl.json
index 10d05fadf93..68c466150f1 100644
--- a/homeassistant/components/rainmachine/.translations/sl.json
+++ b/homeassistant/components/rainmachine/.translations/sl.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Ta regulator RainMachine je \u017ee konfiguriran."
+ },
"error": {
"identifier_exists": "Ra\u010dun \u017ee registriran",
"invalid_credentials": "Neveljavne poverilnice"
diff --git a/homeassistant/components/rainmachine/.translations/sv.json b/homeassistant/components/rainmachine/.translations/sv.json
index 03f9c671c35..864c1105446 100644
--- a/homeassistant/components/rainmachine/.translations/sv.json
+++ b/homeassistant/components/rainmachine/.translations/sv.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Denna RainMachine-enhet \u00e4r redan konfigurerad"
+ },
"error": {
"identifier_exists": "Kontot \u00e4r redan registrerat",
"invalid_credentials": "Ogiltiga autentiseringsuppgifter"
diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py
index d2c0281fa80..92c14d0e0cb 100644
--- a/homeassistant/components/rainmachine/__init__.py
+++ b/homeassistant/components/rainmachine/__init__.py
@@ -451,9 +451,16 @@ class RainMachineEntity(Entity):
@callback
def _update_state(self):
"""Update the state."""
- self.async_schedule_update_ha_state(True)
+ self.update_from_latest_data()
+ self.async_write_ha_state()
async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed."""
for handler in self._dispatcher_handlers:
handler()
+ self._dispatcher_handlers = []
+
+ @callback
+ def update_from_latest_data(self):
+ """Update the entity."""
+ raise NotImplementedError
diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py
index 34b8de80b88..409ad0c9980 100644
--- a/homeassistant/components/rainmachine/binary_sensor.py
+++ b/homeassistant/components/rainmachine/binary_sensor.py
@@ -2,6 +2,7 @@
import logging
from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import RainMachineEntity
@@ -129,9 +130,15 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice):
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()
+ self.update_from_latest_data()
- async def async_update(self):
+ 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)
+
+ @callback
+ def update_from_latest_data(self):
"""Update the state."""
if self._sensor_type == TYPE_FLOW_SENSOR:
self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get(
@@ -157,8 +164,3 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice):
self._state = self.rainmachine.data[DATA_RESTRICTIONS_CURRENT]["rainSensor"]
elif self._sensor_type == TYPE_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/sensor.py b/homeassistant/components/rainmachine/sensor.py
index 8487628a32b..371ba00dfd0 100644
--- a/homeassistant/components/rainmachine/sensor.py
+++ b/homeassistant/components/rainmachine/sensor.py
@@ -1,6 +1,7 @@
"""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 RainMachineEntity
@@ -146,9 +147,15 @@ class RainMachineSensor(RainMachineEntity):
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()
+ self.update_from_latest_data()
- async def async_update(self):
+ 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)
+
+ @callback
+ def update_from_latest_data(self):
"""Update the sensor's state."""
if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3:
self._state = self.rainmachine.data[DATA_PROVISION_SETTINGS]["system"].get(
@@ -178,8 +185,3 @@ class RainMachineSensor(RainMachineEntity):
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/switch.py b/homeassistant/components/rainmachine/switch.py
index 2bf63dbf495..264de1d6782 100644
--- a/homeassistant/components/rainmachine/switch.py
+++ b/homeassistant/components/rainmachine/switch.py
@@ -6,6 +6,7 @@ from regenmaschine.errors import RequestError
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
from . import RainMachineEntity
@@ -205,7 +206,8 @@ class RainMachineProgram(RainMachineSwitch):
self.rainmachine.controller.programs.start(self._rainmachine_entity_id)
)
- async def async_update(self) -> None:
+ @callback
+ def update_from_latest_data(self) -> None:
"""Update info for the program."""
[self._switch_data] = [
p
@@ -269,7 +271,8 @@ class RainMachineZone(RainMachineSwitch):
)
)
- async def async_update(self) -> None:
+ @callback
+ def update_from_latest_data(self) -> None:
"""Update info for the zone."""
[self._switch_data] = [
z
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index 9fa09f9a478..04fabd28bc6 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -2,7 +2,7 @@
"domain": "recorder",
"name": "Recorder",
"documentation": "https://www.home-assistant.io/integrations/recorder",
- "requirements": ["sqlalchemy==1.3.13"],
+ "requirements": ["sqlalchemy==1.3.15"],
"dependencies": [],
"codeowners": [],
"quality_scale": "internal"
diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py
index 3a71ebb94d1..580c0a3b152 100644
--- a/homeassistant/components/remote/__init__.py
+++ b/homeassistant/components/remote/__init__.py
@@ -24,7 +24,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.loader import bind_hass
-# mypy: allow-untyped-calls
+# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
_LOGGER = logging.getLogger(__name__)
@@ -114,9 +114,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
- return cast(
- bool, await cast(EntityComponent, hass.data[DOMAIN]).async_setup_entry(entry)
- )
+ return await cast(EntityComponent, hass.data[DOMAIN]).async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
diff --git a/homeassistant/components/ring/.translations/es.json b/homeassistant/components/ring/.translations/es.json
index 6bdd20d361b..f5598c56d70 100644
--- a/homeassistant/components/ring/.translations/es.json
+++ b/homeassistant/components/ring/.translations/es.json
@@ -19,9 +19,9 @@
"password": "Contrase\u00f1a",
"username": "Usuario"
},
- "title": "Iniciar sesi\u00f3n con cuenta de Anillo"
+ "title": "Iniciar sesi\u00f3n con cuenta de Ring"
}
},
- "title": "Anillo"
+ "title": "Ring"
}
}
\ No newline at end of file
diff --git a/homeassistant/components/ring/.translations/no.json b/homeassistant/components/ring/.translations/no.json
index 27dd7438f4a..bd8699a3617 100644
--- a/homeassistant/components/ring/.translations/no.json
+++ b/homeassistant/components/ring/.translations/no.json
@@ -22,6 +22,6 @@
"title": "Logg p\u00e5 med din Ring-konto"
}
},
- "title": "Ring"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/ca.json b/homeassistant/components/roku/.translations/ca.json
new file mode 100644
index 00000000000..3f9897784f9
--- /dev/null
+++ b/homeassistant/components/roku/.translations/ca.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu Roku ja est\u00e0 configurat",
+ "unknown": "Error inesperat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar, torna-ho a provar"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Vols configurar {name}? Es substituiran les configuracions manuals d'aquest dispositiu en els arxius yaml.",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3 o adre\u00e7a IP"
+ },
+ "description": "Introdueix la teva informaci\u00f3 de Roku.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/cs.json b/homeassistant/components/roku/.translations/cs.json
new file mode 100644
index 00000000000..3b814303e69
--- /dev/null
+++ b/homeassistant/components/roku/.translations/cs.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "unknown": "Neo\u010dek\u00e1van\u00e1 chyba"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/de.json b/homeassistant/components/roku/.translations/de.json
new file mode 100644
index 00000000000..3954d9d549d
--- /dev/null
+++ b/homeassistant/components/roku/.translations/de.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Das Roku-Ger\u00e4t ist bereits konfiguriert",
+ "unknown": "Unerwarteter Fehler"
+ },
+ "error": {
+ "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "one": "eins",
+ "other": "andere"
+ },
+ "description": "M\u00f6chten Sie {name} einrichten?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Host oder IP-Adresse"
+ },
+ "description": "Geben Sie Ihre Roku-Informationen ein.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/en.json b/homeassistant/components/roku/.translations/en.json
new file mode 100644
index 00000000000..30c53e1d89e
--- /dev/null
+++ b/homeassistant/components/roku/.translations/en.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Roku device is already configured",
+ "unknown": "Unexpected error"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Do you want to set up {name}?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Host or IP address"
+ },
+ "description": "Enter your Roku information.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/es.json b/homeassistant/components/roku/.translations/es.json
new file mode 100644
index 00000000000..ffa850f6ebe
--- /dev/null
+++ b/homeassistant/components/roku/.translations/es.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo Roku ya est\u00e1 configurado",
+ "unknown": "Error inesperado"
+ },
+ "error": {
+ "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo."
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "\u00bfQuieres configurar {name}?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Host o direcci\u00f3n IP"
+ },
+ "description": "Introduce tu informaci\u00f3n de Roku.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/fr.json b/homeassistant/components/roku/.translations/fr.json
new file mode 100644
index 00000000000..a76f68f2f61
--- /dev/null
+++ b/homeassistant/components/roku/.translations/fr.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le p\u00e9riph\u00e9rique Roku est d\u00e9j\u00e0 configur\u00e9",
+ "unknown": "Erreur inattendue"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Voulez-vous configurer {name} ?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te ou adresse IP"
+ },
+ "description": "Entrez vos informations Roku.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/it.json b/homeassistant/components/roku/.translations/it.json
new file mode 100644
index 00000000000..c530504c6ec
--- /dev/null
+++ b/homeassistant/components/roku/.translations/it.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo Roku \u00e8 gi\u00e0 configurato",
+ "unknown": "Errore imprevisto"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "one": "uno",
+ "other": "altri"
+ },
+ "description": "Vuoi impostare {name}?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Host o indirizzo IP"
+ },
+ "description": "Inserisci le tue informazioni Roku.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/ko.json b/homeassistant/components/roku/.translations/ko.json
new file mode 100644
index 00000000000..d7cad509da1
--- /dev/null
+++ b/homeassistant/components/roku/.translations/ko.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Roku \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "{name} \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c"
+ },
+ "description": "Roku \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/lb.json b/homeassistant/components/roku/.translations/lb.json
new file mode 100644
index 00000000000..da6136334ce
--- /dev/null
+++ b/homeassistant/components/roku/.translations/lb.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Roku Apparat ass scho konfigur\u00e9iert",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol."
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "one": "Een",
+ "other": "Aaner"
+ },
+ "description": "Soll {name} konfigur\u00e9iert ginn?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Numm oder IP Adresse"
+ },
+ "description": "F\u00ebll d\u00e9ng Roku Informatiounen aus.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/no.json b/homeassistant/components/roku/.translations/no.json
new file mode 100644
index 00000000000..df56aa5d35d
--- /dev/null
+++ b/homeassistant/components/roku/.translations/no.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Roku-enheten er allerede konfigurert",
+ "unknown": "Uventet feil"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "Vil du sette opp {name} ?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Vert eller IP-adresse"
+ },
+ "description": "Skriv inn Roku-informasjonen din.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/pl.json b/homeassistant/components/roku/.translations/pl.json
new file mode 100644
index 00000000000..b92aab58df6
--- /dev/null
+++ b/homeassistant/components/roku/.translations/pl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie Roku jest ju\u017c skonfigurowane."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie."
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "few": "kilka",
+ "many": "wiele",
+ "one": "jeden",
+ "other": "inne"
+ },
+ "description": "Czy chcesz skonfigurowa\u0107 {name}? R\u0119czne konfiguracje tego urz\u0105dzenia zostan\u0105 zast\u0105pione.",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Nazwa hosta lub adres IP"
+ },
+ "description": "Wprowad\u017a dane Roku.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/ru.json b/homeassistant/components/roku/.translations/ru.json
new file mode 100644
index 00000000000..0db5f9718aa
--- /dev/null
+++ b/homeassistant/components/roku/.translations/ru.json
@@ -0,0 +1,26 @@
+{
+ "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.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437."
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e Roku.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/sl.json b/homeassistant/components/roku/.translations/sl.json
new file mode 100644
index 00000000000..0745151cb0a
--- /dev/null
+++ b/homeassistant/components/roku/.translations/sl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Naprava roku je \u017ee konfigurirana",
+ "unknown": "Nepri\u010dakovana napaka"
+ },
+ "error": {
+ "cannot_connect": "Povezava ni uspela, poskusite znova"
+ },
+ "flow_title": "Roku: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {
+ "few": "nekaj",
+ "one": "ena",
+ "other": "drugo",
+ "two": "dva"
+ },
+ "description": "Ali \u017eelite nastaviti {name}?",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "Gostitelj ali IP naslov"
+ },
+ "description": "Vnesite va\u0161e Roku podatke.",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/.translations/zh-Hant.json b/homeassistant/components/roku/.translations/zh-Hant.json
new file mode 100644
index 00000000000..529fcb604c7
--- /dev/null
+++ b/homeassistant/components/roku/.translations/zh-Hant.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Roku \u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21"
+ },
+ "flow_title": "Roku\uff1a{name}",
+ "step": {
+ "ssdp_confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f",
+ "title": "Roku"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740"
+ },
+ "description": "\u8f38\u5165 Roku \u8cc7\u8a0a\u3002",
+ "title": "Roku"
+ }
+ },
+ "title": "Roku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py
index b84b6dd1e63..636260b510c 100644
--- a/homeassistant/components/roku/__init__.py
+++ b/homeassistant/components/roku/__init__.py
@@ -1,29 +1,22 @@
"""Support for Roku."""
-import logging
+import asyncio
+from datetime import timedelta
+from socket import gaierror as SocketGIAError
+from typing import Dict
+from requests.exceptions import RequestException
from roku import Roku, RokuException
import voluptuous as vol
-from homeassistant.components.discovery import SERVICE_ROKU
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
+from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = "roku"
-
-SERVICE_SCAN = "roku_scan"
-
-ATTR_ROKU = "roku"
-
-DATA_ROKU = "data_roku"
-
-NOTIFICATION_ID = "roku_notification"
-NOTIFICATION_TITLE = "Roku Setup"
-NOTIFICATION_SCAN_ID = "roku_scan_notification"
-NOTIFICATION_SCAN_TITLE = "Roku Scan"
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+from .const import DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN
CONFIG_SCHEMA = vol.Schema(
{
@@ -34,77 +27,67 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-# Currently no attributes but it might change later
-ROKU_SCAN_SCHEMA = vol.Schema({})
+PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN]
+SCAN_INTERVAL = timedelta(seconds=30)
-def setup(hass, config):
- """Set up the Roku component."""
- hass.data[DATA_ROKU] = {}
+def get_roku_data(host: str) -> dict:
+ """Retrieve a Roku instance and version info for the device."""
+ roku = Roku(host)
+ roku_device_info = roku.device_info
- def service_handler(service):
- """Handle service calls."""
- if service.service == SERVICE_SCAN:
- scan_for_rokus(hass)
+ return {
+ DATA_CLIENT: roku,
+ DATA_DEVICE_INFO: roku_device_info,
+ }
- def roku_discovered(service, info):
- """Set up an Roku that was auto discovered."""
- _setup_roku(hass, config, {CONF_HOST: info["host"]})
- discovery.listen(hass, SERVICE_ROKU, roku_discovered)
+async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
+ """Set up the Roku integration."""
+ hass.data.setdefault(DOMAIN, {})
- for conf in config.get(DOMAIN, []):
- _setup_roku(hass, config, conf)
-
- hass.services.register(
- DOMAIN, SERVICE_SCAN, service_handler, schema=ROKU_SCAN_SCHEMA
- )
+ if DOMAIN in config:
+ for entry_config in config[DOMAIN]:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config,
+ )
+ )
return True
-def scan_for_rokus(hass):
- """Scan for devices and present a notification of the ones found."""
-
- rokus = Roku.discover()
-
- devices = []
- for roku in rokus:
- try:
- r_info = roku.device_info
- except RokuException: # skip non-roku device
- continue
- devices.append(
- "Name: {0}
Host: {1}
".format(
- r_info.userdevicename
- if r_info.userdevicename
- else f"{r_info.modelname} {r_info.serial_num}",
- roku.host,
- )
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Roku from a config entry."""
+ try:
+ roku_data = await hass.async_add_executor_job(
+ get_roku_data, entry.data[CONF_HOST],
)
- if not devices:
- devices = ["No device(s) found"]
+ except (SocketGIAError, RequestException, RokuException) as exception:
+ raise ConfigEntryNotReady from exception
- hass.components.persistent_notification.create(
- "The following devices were found:
" + "
".join(devices),
- title=NOTIFICATION_SCAN_TITLE,
- notification_id=NOTIFICATION_SCAN_ID,
+ hass.data[DOMAIN][entry.entry_id] = roku_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) -> bool:
+ """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)
-def _setup_roku(hass, hass_config, roku_config):
- """Set up a Roku."""
-
- host = roku_config[CONF_HOST]
-
- if host in hass.data[DATA_ROKU]:
- return
-
- roku = Roku(host)
- r_info = roku.device_info
-
- hass.data[DATA_ROKU][host] = {ATTR_ROKU: r_info.serial_num}
-
- discovery.load_platform(hass, "media_player", DOMAIN, roku_config, hass_config)
-
- discovery.load_platform(hass, "remote", DOMAIN, roku_config, hass_config)
+ return unload_ok
diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py
new file mode 100644
index 00000000000..eec5683c95d
--- /dev/null
+++ b/homeassistant/components/roku/config_flow.py
@@ -0,0 +1,143 @@
+"""Config flow for Roku."""
+import logging
+from socket import gaierror as SocketGIAError
+from typing import Any, Dict, Optional
+from urllib.parse import urlparse
+
+from requests.exceptions import RequestException
+from roku import Roku, RokuException
+import voluptuous as vol
+
+from homeassistant.components.ssdp import (
+ ATTR_SSDP_LOCATION,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL,
+)
+from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+
+from .const import DOMAIN # pylint: disable=unused-import
+
+DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
+
+ERROR_CANNOT_CONNECT = "cannot_connect"
+ERROR_UNKNOWN = "unknown"
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def validate_input(data: Dict) -> Dict:
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+
+ try:
+ roku = Roku(data["host"])
+ device_info = roku.device_info
+ except (SocketGIAError, RequestException, RokuException) as exception:
+ raise CannotConnect from exception
+ except Exception as exception: # pylint: disable=broad-except
+ raise UnknownError from exception
+
+ return {
+ "title": data["host"],
+ "host": data["host"],
+ "serial_num": device_info.serial_num,
+ }
+
+
+class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a Roku config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
+
+ @callback
+ def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
+ """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, user_input: Optional[Dict] = None
+ ) -> Dict[str, Any]:
+ """Handle configuration by yaml file."""
+ return await self.async_step_user(user_input)
+
+ async def async_step_user(
+ self, user_input: Optional[Dict] = None
+ ) -> Dict[str, Any]:
+ """Handle a flow initialized by the user."""
+ if not user_input:
+ return self._show_form()
+
+ errors = {}
+
+ try:
+ info = await self.hass.async_add_executor_job(validate_input, user_input)
+ except CannotConnect:
+ errors["base"] = ERROR_CANNOT_CONNECT
+ return self._show_form(errors)
+ except UnknownError:
+ _LOGGER.exception("Unknown error trying to connect")
+ return self.async_abort(reason=ERROR_UNKNOWN)
+
+ await self.async_set_unique_id(info["serial_num"])
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(title=info["title"], data=user_input)
+
+ async def async_step_ssdp(
+ self, discovery_info: Optional[Dict] = None
+ ) -> Dict[str, Any]:
+ """Handle a flow initialized by discovery."""
+ host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname
+ name = discovery_info[ATTR_UPNP_FRIENDLY_NAME]
+ serial_num = discovery_info[ATTR_UPNP_SERIAL]
+
+ await self.async_set_unique_id(serial_num)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: host})
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ self.context.update(
+ {CONF_HOST: host, CONF_NAME: name, "title_placeholders": {"name": host}}
+ )
+
+ return await self.async_step_ssdp_confirm()
+
+ async def async_step_ssdp_confirm(
+ self, user_input: Optional[Dict] = None
+ ) -> Dict[str, Any]:
+ """Handle user-confirmation of discovered device."""
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ name = self.context.get(CONF_NAME)
+
+ if user_input is not None:
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ user_input[CONF_HOST] = self.context.get(CONF_HOST)
+ user_input[CONF_NAME] = name
+
+ try:
+ await self.hass.async_add_executor_job(validate_input, user_input)
+ return self.async_create_entry(title=name, data=user_input)
+ except CannotConnect:
+ return self.async_abort(reason=ERROR_CANNOT_CONNECT)
+ except UnknownError:
+ _LOGGER.exception("Unknown error trying to connect")
+ return self.async_abort(reason=ERROR_UNKNOWN)
+
+ return self.async_show_form(
+ step_id="ssdp_confirm", description_placeholders={"name": name},
+ )
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class UnknownError(HomeAssistantError):
+ """Error to indicate we encountered an unknown error."""
diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py
index 54c52de2622..b06eed5df9f 100644
--- a/homeassistant/components/roku/const.py
+++ b/homeassistant/components/roku/const.py
@@ -1,2 +1,8 @@
"""Constants for the Roku integration."""
+DOMAIN = "roku"
+
+DATA_CLIENT = "client"
+DATA_DEVICE_INFO = "device_info"
+
DEFAULT_PORT = 8060
+DEFAULT_MANUFACTURER = "Roku"
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
index 20461c789e2..43ccb6b8ad3 100644
--- a/homeassistant/components/roku/manifest.json
+++ b/homeassistant/components/roku/manifest.json
@@ -2,8 +2,16 @@
"domain": "roku",
"name": "Roku",
"documentation": "https://www.home-assistant.io/integrations/roku",
- "requirements": ["roku==4.0.0"],
+ "requirements": ["roku==4.1.0"],
"dependencies": [],
- "after_dependencies": ["discovery"],
- "codeowners": ["@ctalkington"]
+ "ssdp": [
+ {
+ "st": "roku:ecp",
+ "manufacturer": "Roku",
+ "deviceType": "urn:roku-com:device:player:1-0"
+ }
+ ],
+ "codeowners": ["@ctalkington"],
+ "quality_scale": "silver",
+ "config_flow": true
}
diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py
index 21a2f562293..ba923f0fdd2 100644
--- a/homeassistant/components/roku/media_player.py
+++ b/homeassistant/components/roku/media_player.py
@@ -1,8 +1,9 @@
"""Support for the Roku media player."""
-import logging
-
-import requests.exceptions
-from roku import Roku
+from requests.exceptions import (
+ ConnectionError as RequestsConnectionError,
+ ReadTimeout as RequestsReadTimeout,
+)
+from roku import RokuException
from homeassistant.components.media_player import MediaPlayerDevice
from homeassistant.components.media_player.const import (
@@ -16,17 +17,9 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
)
-from homeassistant.const import (
- CONF_HOST,
- STATE_HOME,
- STATE_IDLE,
- STATE_PLAYING,
- STATE_STANDBY,
-)
+from homeassistant.const import STATE_HOME, STATE_IDLE, STATE_PLAYING, STATE_STANDBY
-from .const import DEFAULT_PORT
-
-_LOGGER = logging.getLogger(__name__)
+from .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DEFAULT_PORT, DOMAIN
SUPPORT_ROKU = (
SUPPORT_PREVIOUS_TRACK
@@ -40,23 +33,19 @@ SUPPORT_ROKU = (
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Roku platform."""
- if not discovery_info:
- return
-
- host = discovery_info[CONF_HOST]
- async_add_entities([RokuDevice(host)], True)
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up the Roku config entry."""
+ roku = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT]
+ async_add_entities([RokuDevice(roku)], True)
class RokuDevice(MediaPlayerDevice):
"""Representation of a Roku device on the network."""
- def __init__(self, host):
+ def __init__(self, roku):
"""Initialize the Roku device."""
-
- self.roku = Roku(host)
- self.ip_address = host
+ self.roku = roku
+ self.ip_address = roku.host
self.channels = []
self.current_app = None
self._available = False
@@ -77,7 +66,7 @@ class RokuDevice(MediaPlayerDevice):
self.current_app = None
self._available = True
- except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
+ except (RequestsConnectionError, RequestsReadTimeout, RokuException):
self._available = False
pass
@@ -130,6 +119,17 @@ class RokuDevice(MediaPlayerDevice):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return self._device_info.serial_num
+ @property
+ def device_info(self):
+ """Return device specific attributes."""
+ return {
+ "name": self.name,
+ "identifiers": {(DOMAIN, self.unique_id)},
+ "manufacturer": DEFAULT_MANUFACTURER,
+ "model": self._device_info.model_num,
+ "sw_version": self._device_info.software_version,
+ }
+
@property
def media_content_type(self):
"""Content type of current playing media."""
diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py
index c953d9ba734..548282d6b2f 100644
--- a/homeassistant/components/roku/remote.py
+++ b/homeassistant/components/roku/remote.py
@@ -1,34 +1,48 @@
"""Support for the Roku remote."""
-import requests.exceptions
-from roku import Roku
+from typing import Callable, List
-from homeassistant.components import remote
-from homeassistant.const import CONF_HOST
+from requests.exceptions import (
+ ConnectionError as RequestsConnectionError,
+ ReadTimeout as RequestsReadTimeout,
+)
+from roku import RokuException
+
+from homeassistant.components.remote import RemoteDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import DATA_CLIENT, DEFAULT_MANUFACTURER, DOMAIN
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Roku remote platform."""
- if not discovery_info:
- return
-
- host = discovery_info[CONF_HOST]
- async_add_entities([RokuRemote(host)], True)
+async def async_setup_entry(
+ hass: HomeAssistantType,
+ entry: ConfigEntry,
+ async_add_entities: Callable[[List, bool], None],
+) -> bool:
+ """Load Roku remote based on a config entry."""
+ roku = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT]
+ async_add_entities([RokuRemote(roku)], True)
-class RokuRemote(remote.RemoteDevice):
+class RokuRemote(RemoteDevice):
"""Device that sends commands to an Roku."""
- def __init__(self, host):
+ def __init__(self, roku):
"""Initialize the Roku device."""
-
- self.roku = Roku(host)
+ self.roku = roku
+ self._available = False
self._device_info = {}
def update(self):
"""Retrieve latest state."""
+ if not self.enabled:
+ return
+
try:
self._device_info = self.roku.device_info
- except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
+ self._available = True
+ except (RequestsConnectionError, RequestsReadTimeout, RokuException):
+ self._available = False
pass
@property
@@ -38,11 +52,27 @@ class RokuRemote(remote.RemoteDevice):
return self._device_info.user_device_name
return f"Roku {self._device_info.serial_num}"
+ @property
+ def available(self):
+ """Return if able to retrieve information from device or not."""
+ return self._available
+
@property
def unique_id(self):
"""Return a unique ID."""
return self._device_info.serial_num
+ @property
+ def device_info(self):
+ """Return device specific attributes."""
+ return {
+ "name": self.name,
+ "identifiers": {(DOMAIN, self.unique_id)},
+ "manufacturer": DEFAULT_MANUFACTURER,
+ "model": self._device_info.model_num,
+ "sw_version": self._device_info.software_version,
+ }
+
@property
def is_on(self):
"""Return true if device is on."""
diff --git a/homeassistant/components/roku/services.yaml b/homeassistant/components/roku/services.yaml
deleted file mode 100644
index 956ecb0dd2d..00000000000
--- a/homeassistant/components/roku/services.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-roku_scan:
- description: Scans the local network for Rokus. All found devices are presented as a persistent notification.
diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json
new file mode 100644
index 00000000000..17072850259
--- /dev/null
+++ b/homeassistant/components/roku/strings.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "title": "Roku",
+ "flow_title": "Roku: {name}",
+ "step": {
+ "user": {
+ "title": "Roku",
+ "description": "Enter your Roku information.",
+ "data": {
+ "host": "Host or IP address"
+ }
+ },
+ "ssdp_confirm": {
+ "title": "Roku",
+ "description": "Do you want to set up {name}?",
+ "data": {}
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again"
+ },
+ "abort": {
+ "already_configured": "Roku device is already configured",
+ "unknown": "Unexpected error"
+ }
+ }
+}
diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py
index c6833fcfda0..cd27b33271f 100644
--- a/homeassistant/components/rtorrent/sensor.py
+++ b/homeassistant/components/rtorrent/sensor.py
@@ -21,12 +21,24 @@ _LOGGER = logging.getLogger(__name__)
SENSOR_TYPE_CURRENT_STATUS = "current_status"
SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed"
SENSOR_TYPE_UPLOAD_SPEED = "upload_speed"
+SENSOR_TYPE_ALL_TORRENTS = "all_torrents"
+SENSOR_TYPE_STOPPED_TORRENTS = "stopped_torrents"
+SENSOR_TYPE_COMPLETE_TORRENTS = "complete_torrents"
+SENSOR_TYPE_UPLOADING_TORRENTS = "uploading_torrents"
+SENSOR_TYPE_DOWNLOADING_TORRENTS = "downloading_torrents"
+SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents"
DEFAULT_NAME = "rtorrent"
SENSOR_TYPES = {
SENSOR_TYPE_CURRENT_STATUS: ["Status", None],
SENSOR_TYPE_DOWNLOAD_SPEED: ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND],
SENSOR_TYPE_UPLOAD_SPEED: ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND],
+ SENSOR_TYPE_ALL_TORRENTS: ["All Torrents", None],
+ SENSOR_TYPE_STOPPED_TORRENTS: ["Stopped Torrents", None],
+ SENSOR_TYPE_COMPLETE_TORRENTS: ["Complete Torrents", None],
+ SENSOR_TYPE_UPLOADING_TORRENTS: ["Uploading Torrents", None],
+ SENSOR_TYPE_DOWNLOADING_TORRENTS: ["Downloading Torrents", None],
+ SENSOR_TYPE_ACTIVE_TORRENTS: ["Active Torrents", None],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -102,6 +114,11 @@ class RTorrentSensor(Entity):
multicall = xmlrpc.client.MultiCall(self.client)
multicall.throttle.global_up.rate()
multicall.throttle.global_down.rate()
+ multicall.d.multicall2("", "main")
+ multicall.d.multicall2("", "stopped")
+ multicall.d.multicall2("", "complete")
+ multicall.d.multicall2("", "seeding", "d.up.rate=")
+ multicall.d.multicall2("", "leeching", "d.down.rate=")
try:
self.data = multicall()
@@ -113,6 +130,21 @@ class RTorrentSensor(Entity):
upload = self.data[0]
download = self.data[1]
+ all_torrents = self.data[2]
+ stopped_torrents = self.data[3]
+ complete_torrents = self.data[4]
+
+ uploading_torrents = 0
+ for up_torrent in self.data[5]:
+ if up_torrent[0]:
+ uploading_torrents += 1
+
+ downloading_torrents = 0
+ for down_torrent in self.data[6]:
+ if down_torrent[0]:
+ downloading_torrents += 1
+
+ active_torrents = uploading_torrents + downloading_torrents
if self.type == SENSOR_TYPE_CURRENT_STATUS:
if self.data:
@@ -132,3 +164,15 @@ class RTorrentSensor(Entity):
self._state = format_speed(download)
elif self.type == SENSOR_TYPE_UPLOAD_SPEED:
self._state = format_speed(upload)
+ elif self.type == SENSOR_TYPE_ALL_TORRENTS:
+ self._state = len(all_torrents)
+ elif self.type == SENSOR_TYPE_STOPPED_TORRENTS:
+ self._state = len(stopped_torrents)
+ elif self.type == SENSOR_TYPE_COMPLETE_TORRENTS:
+ self._state = len(complete_torrents)
+ elif self.type == SENSOR_TYPE_UPLOADING_TORRENTS:
+ self._state = uploading_torrents
+ elif self.type == SENSOR_TYPE_DOWNLOADING_TORRENTS:
+ self._state = downloading_torrents
+ elif self.type == SENSOR_TYPE_ACTIVE_TORRENTS:
+ self._state = active_torrents
diff --git a/homeassistant/components/samsungtv/.translations/ca.json b/homeassistant/components/samsungtv/.translations/ca.json
index 7ca5879a5c0..a742cc546b8 100644
--- a/homeassistant/components/samsungtv/.translations/ca.json
+++ b/homeassistant/components/samsungtv/.translations/ca.json
@@ -4,7 +4,6 @@
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/da.json b/homeassistant/components/samsungtv/.translations/da.json
index 379fd5d8b6d..7a6b5540c59 100644
--- a/homeassistant/components/samsungtv/.translations/da.json
+++ b/homeassistant/components/samsungtv/.translations/da.json
@@ -4,7 +4,6 @@
"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. 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."
},
diff --git a/homeassistant/components/samsungtv/.translations/de.json b/homeassistant/components/samsungtv/.translations/de.json
index 27b9ecc37df..24afa67038d 100644
--- a/homeassistant/components/samsungtv/.translations/de.json
+++ b/homeassistant/components/samsungtv/.translations/de.json
@@ -4,14 +4,13 @@
"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 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.",
+ "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 Autorisierung fragt. Manuelle Konfigurationen f\u00fcr dieses Fernsehger\u00e4t werden \u00fcberschrieben.",
"title": "Samsung TV"
},
"user": {
diff --git a/homeassistant/components/samsungtv/.translations/en.json b/homeassistant/components/samsungtv/.translations/en.json
index 2d3856fbaff..37dc84d3e30 100644
--- a/homeassistant/components/samsungtv/.translations/en.json
+++ b/homeassistant/components/samsungtv/.translations/en.json
@@ -4,7 +4,6 @@
"already_configured": "This Samsung TV is already configured.",
"already_in_progress": "Samsung TV configuration is already in progress.",
"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_successful": "Unable to connect to this Samsung TV device.",
"not_supported": "This Samsung TV device is currently not supported."
},
diff --git a/homeassistant/components/samsungtv/.translations/es.json b/homeassistant/components/samsungtv/.translations/es.json
index 4466b329a2a..91581de59a1 100644
--- a/homeassistant/components/samsungtv/.translations/es.json
+++ b/homeassistant/components/samsungtv/.translations/es.json
@@ -4,7 +4,6 @@
"already_configured": "Este televisor Samsung ya est\u00e1 configurado.",
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/fr.json b/homeassistant/components/samsungtv/.translations/fr.json
index e381660a3e2..8e722a7add0 100644
--- a/homeassistant/components/samsungtv/.translations/fr.json
+++ b/homeassistant/components/samsungtv/.translations/fr.json
@@ -4,7 +4,6 @@
"already_configured": "Ce t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 configur\u00e9.",
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/hu.json b/homeassistant/components/samsungtv/.translations/hu.json
index c7a046428bc..6ed1d806739 100644
--- a/homeassistant/components/samsungtv/.translations/hu.json
+++ b/homeassistant/components/samsungtv/.translations/hu.json
@@ -4,7 +4,6 @@
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/it.json b/homeassistant/components/samsungtv/.translations/it.json
index 3d2d4dd8e11..692f91efea9 100644
--- a/homeassistant/components/samsungtv/.translations/it.json
+++ b/homeassistant/components/samsungtv/.translations/it.json
@@ -4,7 +4,6 @@
"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 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."
},
diff --git a/homeassistant/components/samsungtv/.translations/ko.json b/homeassistant/components/samsungtv/.translations/ko.json
index 0226fd52dc0..20b4496b428 100644
--- a/homeassistant/components/samsungtv/.translations/ko.json
+++ b/homeassistant/components/samsungtv/.translations/ko.json
@@ -4,7 +4,6 @@
"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\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."
},
diff --git a/homeassistant/components/samsungtv/.translations/lb.json b/homeassistant/components/samsungtv/.translations/lb.json
index b3a94a1a2a6..39ec28d6992 100644
--- a/homeassistant/components/samsungtv/.translations/lb.json
+++ b/homeassistant/components/samsungtv/.translations/lb.json
@@ -4,7 +4,6 @@
"already_configured": "D\u00ebs Samsung TV ass scho konfigur\u00e9iert.",
"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"
},
diff --git a/homeassistant/components/samsungtv/.translations/nl.json b/homeassistant/components/samsungtv/.translations/nl.json
index 09c0bba05a3..3dcb9e59d74 100644
--- a/homeassistant/components/samsungtv/.translations/nl.json
+++ b/homeassistant/components/samsungtv/.translations/nl.json
@@ -4,7 +4,6 @@
"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_successful": "Niet in staat om verbinding te maken met dit Samsung TV toestel.",
"not_supported": "Deze Samsung TV wordt momenteel niet ondersteund."
},
diff --git a/homeassistant/components/samsungtv/.translations/no.json b/homeassistant/components/samsungtv/.translations/no.json
index 544ab581be8..6e02251f271 100644
--- a/homeassistant/components/samsungtv/.translations/no.json
+++ b/homeassistant/components/samsungtv/.translations/no.json
@@ -4,15 +4,14 @@
"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 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}",
+ "flow_title": "",
"step": {
"confirm": {
"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"
+ "title": ""
},
"user": {
"data": {
@@ -20,9 +19,9 @@
"name": "Navn"
},
"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"
+ "title": ""
}
},
- "title": "Samsung TV"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/samsungtv/.translations/pl.json b/homeassistant/components/samsungtv/.translations/pl.json
index 200d8d2cf9a..02231169b65 100644
--- a/homeassistant/components/samsungtv/.translations/pl.json
+++ b/homeassistant/components/samsungtv/.translations/pl.json
@@ -4,7 +4,6 @@
"already_configured": "Ten telewizor Samsung jest ju\u017c skonfigurowany.",
"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_successful": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 urz\u0105dzeniem Samsung TV.",
"not_supported": "Ten telewizor Samsung nie jest obecnie obs\u0142ugiwany."
},
diff --git a/homeassistant/components/samsungtv/.translations/ru.json b/homeassistant/components/samsungtv/.translations/ru.json
index 14f772c5e1d..016979eb330 100644
--- a/homeassistant/components/samsungtv/.translations/ru.json
+++ b/homeassistant/components/samsungtv/.translations/ru.json
@@ -4,7 +4,6 @@
"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\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."
},
diff --git a/homeassistant/components/samsungtv/.translations/sl.json b/homeassistant/components/samsungtv/.translations/sl.json
index 95286476ed0..bbf39de3409 100644
--- a/homeassistant/components/samsungtv/.translations/sl.json
+++ b/homeassistant/components/samsungtv/.translations/sl.json
@@ -4,7 +4,6 @@
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/sv.json b/homeassistant/components/samsungtv/.translations/sv.json
index f75e8238506..423bf61a750 100644
--- a/homeassistant/components/samsungtv/.translations/sv.json
+++ b/homeassistant/components/samsungtv/.translations/sv.json
@@ -4,7 +4,6 @@
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/tr.json b/homeassistant/components/samsungtv/.translations/tr.json
index 3cf1f135e1f..e23969be8a2 100644
--- a/homeassistant/components/samsungtv/.translations/tr.json
+++ b/homeassistant/components/samsungtv/.translations/tr.json
@@ -4,7 +4,6 @@
"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."
},
diff --git a/homeassistant/components/samsungtv/.translations/zh-Hant.json b/homeassistant/components/samsungtv/.translations/zh-Hant.json
index 80cfa32a6bf..d12d47551c8 100644
--- a/homeassistant/components/samsungtv/.translations/zh-Hant.json
+++ b/homeassistant/components/samsungtv/.translations/zh-Hant.json
@@ -4,7 +4,6 @@
"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\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"
},
diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py
new file mode 100644
index 00000000000..9a78730d775
--- /dev/null
+++ b/homeassistant/components/schluter/__init__.py
@@ -0,0 +1,73 @@
+"""The Schluter DITRA-HEAT integration."""
+import logging
+
+from requests import RequestException, Session
+from schluter.api import Api
+from schluter.authenticator import AuthenticationState, Authenticator
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+DATA_SCHLUTER_SESSION = "schluter_session"
+DATA_SCHLUTER_API = "schluter_api"
+SCHLUTER_CONFIG_FILE = ".schluter.conf"
+API_TIMEOUT = 10
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ vol.Required(DOMAIN): vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+def setup(hass, config):
+ """Set up the Schluter component."""
+ _LOGGER.debug("Starting setup of schluter")
+
+ conf = config[DOMAIN]
+ api_http_session = Session()
+ api = Api(timeout=API_TIMEOUT, http_session=api_http_session)
+
+ authenticator = Authenticator(
+ api,
+ conf.get(CONF_USERNAME),
+ conf.get(CONF_PASSWORD),
+ session_id_cache_file=hass.config.path(SCHLUTER_CONFIG_FILE),
+ )
+
+ authentication = None
+ try:
+ authentication = authenticator.authenticate()
+ except RequestException as ex:
+ _LOGGER.error("Unable to connect to Schluter service: %s", ex)
+ return
+
+ state = authentication.state
+
+ if state == AuthenticationState.AUTHENTICATED:
+ hass.data[DOMAIN] = {
+ DATA_SCHLUTER_API: api,
+ DATA_SCHLUTER_SESSION: authentication.session_id,
+ }
+ discovery.load_platform(hass, "climate", DOMAIN, {}, config)
+ return True
+ if state == AuthenticationState.BAD_PASSWORD:
+ _LOGGER.error("Invalid password provided")
+ return False
+ if state == AuthenticationState.BAD_EMAIL:
+ _LOGGER.error("Invalid email provided")
+ return False
+
+ _LOGGER.error("Unknown set up error: %s", state)
+ return False
diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py
new file mode 100644
index 00000000000..99dc5b0d495
--- /dev/null
+++ b/homeassistant/components/schluter/climate.py
@@ -0,0 +1,169 @@
+"""Support for Schluter thermostats."""
+import logging
+
+from requests import RequestException
+import voluptuous as vol
+
+from homeassistant.components.climate import (
+ PLATFORM_SCHEMA,
+ SCAN_INTERVAL,
+ ClimateDevice,
+)
+from homeassistant.components.climate.const import (
+ CURRENT_HVAC_HEAT,
+ CURRENT_HVAC_IDLE,
+ HVAC_MODE_HEAT,
+ SUPPORT_TARGET_TEMPERATURE,
+)
+from homeassistant.const import ATTR_TEMPERATURE, CONF_SCAN_INTERVAL
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from . import DATA_SCHLUTER_API, DATA_SCHLUTER_SESSION, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1))}
+)
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the Schluter thermostats."""
+ if discovery_info is None:
+ return
+ session_id = hass.data[DOMAIN][DATA_SCHLUTER_SESSION]
+ api = hass.data[DOMAIN][DATA_SCHLUTER_API]
+ temp_unit = hass.config.units.temperature_unit
+
+ async def async_update_data():
+ try:
+ thermostats = await hass.async_add_executor_job(
+ api.get_thermostats, session_id
+ )
+ except RequestException as err:
+ raise UpdateFailed(f"Error communicating with Schluter API: {err}")
+
+ if thermostats is None:
+ return {}
+
+ return {thermo.serial_number: thermo for thermo in thermostats}
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name="schluter",
+ update_method=async_update_data,
+ update_interval=SCAN_INTERVAL,
+ )
+
+ await coordinator.async_refresh()
+
+ async_add_entities(
+ SchluterThermostat(coordinator, serial_number, temp_unit, api, session_id)
+ for serial_number, thermostat in coordinator.data.items()
+ )
+
+
+class SchluterThermostat(ClimateDevice):
+ """Representation of a Schluter thermostat."""
+
+ def __init__(self, coordinator, serial_number, temp_unit, api, session_id):
+ """Initialize the thermostat."""
+ self._unit = temp_unit
+ self._coordinator = coordinator
+ self._serial_number = serial_number
+ self._api = api
+ self._session_id = session_id
+ self._support_flags = SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def available(self):
+ """Return if thermostat is available."""
+ return self._coordinator.last_update_success
+
+ @property
+ def should_poll(self):
+ """Return if platform should poll."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ @property
+ def unique_id(self):
+ """Return unique ID for this device."""
+ return self._serial_number
+
+ @property
+ def name(self):
+ """Return the name of the thermostat."""
+ return self._coordinator.data[self._serial_number].name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._coordinator.data[self._serial_number].temperature
+
+ @property
+ def hvac_mode(self):
+ """Return current mode. Only heat available for floor thermostat."""
+ return HVAC_MODE_HEAT
+
+ @property
+ def hvac_action(self):
+ """Return current operation. Can only be heating or idle."""
+ return (
+ CURRENT_HVAC_HEAT
+ if self._coordinator.data[self._serial_number].is_heating
+ else CURRENT_HVAC_IDLE
+ )
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._coordinator.data[self._serial_number].set_point_temp
+
+ @property
+ def hvac_modes(self):
+ """List of available operation modes."""
+ return [HVAC_MODE_HEAT]
+
+ @property
+ def min_temp(self):
+ """Identify min_temp in Schluter API."""
+ return self._coordinator.data[self._serial_number].min_temp
+
+ @property
+ def max_temp(self):
+ """Identify max_temp in Schluter API."""
+ return self._coordinator.data[self._serial_number].max_temp
+
+ async def async_set_hvac_mode(self, hvac_mode):
+ """Mode is always heating, so do nothing."""
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temp = None
+ target_temp = kwargs.get(ATTR_TEMPERATURE)
+ serial_number = self._coordinator.data[self._serial_number].serial_number
+ _LOGGER.debug("Setting thermostat temperature: %s", target_temp)
+
+ try:
+ if target_temp is not None:
+ self._api.set_temperature(self._session_id, serial_number, target_temp)
+ except RequestException as ex:
+ _LOGGER.error("An error occurred while setting temperature: %s", ex)
+
+ 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)
diff --git a/homeassistant/components/schluter/const.py b/homeassistant/components/schluter/const.py
new file mode 100644
index 00000000000..e09c8cf66a9
--- /dev/null
+++ b/homeassistant/components/schluter/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Schluter DITRA-HEAT integration."""
+
+DOMAIN = "schluter"
diff --git a/homeassistant/components/schluter/manifest.json b/homeassistant/components/schluter/manifest.json
new file mode 100644
index 00000000000..1a7cebcf06a
--- /dev/null
+++ b/homeassistant/components/schluter/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "schluter",
+ "name": "Schluter",
+ "documentation": "https://www.home-assistant.io/integrations/schluter",
+ "requirements": ["py-schluter==0.1.7"],
+ "dependencies": [],
+ "codeowners": ["@prairieapps"]
+}
diff --git a/homeassistant/components/sendgrid/__init__.py b/homeassistant/components/sendgrid/__init__.py
index 91fff97c150..164dead5b23 100644
--- a/homeassistant/components/sendgrid/__init__.py
+++ b/homeassistant/components/sendgrid/__init__.py
@@ -1 +1 @@
-"""The sendgrid component."""
+"""The sendgrid integration."""
diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json
index 900fe9252b4..63a511809d8 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.1"],
+ "requirements": ["sendgrid==6.1.3"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/sense/.translations/da.json b/homeassistant/components/sense/.translations/da.json
new file mode 100644
index 00000000000..5085cdbce9e
--- /dev/null
+++ b/homeassistant/components/sense/.translations/da.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Adgangskode"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/fr.json b/homeassistant/components/sense/.translations/fr.json
new file mode 100644
index 00000000000..999ac2a0ac7
--- /dev/null
+++ b/homeassistant/components/sense/.translations/fr.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "invalid_auth": "Authentification invalide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "Adresse e-mail",
+ "password": "Mot de passe"
+ },
+ "title": "Connectez-vous \u00e0 votre moniteur d'\u00e9nergie Sense"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/ko.json b/homeassistant/components/sense/.translations/ko.json
new file mode 100644
index 00000000000..6041992e56d
--- /dev/null
+++ b/homeassistant/components/sense/.translations/ko.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \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": {
+ "email": "\uc774\uba54\uc77c \uc8fc\uc18c",
+ "password": "\ube44\ubc00\ubc88\ud638"
+ },
+ "title": "Sense Energy Monitor \uc5d0 \uc5f0\uacb0\ud558\uae30"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/no.json b/homeassistant/components/sense/.translations/no.json
index 70bd45558a3..d3fe4028e0f 100644
--- a/homeassistant/components/sense/.translations/no.json
+++ b/homeassistant/components/sense/.translations/no.json
@@ -17,6 +17,6 @@
"title": "Koble til din Sense Energi Monitor"
}
},
- "title": "Sense"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/pl.json b/homeassistant/components/sense/.translations/pl.json
new file mode 100644
index 00000000000..0e0e7f5da66
--- /dev/null
+++ b/homeassistant/components/sense/.translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane."
+ },
+ "error": {
+ "invalid_auth": "Niepoprawne uwierzytelnienie.",
+ "unknown": "Niespodziewany b\u0142\u0105d."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "Adres e-mail",
+ "password": "Has\u0142o"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/sl.json b/homeassistant/components/sense/.translations/sl.json
new file mode 100644
index 00000000000..9f7568ef249
--- /dev/null
+++ b/homeassistant/components/sense/.translations/sl.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Naprava je \u017ee konfigurirana"
+ },
+ "error": {
+ "cannot_connect": "Povezava ni uspela, poskusite znova",
+ "invalid_auth": "Neveljavna avtentikacija",
+ "unknown": "Nepri\u010dakovana napaka"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "E-po\u0161tni naslov",
+ "password": "Geslo"
+ },
+ "title": "Pove\u017eite se s svojim Sense Energy monitor-jem"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py
index d7887f7ab01..80e75bce400 100644
--- a/homeassistant/components/sense/__init__.py
+++ b/homeassistant/components/sense/__init__.py
@@ -26,6 +26,7 @@ from .const import (
SENSE_DEVICE_UPDATE,
SENSE_DEVICES_DATA,
SENSE_DISCOVERED_DEVICES_DATA,
+ SENSE_TIMEOUT_EXCEPTIONS,
)
_LOGGER = logging.getLogger(__name__)
@@ -101,11 +102,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
except SenseAuthenticationException:
_LOGGER.error("Could not authenticate with sense server")
return False
- except SenseAPITimeoutException:
+ except SENSE_TIMEOUT_EXCEPTIONS:
raise ConfigEntryNotReady
sense_devices_data = SenseDevicesData()
- sense_discovered_devices = await gateway.get_discovered_device_data()
+ try:
+ sense_discovered_devices = await gateway.get_discovered_device_data()
+ except SENSE_TIMEOUT_EXCEPTIONS:
+ raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = {
SENSE_DATA: gateway,
diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py
index 68bbb9ed932..7a5b04229a1 100644
--- a/homeassistant/components/sense/config_flow.py
+++ b/homeassistant/components/sense/config_flow.py
@@ -1,17 +1,13 @@
"""Config flow for Sense integration."""
import logging
-from sense_energy import (
- ASyncSenseable,
- SenseAPITimeoutException,
- SenseAuthenticationException,
-)
+from sense_energy import ASyncSenseable, 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 ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, SENSE_TIMEOUT_EXCEPTIONS
from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import
@@ -55,7 +51,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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:
+ except SENSE_TIMEOUT_EXCEPTIONS:
errors["base"] = "cannot_connect"
except SenseAuthenticationException:
errors["base"] = "invalid_auth"
diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py
index 619956903f2..882c3c9d79f 100644
--- a/homeassistant/components/sense/const.py
+++ b/homeassistant/components/sense/const.py
@@ -1,4 +1,9 @@
"""Constants for monitoring a Sense energy sensor."""
+
+import asyncio
+
+from sense_energy import SenseAPITimeoutException
+
DOMAIN = "sense"
DEFAULT_TIMEOUT = 10
ACTIVE_UPDATE_RATE = 60
@@ -18,6 +23,8 @@ PRODUCTION_ID = "production"
ICON = "mdi:flash"
+SENSE_TIMEOUT_EXCEPTIONS = (asyncio.TimeoutError, SenseAPITimeoutException)
+
MDI_ICONS = {
"ac": "air-conditioner",
"aquarium": "fish",
diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py
index 6fe7b59c46c..06cfb90d2b5 100644
--- a/homeassistant/components/sense/sensor.py
+++ b/homeassistant/components/sense/sensor.py
@@ -2,8 +2,6 @@
from datetime import timedelta
import logging
-from sense_energy import SenseAPITimeoutException
-
from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -24,6 +22,7 @@ from .const import (
SENSE_DEVICE_UPDATE,
SENSE_DEVICES_DATA,
SENSE_DISCOVERED_DEVICES_DATA,
+ SENSE_TIMEOUT_EXCEPTIONS,
)
MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300)
@@ -256,7 +255,7 @@ class SenseTrendsSensor(Entity):
try:
await self.update_sensor()
- except SenseAPITimeoutException:
+ except SENSE_TIMEOUT_EXCEPTIONS:
_LOGGER.error("Timeout retrieving data")
return
diff --git a/homeassistant/components/sensor/.translations/lb.json b/homeassistant/components/sensor/.translations/lb.json
index 01a4e89c9f4..f999e3c16f0 100644
--- a/homeassistant/components/sensor/.translations/lb.json
+++ b/homeassistant/components/sensor/.translations/lb.json
@@ -1,26 +1,26 @@
{
"device_automation": {
"condition_type": {
- "is_battery_level": "{entity_name} Batterie niveau",
- "is_humidity": "{entity_name} Fiichtegkeet",
- "is_illuminance": "{entity_name} Beliichtung",
- "is_power": "{entity_name} Leeschtung",
- "is_pressure": "{entity_name} Drock",
- "is_signal_strength": "{entity_name} Signal St\u00e4erkt",
- "is_temperature": "{entity_name} Temperatur",
- "is_timestamp": "{entity_name} Z\u00e4itstempel",
- "is_value": "{entity_name} W\u00e4ert"
+ "is_battery_level": "Aktuell {entity_name} Batterie niveau",
+ "is_humidity": "Aktuell {entity_name} Fiichtegkeet",
+ "is_illuminance": "Aktuell {entity_name} Beliichtung",
+ "is_power": "Aktuell {entity_name} Leeschtung",
+ "is_pressure": "Aktuell {entity_name} Drock",
+ "is_signal_strength": "Aktuell {entity_name} Signal St\u00e4erkt",
+ "is_temperature": "Aktuell {entity_name} Temperatur",
+ "is_timestamp": "Aktuelle {entity_name} Z\u00e4itstempel",
+ "is_value": "Aktuelle {entity_name} W\u00e4ert"
},
"trigger_type": {
- "battery_level": "{entity_name} Batterie niveau",
- "humidity": "{entity_name} Fiichtegkeet",
- "illuminance": "{entity_name} Beliichtung",
- "power": "{entity_name} Leeschtung",
- "pressure": "{entity_name} Drock",
- "signal_strength": "{entity_name} Signal St\u00e4erkt",
- "temperature": "{entity_name} Temperatur",
- "timestamp": "{entity_name} Z\u00e4itstempel",
- "value": "{entity_name} W\u00e4ert"
+ "battery_level": "{entity_name} Batterie niveau \u00e4nnert",
+ "humidity": "{entity_name} Fiichtegkeet \u00e4nnert",
+ "illuminance": "{entity_name} Beliichtung \u00e4nnert",
+ "power": "{entity_name} Leeschtung \u00e4nnert",
+ "pressure": "{entity_name} Drock \u00e4nnert",
+ "signal_strength": "{entity_name} Signal St\u00e4erkt \u00e4nnert",
+ "temperature": "{entity_name} Temperatur \u00e4nnert",
+ "timestamp": "{entity_name} Z\u00e4itstempel \u00e4nnert",
+ "value": "{entity_name} W\u00e4ert \u00e4nnert"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/.translations/no.json b/homeassistant/components/sentry/.translations/no.json
index 79bb5f6cf87..36ce52f74ea 100644
--- a/homeassistant/components/sentry/.translations/no.json
+++ b/homeassistant/components/sentry/.translations/no.json
@@ -10,9 +10,9 @@
"step": {
"user": {
"description": "Fyll inn din Sentry DNS",
- "title": "Sentry"
+ "title": ""
}
},
- "title": "Sentry"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/shodan/manifest.json b/homeassistant/components/shodan/manifest.json
index 1b04da721b1..86006191942 100644
--- a/homeassistant/components/shodan/manifest.json
+++ b/homeassistant/components/shodan/manifest.json
@@ -2,7 +2,7 @@
"domain": "shodan",
"name": "Shodan",
"documentation": "https://www.home-assistant.io/integrations/shodan",
- "requirements": ["shodan==1.21.3"],
+ "requirements": ["shodan==1.22.0"],
"dependencies": [],
"codeowners": ["@fabaff"]
}
diff --git a/homeassistant/components/shopping_list/.translations/ca.json b/homeassistant/components/shopping_list/.translations/ca.json
new file mode 100644
index 00000000000..541ee0c0e9c
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/ca.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "La llista de compres ja est\u00e0 configurada."
+ },
+ "step": {
+ "user": {
+ "description": "Vols configurar la llista de compres?",
+ "title": "Llista de compres"
+ }
+ },
+ "title": "Llista de compres"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/de.json b/homeassistant/components/shopping_list/.translations/de.json
new file mode 100644
index 00000000000..13638985ee2
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/de.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Die Einkaufsliste ist bereits konfiguriert."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie die Einkaufsliste konfigurieren?",
+ "title": "Einkaufsliste"
+ }
+ },
+ "title": "Einkaufsliste"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/es.json b/homeassistant/components/shopping_list/.translations/es.json
new file mode 100644
index 00000000000..a2c89f0032f
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/es.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "La lista de la compra ya est\u00e1 configurada."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfQuieres configurar la lista de la compra?",
+ "title": "Lista de la compra"
+ }
+ },
+ "title": "Lista de la compra"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/fr.json b/homeassistant/components/shopping_list/.translations/fr.json
new file mode 100644
index 00000000000..05034e3e58e
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/fr.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "La liste d'achats est d\u00e9j\u00e0 configur\u00e9e."
+ },
+ "step": {
+ "user": {
+ "description": "Voulez-vous configurer la liste d'achats ?",
+ "title": "Liste d'achats"
+ }
+ },
+ "title": "Liste d\u2019achats"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/it.json b/homeassistant/components/shopping_list/.translations/it.json
new file mode 100644
index 00000000000..ffd1c1d7f67
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/it.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "La lista della spesa \u00e8 gi\u00e0 configurata."
+ },
+ "step": {
+ "user": {
+ "description": "Vuoi configurare la lista della spesa?",
+ "title": "Lista della Spesa"
+ }
+ },
+ "title": "Lista della Spesa"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/ko.json b/homeassistant/components/shopping_list/.translations/ko.json
new file mode 100644
index 00000000000..7885890d8b4
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/ko.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc7a5\ubcf4\uae30\ubaa9\ub85d\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "user": {
+ "description": "\uc7a5\ubcf4\uae30\ubaa9\ub85d\uc744 \uad6c\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "\uc7a5\ubcf4\uae30\ubaa9\ub85d"
+ }
+ },
+ "title": "\uc7a5\ubcf4\uae30\ubaa9\ub85d"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/lb.json b/homeassistant/components/shopping_list/.translations/lb.json
new file mode 100644
index 00000000000..46f26637689
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/lb.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Akafsl\u00ebscht ass scho konfigur\u00e9iert."
+ },
+ "step": {
+ "user": {
+ "description": "Soll Akafsl\u00ebscht konfigur\u00e9iert ginn?",
+ "title": "Akafsl\u00ebscht"
+ }
+ },
+ "title": "Akafsl\u00ebscht"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/no.json b/homeassistant/components/shopping_list/.translations/no.json
new file mode 100644
index 00000000000..7945f3b0d3f
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/no.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Handlelisten er allerede konfigurert."
+ },
+ "step": {
+ "user": {
+ "description": "\u00d8nsker du \u00e5 konfigurere handleliste?",
+ "title": "Handleliste"
+ }
+ },
+ "title": "Handleliste"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/pl.json b/homeassistant/components/shopping_list/.translations/pl.json
new file mode 100644
index 00000000000..d16122d0df9
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/pl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Lista zakup\u00f3w jest ju\u017c skonfigurowana."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 list\u0119 zakup\u00f3w?",
+ "title": "Lista zakup\u00f3w"
+ }
+ },
+ "title": "Lista zakup\u00f3w"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/ru.json b/homeassistant/components/shopping_list/.translations/ru.json
new file mode 100644
index 00000000000..e230421909d
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/ru.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0441\u043f\u0438\u0441\u043e\u043a \u043f\u043e\u043a\u0443\u043f\u043e\u043a?",
+ "title": "\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u043e\u043a\u0443\u043f\u043e\u043a"
+ }
+ },
+ "title": "\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u043e\u043a\u0443\u043f\u043e\u043a"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/sk.json b/homeassistant/components/shopping_list/.translations/sk.json
new file mode 100644
index 00000000000..857ef6488e5
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/sk.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "N\u00e1kupn\u00fd zoznam je u\u017e nakonfigurovan\u00fd."
+ },
+ "step": {
+ "user": {
+ "description": "Chcete nakonfigurova\u0165 n\u00e1kupn\u00fd zoznam?",
+ "title": "N\u00e1kupn\u00fd zoznam"
+ }
+ },
+ "title": "N\u00e1kupn\u00fd zoznam"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/sl.json b/homeassistant/components/shopping_list/.translations/sl.json
new file mode 100644
index 00000000000..f5d594ed6f5
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/sl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Nakupovalni seznam je \u017ee konfiguriran."
+ },
+ "step": {
+ "user": {
+ "description": "Ali \u017eelite konfigurirati nakupovalni seznam?",
+ "title": "Nakupovalni seznam"
+ }
+ },
+ "title": "Nakupovalni seznam"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/.translations/zh-Hant.json b/homeassistant/components/shopping_list/.translations/zh-Hant.json
new file mode 100644
index 00000000000..aea7a9b6409
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/zh-Hant.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8cfc\u7269\u6e05\u55ae\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u8cfc\u7269\u6e05\u55ae\uff1f",
+ "title": "\u8cfc\u7269\u6e05\u55ae"
+ }
+ },
+ "title": "\u8cfc\u7269\u6e05\u55ae"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/bg.json b/homeassistant/components/simplisafe/.translations/bg.json
index 4f15cc674b0..0ec8fd3c6b1 100644
--- a/homeassistant/components/simplisafe/.translations/bg.json
+++ b/homeassistant/components/simplisafe/.translations/bg.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "\u041a\u043e\u0434 (\u0437\u0430 Home Assistant)",
"password": "\u041f\u0430\u0440\u043e\u043b\u0430",
"username": "E-mail \u0430\u0434\u0440\u0435\u0441"
},
diff --git a/homeassistant/components/simplisafe/.translations/ca.json b/homeassistant/components/simplisafe/.translations/ca.json
index a89e4c753cb..f2d9db5797d 100644
--- a/homeassistant/components/simplisafe/.translations/ca.json
+++ b/homeassistant/components/simplisafe/.translations/ca.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Codi (pel Home Assistant)",
"password": "Contrasenya",
"username": "Correu electr\u00f2nic"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Codi (per la UI de Home Assistant)"
+ },
+ "title": "Configuraci\u00f3 de SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/cs.json b/homeassistant/components/simplisafe/.translations/cs.json
index f4a47c5c344..2160dc226d9 100644
--- a/homeassistant/components/simplisafe/.translations/cs.json
+++ b/homeassistant/components/simplisafe/.translations/cs.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "K\u00f3d (pro Home Assistant)",
"password": "Heslo",
"username": "E-mailov\u00e1 adresa"
},
diff --git a/homeassistant/components/simplisafe/.translations/da.json b/homeassistant/components/simplisafe/.translations/da.json
index ccd82979520..39324fe5f51 100644
--- a/homeassistant/components/simplisafe/.translations/da.json
+++ b/homeassistant/components/simplisafe/.translations/da.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Kode (til Home Assistant)",
"password": "Adgangskode",
"username": "Emailadresse"
},
diff --git a/homeassistant/components/simplisafe/.translations/de.json b/homeassistant/components/simplisafe/.translations/de.json
index 4d5eefc480b..08d5b31d202 100644
--- a/homeassistant/components/simplisafe/.translations/de.json
+++ b/homeassistant/components/simplisafe/.translations/de.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Code (f\u00fcr Home Assistant)",
"password": "Passwort",
"username": "E-Mail-Adresse"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Code (wird in der Benutzeroberfl\u00e4che von Home Assistant verwendet)"
+ },
+ "title": "Konfigurieren Sie SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/en.json b/homeassistant/components/simplisafe/.translations/en.json
index 7e9c26291f7..60c3784ee9d 100644
--- a/homeassistant/components/simplisafe/.translations/en.json
+++ b/homeassistant/components/simplisafe/.translations/en.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Code (for Home Assistant)",
"password": "Password",
"username": "Email Address"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Code (used in Home Assistant UI)"
+ },
+ "title": "Configure SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/es-419.json b/homeassistant/components/simplisafe/.translations/es-419.json
index 709d045c348..bf4127fbd84 100644
--- a/homeassistant/components/simplisafe/.translations/es-419.json
+++ b/homeassistant/components/simplisafe/.translations/es-419.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "C\u00f3digo (para Home Assistant)",
"password": "Contrase\u00f1a",
"username": "Direcci\u00f3n de correo electr\u00f3nico"
},
diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json
index 815aa6be742..fe159cf9fa8 100644
--- a/homeassistant/components/simplisafe/.translations/es.json
+++ b/homeassistant/components/simplisafe/.translations/es.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "C\u00f3digo (para Home Assistant)",
"password": "Contrase\u00f1a",
"username": "Direcci\u00f3n de correo electr\u00f3nico"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "C\u00f3digo (utilizado en el interfaz de usuario de Home Assistant)"
+ },
+ "title": "Configurar SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/fr.json b/homeassistant/components/simplisafe/.translations/fr.json
index de05edea8c9..e204fa96f1b 100644
--- a/homeassistant/components/simplisafe/.translations/fr.json
+++ b/homeassistant/components/simplisafe/.translations/fr.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Ce compte SimpliSafe est d\u00e9j\u00e0 utilis\u00e9."
+ },
"error": {
"identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9",
"invalid_credentials": "Informations d'identification invalides"
@@ -7,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Code (pour Home Assistant)",
"password": "Mot de passe",
"username": "Adresse e-mail"
},
@@ -15,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Code (utilis\u00e9 dans l'interface Home Assistant)"
+ },
+ "title": "Configurer SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/it.json b/homeassistant/components/simplisafe/.translations/it.json
index f153ec36959..71581e845f4 100644
--- a/homeassistant/components/simplisafe/.translations/it.json
+++ b/homeassistant/components/simplisafe/.translations/it.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Codice (Home Assistant)",
"password": "Password",
"username": "Indirizzo E-mail"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Codice (utilizzato nell'Interfaccia Utente di Home Assistant)"
+ },
+ "title": "Configurare SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/ko.json b/homeassistant/components/simplisafe/.translations/ko.json
index 3327ddf9ab1..53e67cd5506 100644
--- a/homeassistant/components/simplisafe/.translations/ko.json
+++ b/homeassistant/components/simplisafe/.translations/ko.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "\ucf54\ub4dc (Home Assistant \uc6a9)",
"password": "\ube44\ubc00\ubc88\ud638",
"username": "\uc774\uba54\uc77c \uc8fc\uc18c"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "\ucf54\ub4dc (Home Assistant UI \uc5d0\uc11c \uc0ac\uc6a9\ub428)"
+ },
+ "title": "SimpliSafe \uad6c\uc131"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/lb.json b/homeassistant/components/simplisafe/.translations/lb.json
index c0e9faf08f6..a7e56f817d5 100644
--- a/homeassistant/components/simplisafe/.translations/lb.json
+++ b/homeassistant/components/simplisafe/.translations/lb.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Code (fir Home Assistant)",
"password": "Passwuert",
"username": "E-Mail Adress"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Code (den am Home Assistant Interface benotzt g\u00ebtt)"
+ },
+ "title": "Simplisafe konfigur\u00e9ieren"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/nl.json b/homeassistant/components/simplisafe/.translations/nl.json
index c84593c0b23..bad1c408144 100644
--- a/homeassistant/components/simplisafe/.translations/nl.json
+++ b/homeassistant/components/simplisafe/.translations/nl.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "Code (voor Home Assistant)",
"password": "Wachtwoord",
"username": "E-mailadres"
},
diff --git a/homeassistant/components/simplisafe/.translations/no.json b/homeassistant/components/simplisafe/.translations/no.json
index 4c25893791b..436fba8fd06 100644
--- a/homeassistant/components/simplisafe/.translations/no.json
+++ b/homeassistant/components/simplisafe/.translations/no.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Kode (for Home Assistant)",
"password": "Passord",
"username": "E-postadresse"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Kode (brukt i home assistant ui)"
+ },
+ "title": "Konfigurer SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/pl.json b/homeassistant/components/simplisafe/.translations/pl.json
index 3a9c160a0c5..b673d28a7ca 100644
--- a/homeassistant/components/simplisafe/.translations/pl.json
+++ b/homeassistant/components/simplisafe/.translations/pl.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Kod (dla Home Assistant'a)",
"password": "Has\u0142o",
"username": "Adres e-mail"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Kod (u\u017cywany w interfejsie u\u017cytkownika Home Assistant'a)"
+ },
+ "title": "Konfiguracja SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/pt-BR.json b/homeassistant/components/simplisafe/.translations/pt-BR.json
index 819cb1d95e0..2f1fe9ca10a 100644
--- a/homeassistant/components/simplisafe/.translations/pt-BR.json
+++ b/homeassistant/components/simplisafe/.translations/pt-BR.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "C\u00f3digo (para o Home Assistant)",
"password": "Senha",
"username": "Endere\u00e7o de e-mail"
},
diff --git a/homeassistant/components/simplisafe/.translations/pt.json b/homeassistant/components/simplisafe/.translations/pt.json
index 47929161976..809c8fc29a4 100644
--- a/homeassistant/components/simplisafe/.translations/pt.json
+++ b/homeassistant/components/simplisafe/.translations/pt.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "C\u00f3digo (para Home Assistant)",
"password": "Palavra-passe",
"username": "Endere\u00e7o de e-mail"
},
diff --git a/homeassistant/components/simplisafe/.translations/ro.json b/homeassistant/components/simplisafe/.translations/ro.json
index b7e281a2bc2..33f284e93c2 100644
--- a/homeassistant/components/simplisafe/.translations/ro.json
+++ b/homeassistant/components/simplisafe/.translations/ro.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "Cod (pentru Home Assistant)",
"password": "Parola",
"username": "Adresa de email"
},
diff --git a/homeassistant/components/simplisafe/.translations/ru.json b/homeassistant/components/simplisafe/.translations/ru.json
index 2d8b63c4bab..1e06319672a 100644
--- a/homeassistant/components/simplisafe/.translations/ru.json
+++ b/homeassistant/components/simplisafe/.translations/ru.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "\u041a\u043e\u0434 (\u0434\u043b\u044f Home Assistant)",
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
"username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "\u041a\u043e\u0434 (\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0432 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0435 Home Assistant)"
+ },
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/sl.json b/homeassistant/components/simplisafe/.translations/sl.json
index 7fe0adad2df..15131fb1198 100644
--- a/homeassistant/components/simplisafe/.translations/sl.json
+++ b/homeassistant/components/simplisafe/.translations/sl.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Ta ra\u010dun SimpliSafe je \u017ee v uporabi."
+ },
"error": {
"identifier_exists": "Ra\u010dun je \u017ee registriran",
"invalid_credentials": "Neveljavne poverilnice"
@@ -7,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "Koda (za Home Assistant)",
"password": "Geslo",
"username": "E-po\u0161tni naslov"
},
@@ -15,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "Koda (uporablja se v uporabni\u0161kem vmesniku Home Assistant)"
+ },
+ "title": "Konfigurirajte SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/.translations/sv.json b/homeassistant/components/simplisafe/.translations/sv.json
index 4666a9ea182..28ae99c1dc4 100644
--- a/homeassistant/components/simplisafe/.translations/sv.json
+++ b/homeassistant/components/simplisafe/.translations/sv.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "Kod (f\u00f6r Home Assistant)",
"password": "L\u00f6senord",
"username": "E-postadress"
},
diff --git a/homeassistant/components/simplisafe/.translations/uk.json b/homeassistant/components/simplisafe/.translations/uk.json
index 4dee0ed5f4d..c7938df009e 100644
--- a/homeassistant/components/simplisafe/.translations/uk.json
+++ b/homeassistant/components/simplisafe/.translations/uk.json
@@ -3,7 +3,6 @@
"step": {
"user": {
"data": {
- "code": "\u041a\u043e\u0434 (\u0434\u043b\u044f Home Assistant)",
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
"username": "\u0410\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438"
},
diff --git a/homeassistant/components/simplisafe/.translations/zh-Hans.json b/homeassistant/components/simplisafe/.translations/zh-Hans.json
index 4c57baea77f..2981ee71634 100644
--- a/homeassistant/components/simplisafe/.translations/zh-Hans.json
+++ b/homeassistant/components/simplisafe/.translations/zh-Hans.json
@@ -7,7 +7,6 @@
"step": {
"user": {
"data": {
- "code": "\u4ee3\u7801\uff08\u7528\u4e8eHome Assistant\uff09",
"password": "\u5bc6\u7801",
"username": "\u7535\u5b50\u90ae\u4ef6\u5730\u5740"
},
diff --git a/homeassistant/components/simplisafe/.translations/zh-Hant.json b/homeassistant/components/simplisafe/.translations/zh-Hant.json
index b456bde33c7..bbe44a4fdea 100644
--- a/homeassistant/components/simplisafe/.translations/zh-Hant.json
+++ b/homeassistant/components/simplisafe/.translations/zh-Hant.json
@@ -10,7 +10,6 @@
"step": {
"user": {
"data": {
- "code": "\u9a57\u8b49\u78bc\uff08Home Assistant \u7528\uff09",
"password": "\u5bc6\u78bc",
"username": "\u96fb\u5b50\u90f5\u4ef6\u5730\u5740"
},
@@ -18,5 +17,15 @@
}
},
"title": "SimpliSafe"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "code": "\u9a57\u8b49\u78bc\uff08Home Assistant UI \u4f7f\u7528\uff09"
+ },
+ "title": "\u8a2d\u5b9a SimpliSafe"
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index 8c75ed5d9f5..bf12951e2ae 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -201,10 +201,21 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up SimpliSafe as config entry."""
+ entry_updates = {}
if not config_entry.unique_id:
- hass.config_entries.async_update_entry(
- config_entry, unique_id=config_entry.data[CONF_USERNAME]
- )
+ # If the config entry doesn't already have a unique ID, set one:
+ entry_updates["unique_id"] = config_entry.data[CONF_USERNAME]
+ if CONF_CODE in config_entry.data:
+ # If an alarm code was provided as part of configuration.yaml, pop it out of
+ # the config entry's data and move it to options:
+ data = {**config_entry.data}
+ entry_updates["data"] = data
+ entry_updates["options"] = {
+ **config_entry.options,
+ CONF_CODE: data.pop(CONF_CODE),
+ }
+ if entry_updates:
+ hass.config_entries.async_update_entry(config_entry, **entry_updates)
_verify_domain_control = verify_domain_control(hass, DOMAIN)
@@ -309,6 +320,8 @@ async def async_setup_entry(hass, config_entry):
]:
async_register_admin_service(hass, DOMAIN, service, method, schema=schema)
+ config_entry.add_update_listener(async_update_options)
+
return True
@@ -328,6 +341,12 @@ async def async_unload_entry(hass, entry):
return True
+async def async_update_options(hass, config_entry):
+ """Handle an options update."""
+ simplisafe = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
+ simplisafe.options = config_entry.options
+
+
class SimpliSafeWebsocket:
"""Define a SimpliSafe websocket "manager" object."""
@@ -394,6 +413,7 @@ class SimpliSafe:
self._emergency_refresh_token_used = False
self._hass = hass
self._system_notifications = {}
+ self.options = config_entry.options or {}
self.initial_event_to_use = {}
self.systems = {}
self.websocket = SimpliSafeWebsocket(hass, api.websocket)
@@ -622,13 +642,17 @@ class SimpliSafeEntity(Entity):
@callback
def update():
"""Update the state."""
- self.async_schedule_update_ha_state(True)
+ self.update_from_latest_data()
+ self.async_write_ha_state()
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, TOPIC_UPDATE.format(self._system.system_id), update
)
- async def async_update(self):
+ self.update_from_latest_data()
+
+ @callback
+ def update_from_latest_data(self):
"""Update the entity."""
self.async_update_from_rest_api()
diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py
index 9166c59bec0..4e2393bd238 100644
--- a/homeassistant/components/simplisafe/alarm_control_panel.py
+++ b/homeassistant/components/simplisafe/alarm_control_panel.py
@@ -67,10 +67,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
"""Set up a SimpliSafe alarm control panel based on a config entry."""
simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
async_add_entities(
- [
- SimpliSafeAlarm(simplisafe, system, entry.data.get(CONF_CODE))
- for system in simplisafe.systems.values()
- ],
+ [SimpliSafeAlarm(simplisafe, system) for system in simplisafe.systems.values()],
True,
)
@@ -78,11 +75,10 @@ async def async_setup_entry(hass, entry, async_add_entities):
class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel):
"""Representation of a SimpliSafe alarm."""
- def __init__(self, simplisafe, system, code):
+ def __init__(self, simplisafe, system):
"""Initialize the SimpliSafe alarm."""
super().__init__(simplisafe, system, "Alarm Control Panel")
self._changed_by = None
- self._code = code
self._last_event = None
if system.alarm_going_off:
@@ -125,9 +121,11 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel):
@property
def code_format(self):
"""Return one or more digits/characters."""
- if not self._code:
+ if not self._simplisafe.options.get(CONF_CODE):
return None
- if isinstance(self._code, str) and re.search("^\\d+$", self._code):
+ if isinstance(self._simplisafe.options[CONF_CODE], str) and re.search(
+ "^\\d+$", self._simplisafe.options[CONF_CODE]
+ ):
return FORMAT_NUMBER
return FORMAT_TEXT
@@ -141,16 +139,23 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel):
"""Return the list of supported features."""
return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY
- def _validate_code(self, code, state):
- """Validate given code."""
- check = self._code is None or code == self._code
- if not check:
- _LOGGER.warning("Wrong code entered for %s", state)
- return check
+ @callback
+ def _is_code_valid(self, code, state):
+ """Validate that a code matches the required one."""
+ if not self._simplisafe.options.get(CONF_CODE):
+ return True
+
+ if not code or code != self._simplisafe.options[CONF_CODE]:
+ _LOGGER.warning(
+ "Incorrect alarm code entered (target state: %s): %s", state, code
+ )
+ return False
+
+ return True
async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
- if not self._validate_code(code, "disarming"):
+ if not self._is_code_valid(code, STATE_ALARM_DISARMED):
return
try:
@@ -163,7 +168,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel):
async def async_alarm_arm_home(self, code=None):
"""Send arm home command."""
- if not self._validate_code(code, "arming home"):
+ if not self._is_code_valid(code, STATE_ALARM_ARMED_HOME):
return
try:
@@ -176,7 +181,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel):
async def async_alarm_arm_away(self, code=None):
"""Send arm away command."""
- if not self._validate_code(code, "arming away"):
+ if not self._is_code_valid(code, STATE_ALARM_ARMED_AWAY):
return
try:
diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py
index 4963f9d2de1..031d5496f9d 100644
--- a/homeassistant/components/simplisafe/config_flow.py
+++ b/homeassistant/components/simplisafe/config_flow.py
@@ -5,6 +5,7 @@ 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 # pylint: disable=unused-import
@@ -34,6 +35,12 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors if errors else {},
)
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Define the config flow to handle options."""
+ return SimpliSafeOptionsFlowHandler(config_entry)
+
async def async_step_import(self, import_config):
"""Import a config entry from configuration.yaml."""
return await self.async_step_user(import_config)
@@ -46,17 +53,44 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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)
try:
simplisafe = await API.login_via_credentials(
- username, user_input[CONF_PASSWORD], websession
+ user_input[CONF_USERNAME], user_input[CONF_PASSWORD], websession
)
except SimplipyError:
return await self._show_form(errors={"base": "invalid_credentials"})
return self.async_create_entry(
title=user_input[CONF_USERNAME],
- data={CONF_USERNAME: username, CONF_TOKEN: simplisafe.refresh_token},
+ data={
+ CONF_USERNAME: user_input[CONF_USERNAME],
+ CONF_TOKEN: simplisafe.refresh_token,
+ CONF_CODE: user_input.get(CONF_CODE),
+ },
+ )
+
+
+class SimpliSafeOptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a SimpliSafe options flow."""
+
+ def __init__(self, config_entry):
+ """Initialize."""
+ self.config_entry = config_entry
+
+ async def async_step_init(self, user_input=None):
+ """Manage the options."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_CODE, default=self.config_entry.options.get(CONF_CODE),
+ ): str
+ }
+ ),
)
diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json
index 1d010c67692..cd0cda68125 100644
--- a/homeassistant/components/simplisafe/manifest.json
+++ b/homeassistant/components/simplisafe/manifest.json
@@ -3,7 +3,6 @@
"name": "SimpliSafe",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
- "requirements": ["simplisafe-python==9.0.4"],
- "dependencies": [],
+ "requirements": ["simplisafe-python==9.0.6"],
"codeowners": ["@bachya"]
}
diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json
index 3043bd79104..1c8aadc2192 100644
--- a/homeassistant/components/simplisafe/strings.json
+++ b/homeassistant/components/simplisafe/strings.json
@@ -6,8 +6,7 @@
"title": "Fill in your information",
"data": {
"username": "Email Address",
- "password": "Password",
- "code": "Code (for Home Assistant)"
+ "password": "Password"
}
}
},
@@ -18,5 +17,15 @@
"abort": {
"already_configured": "This SimpliSafe account is already in use."
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Configure SimpliSafe",
+ "data": {
+ "code": "Code (used in Home Assistant UI)"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py
index c0092f013c4..5e48f7b314d 100644
--- a/homeassistant/components/sinch/notify.py
+++ b/homeassistant/components/sinch/notify.py
@@ -83,7 +83,7 @@ class SinchNotificationService(BaseNotificationService):
)
except ErrorResponseException as ex:
_LOGGER.error(
- "Caught ErrorResponseException. Response code: %d (%s)",
+ "Caught ErrorResponseException. Response code: %s (%s)",
ex.error_code,
ex,
)
diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py
index 5ad59da5dee..841fbb68178 100644
--- a/homeassistant/components/sisyphus/__init__.py
+++ b/homeassistant/components/sisyphus/__init__.py
@@ -99,18 +99,23 @@ class TableHolder:
async def get_table(self):
"""Return the Table held by this holder, connecting to it if needed."""
+ if self._table:
+ return self._table
+
if not self._table_task:
self._table_task = self._hass.async_create_task(self._connect_table())
return await self._table_task
async def _connect_table(self):
-
- self._table = await Table.connect(self._host, self._session)
- if self._name is None:
- self._name = self._table.name
- _LOGGER.debug("Connected to %s at %s", self._name, self._host)
- return self._table
+ try:
+ self._table = await Table.connect(self._host, self._session)
+ if self._name is None:
+ self._name = self._table.name
+ _LOGGER.debug("Connected to %s at %s", self._name, self._host)
+ return self._table
+ finally:
+ self._table_task = None
async def close(self):
"""Close the table held by this holder, if any."""
diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json
index 2d78409e21a..86785868170 100644
--- a/homeassistant/components/slack/manifest.json
+++ b/homeassistant/components/slack/manifest.json
@@ -2,7 +2,7 @@
"domain": "slack",
"name": "Slack",
"documentation": "https://www.home-assistant.io/integrations/slack",
- "requirements": ["slacker==0.13.0"],
+ "requirements": ["slackclient==2.5.0"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py
index b645a590c3c..fe6f7ab0d26 100644
--- a/homeassistant/components/slack/notify.py
+++ b/homeassistant/components/slack/notify.py
@@ -1,10 +1,11 @@
"""Slack platform for notify component."""
+import asyncio
import logging
+import os
+from urllib.parse import urlparse
-import requests
-from requests.auth import HTTPBasicAuth, HTTPDigestAuth
-import slacker
-from slacker import Slacker
+from slack import WebClient
+from slack.errors import SlackApiError
import voluptuous as vol
from homeassistant.components.notify import (
@@ -15,157 +16,138 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.const import CONF_API_KEY, CONF_ICON, CONF_USERNAME
-import homeassistant.helpers.config_validation as cv
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client, config_validation as cv
_LOGGER = logging.getLogger(__name__)
-CONF_CHANNEL = "default_channel"
-CONF_TIMEOUT = 15
-
-# Top level attributes in 'data'
ATTR_ATTACHMENTS = "attachments"
+ATTR_BLOCKS = "blocks"
ATTR_FILE = "file"
-# Attributes contained in file
-ATTR_FILE_URL = "url"
-ATTR_FILE_PATH = "path"
-ATTR_FILE_USERNAME = "username"
-ATTR_FILE_PASSWORD = "password"
-ATTR_FILE_AUTH = "auth"
-# Any other value or absence of 'auth' lead to basic authentication being used
-ATTR_FILE_AUTH_DIGEST = "digest"
+
+CONF_DEFAULT_CHANNEL = "default_channel"
+
+DEFAULT_TIMEOUT_SECONDS = 15
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_CHANNEL): cv.string,
+ vol.Required(CONF_DEFAULT_CHANNEL): cv.string,
vol.Optional(CONF_ICON): cv.string,
vol.Optional(CONF_USERNAME): cv.string,
}
)
-def get_service(hass, config, discovery_info=None):
- """Get the Slack notification service."""
-
- channel = config.get(CONF_CHANNEL)
- api_key = config.get(CONF_API_KEY)
- username = config.get(CONF_USERNAME)
- icon = config.get(CONF_ICON)
+async def async_get_service(hass, config, discovery_info=None):
+ """Set up the Slack notification service."""
+ session = aiohttp_client.async_get_clientsession(hass)
+ client = WebClient(token=config[CONF_API_KEY], run_async=True, session=session)
try:
- return SlackNotificationService(
- channel, api_key, username, icon, hass.config.is_allowed_path
- )
+ await client.auth_test()
+ except SlackApiError as err:
+ _LOGGER.error("Error while setting up integration: %s", err)
+ return
- except slacker.Error:
- _LOGGER.exception("Authentication failed")
- return None
+ return SlackNotificationService(
+ hass,
+ client,
+ config[CONF_DEFAULT_CHANNEL],
+ username=config.get(CONF_USERNAME),
+ icon=config.get(CONF_ICON),
+ )
+
+
+@callback
+def _async_sanitize_channel_names(channel_list):
+ """Remove any # symbols from a channel list."""
+ return [channel.lstrip("#") for channel in channel_list]
class SlackNotificationService(BaseNotificationService):
- """Implement the notification service for Slack."""
-
- def __init__(self, default_channel, api_token, username, icon, is_allowed_path):
- """Initialize the service."""
+ """Define the Slack notification logic."""
+ def __init__(self, hass, client, default_channel, username, icon):
+ """Initialize."""
+ self._client = client
self._default_channel = default_channel
- self._api_token = api_token
- self._username = username
+ self._hass = hass
self._icon = icon
- if self._username or self._icon:
+
+ if username or self._icon:
self._as_user = False
else:
self._as_user = True
- self.is_allowed_path = is_allowed_path
- self.slack = Slacker(self._api_token)
- self.slack.auth.test()
+ async def _async_send_local_file_message(self, path, targets, message, title):
+ """Upload a local file (with message) to Slack."""
+ if not self._hass.config.is_allowed_path(path):
+ _LOGGER.error("Path does not exist or is not allowed: %s", path)
+ return
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
+ parsed_url = urlparse(path)
+ filename = os.path.basename(parsed_url.path)
- if kwargs.get(ATTR_TARGET) is None:
- targets = [self._default_channel]
- else:
- targets = kwargs.get(ATTR_TARGET)
-
- data = kwargs.get(ATTR_DATA)
- attachments = data.get(ATTR_ATTACHMENTS) if data else None
- file = data.get(ATTR_FILE) if data else None
- title = kwargs.get(ATTR_TITLE)
-
- for target in targets:
- try:
- if file is not None:
- # Load from file or URL
- file_as_bytes = self.load_file(
- url=file.get(ATTR_FILE_URL),
- local_path=file.get(ATTR_FILE_PATH),
- username=file.get(ATTR_FILE_USERNAME),
- password=file.get(ATTR_FILE_PASSWORD),
- auth=file.get(ATTR_FILE_AUTH),
- )
- # Choose filename
- if file.get(ATTR_FILE_URL):
- filename = file.get(ATTR_FILE_URL)
- else:
- filename = file.get(ATTR_FILE_PATH)
- # Prepare structure for Slack API
- data = {
- "content": None,
- "filetype": None,
- "filename": filename,
- # If optional title is none use the filename
- "title": title if title else filename,
- "initial_comment": message,
- "channels": target,
- }
- # Post to slack
- self.slack.files.post(
- "files.upload", data=data, files={"file": file_as_bytes}
- )
- else:
- self.slack.chat.post_message(
- target,
- message,
- as_user=self._as_user,
- username=self._username,
- icon_emoji=self._icon,
- attachments=attachments,
- link_names=True,
- )
- except slacker.Error as err:
- _LOGGER.error("Could not send notification. Error: %s", err)
-
- def load_file(
- self, url=None, local_path=None, username=None, password=None, auth=None
- ):
- """Load image/document/etc from a local path or URL."""
try:
- if url:
- # Check whether authentication parameters are provided
- if username:
- # Use digest or basic authentication
- if ATTR_FILE_AUTH_DIGEST == auth:
- auth_ = HTTPDigestAuth(username, password)
- else:
- auth_ = HTTPBasicAuth(username, password)
- # Load file from URL with authentication
- req = requests.get(url, auth=auth_, timeout=CONF_TIMEOUT)
- else:
- # Load file from URL without authentication
- req = requests.get(url, timeout=CONF_TIMEOUT)
- return req.content
+ await self._client.files_upload(
+ channels=",".join(targets),
+ file=path,
+ filename=filename,
+ initial_comment=message,
+ title=title or filename,
+ )
+ except SlackApiError as err:
+ _LOGGER.error("Error while uploading file-based message: %s", err)
- if local_path:
- # Check whether path is whitelisted in configuration.yaml
- if self.is_allowed_path(local_path):
- return open(local_path, "rb")
- _LOGGER.warning("'%s' is not secure to load data from!", local_path)
- else:
- _LOGGER.warning("Neither URL nor local path found in params!")
+ async def _async_send_text_only_message(
+ self, targets, message, title, attachments, blocks
+ ):
+ """Send a text-only message."""
+ tasks = {
+ target: self._client.chat_postMessage(
+ channel=target,
+ text=message,
+ as_user=self._as_user,
+ attachments=attachments,
+ blocks=blocks,
+ icon_emoji=self._icon,
+ link_names=True,
+ )
+ for target in targets
+ }
- except OSError as error:
- _LOGGER.error("Can't load from URL or local path: %s", error)
+ results = await asyncio.gather(*tasks.values(), return_exceptions=True)
+ for target, result in zip(tasks, results):
+ if isinstance(result, SlackApiError):
+ _LOGGER.error(
+ "There was a Slack API error while sending to %s: %s",
+ target,
+ result,
+ )
- return None
+ async def async_send_message(self, message, **kwargs):
+ """Send a message to Slack."""
+ data = kwargs[ATTR_DATA] or {}
+ title = kwargs.get(ATTR_TITLE)
+ targets = _async_sanitize_channel_names(
+ kwargs.get(ATTR_TARGET, [self._default_channel])
+ )
+
+ if ATTR_FILE in data:
+ return await self._async_send_local_file_message(
+ data[ATTR_FILE], targets, message, title
+ )
+
+ attachments = data.get(ATTR_ATTACHMENTS, {})
+ if attachments:
+ _LOGGER.warning(
+ "Attachments are deprecated and part of Slack's legacy API; support "
+ "for them will be dropped in 0.114.0. In most cases, Blocks should be "
+ "used instead: https://www.home-assistant.io/integrations/slack/"
+ )
+ blocks = data.get(ATTR_BLOCKS, {})
+
+ return await self._async_send_text_only_message(
+ targets, message, title, attachments, blocks
+ )
diff --git a/homeassistant/components/smartthings/.translations/no.json b/homeassistant/components/smartthings/.translations/no.json
index b539e315ea3..a25de0e2feb 100644
--- a/homeassistant/components/smartthings/.translations/no.json
+++ b/homeassistant/components/smartthings/.translations/no.json
@@ -23,6 +23,6 @@
"title": "Installer SmartApp"
}
},
- "title": "SmartThings"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json
index e6bb655dc59..af8c64ac06f 100644
--- a/homeassistant/components/smhi/manifest.json
+++ b/homeassistant/components/smhi/manifest.json
@@ -3,7 +3,7 @@
"name": "SMHI",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smhi",
- "requirements": ["smhi-pkg==1.0.10"],
+ "requirements": ["smhi-pkg==1.0.13"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/solaredge/.translations/no.json b/homeassistant/components/solaredge/.translations/no.json
index ad7cb55316b..4dd4177dd15 100644
--- a/homeassistant/components/solaredge/.translations/no.json
+++ b/homeassistant/components/solaredge/.translations/no.json
@@ -16,6 +16,6 @@
"title": "Definer API-parametrene for denne installasjonen"
}
},
- "title": "SolarEdge"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/solarlog/.translations/no.json b/homeassistant/components/solarlog/.translations/no.json
index 017e886c817..9fddb46cdcf 100644
--- a/homeassistant/components/solarlog/.translations/no.json
+++ b/homeassistant/components/solarlog/.translations/no.json
@@ -16,6 +16,6 @@
"title": "Definer din Solar-Log tilkobling"
}
},
- "title": "Solar-Log"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/soma/.translations/ko.json b/homeassistant/components/soma/.translations/ko.json
index ae4d84671a3..ea79a455924 100644
--- a/homeassistant/components/soma/.translations/ko.json
+++ b/homeassistant/components/soma/.translations/ko.json
@@ -16,7 +16,7 @@
"host": "\ud638\uc2a4\ud2b8",
"port": "\ud3ec\ud2b8"
},
- "description": "SOMA Connect \uc640\uc758 \uc5f0\uacb0 \uc124\uc815\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "description": "SOMA Connect \uc5f0\uacb0 \uc124\uc815\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.",
"title": "SOMA Connect"
}
},
diff --git a/homeassistant/components/soma/.translations/no.json b/homeassistant/components/soma/.translations/no.json
index 518cbc6a37e..c8cfa1473fe 100644
--- a/homeassistant/components/soma/.translations/no.json
+++ b/homeassistant/components/soma/.translations/no.json
@@ -14,12 +14,12 @@
"user": {
"data": {
"host": "Vert",
- "port": "Port"
+ "port": ""
},
"description": "Vennligst skriv tilkoblingsinnstillingene for din SOMA Connect.",
- "title": "SOMA Connect"
+ "title": ""
}
},
- "title": "Soma"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index 74a8dea06d4..a015e7a5095 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -3,12 +3,12 @@
"name": "Sonos",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonos",
- "requirements": ["pysonos==0.0.24"],
+ "requirements": ["pysonos==0.0.25"],
"dependencies": [],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
}
],
- "codeowners": []
+ "codeowners": ["@amelchio"]
}
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 8828c27e9c7..13484e6901b 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -4,12 +4,12 @@ import datetime
import functools as ft
import logging
import socket
-import urllib
import async_timeout
import pysonos
from pysonos import alarms
from pysonos.exceptions import SoCoException, SoCoUPnPException
+import pysonos.music_library
import pysonos.snapshot
import voluptuous as vol
@@ -338,19 +338,6 @@ def _timespan_secs(timespan):
return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))))
-def _is_radio_uri(uri):
- """Return whether the URI is a stream (not a playlist)."""
- radio_schemes = (
- "x-rincon-mp3radio:",
- "x-sonosapi-stream:",
- "x-sonosapi-radio:",
- "x-sonosapi-hls:",
- "hls-radio:",
- "x-rincon-stream:",
- )
- return uri.startswith(radio_schemes)
-
-
class SonosEntity(MediaPlayerDevice):
"""Representation of a Sonos entity."""
@@ -503,6 +490,11 @@ class SonosEntity(MediaPlayerDevice):
"""Return True if entity is available."""
return self._seen_timer is not None
+ def _clear_media_position(self):
+ """Clear the media_position."""
+ self._media_position = None
+ self._media_position_updated_at = None
+
def _set_favorites(self):
"""Set available favorites."""
self._favorites = []
@@ -515,17 +507,6 @@ class SonosEntity(MediaPlayerDevice):
# Skip unknown types
_LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex)
- def _radio_artwork(self, url):
- """Return the private URL with artwork for a radio stream."""
- if url in UNAVAILABLE_VALUES:
- return None
-
- if url.find("tts_proxy") > 0:
- # If the content is a tts don't try to fetch an image from it.
- return None
-
- return f"http://{self.soco.ip_address}:1400/getaa?s=1&u={urllib.parse.quote(url, safe='')}"
-
def _attach_player(self):
"""Get basic information and add event subscriptions."""
try:
@@ -576,6 +557,12 @@ class SonosEntity(MediaPlayerDevice):
self._shuffle = self.soco.shuffle
self._uri = None
+ self._media_duration = None
+ self._media_image_url = None
+ self._media_artist = None
+ self._media_album_name = None
+ self._media_title = None
+ self._source_name = None
update_position = new_status != self._status
self._status = new_status
@@ -586,13 +573,20 @@ class SonosEntity(MediaPlayerDevice):
self.update_media_linein(SOURCE_LINEIN)
else:
track_info = self.soco.get_current_track_info()
- self._uri = track_info["uri"]
-
- if _is_radio_uri(track_info["uri"]):
- variables = event and event.variables
- self.update_media_radio(variables, track_info)
+ if not track_info["uri"]:
+ self._clear_media_position()
else:
- self.update_media_music(update_position, track_info)
+ self._uri = track_info["uri"]
+ self._media_artist = track_info.get("artist")
+ self._media_album_name = track_info.get("album")
+ self._media_title = track_info.get("title")
+
+ if self.soco.is_radio_uri(track_info["uri"]):
+ variables = event and event.variables
+ self.update_media_radio(variables, track_info)
+ else:
+ variables = event and event.variables
+ self.update_media_music(update_position, track_info)
self.schedule_update_ha_state()
@@ -604,74 +598,33 @@ class SonosEntity(MediaPlayerDevice):
def update_media_linein(self, source):
"""Update state when playing from line-in/tv."""
- self._media_duration = None
- self._media_position = None
- self._media_position_updated_at = None
+ self._clear_media_position()
- self._media_image_url = None
-
- self._media_artist = None
- self._media_album_name = None
self._media_title = source
-
self._source_name = source
def update_media_radio(self, variables, track_info):
"""Update state when streaming radio."""
- self._media_duration = None
- self._media_position = None
- self._media_position_updated_at = None
+ self._clear_media_position()
- media_info = self.soco.avTransport.GetMediaInfo([("InstanceID", 0)])
- self._media_image_url = self._radio_artwork(media_info["CurrentURI"])
+ try:
+ library = pysonos.music_library.MusicLibrary(self.soco)
+ album_art_uri = variables["current_track_meta_data"].album_art_uri
+ self._media_image_url = library.build_album_art_full_uri(album_art_uri)
+ except (TypeError, KeyError, AttributeError):
+ pass
- self._media_artist = track_info.get("artist")
- self._media_album_name = None
- self._media_title = track_info.get("title")
-
- if self._media_artist and self._media_title:
- # artist and album name are in the data, concatenate
- # that do display as artist.
- # "Information" field in the sonos pc app
- self._media_artist = "{artist} - {title}".format(
- artist=self._media_artist, title=self._media_title
- )
- elif variables:
- # "On Now" field in the sonos pc app
- current_track_metadata = variables.get("current_track_meta_data")
- if current_track_metadata:
- self._media_artist = current_track_metadata.radio_show.split(",")[0]
-
- # For radio streams we set the radio station name as the title.
- current_uri_metadata = media_info["CurrentURIMetaData"]
- if current_uri_metadata not in UNAVAILABLE_VALUES:
- # currently soco does not have an API for this
- current_uri_metadata = pysonos.xml.XML.fromstring(
- pysonos.utils.really_utf8(current_uri_metadata)
- )
-
- md_title = current_uri_metadata.findtext(
- ".//{http://purl.org/dc/elements/1.1/}title"
- )
-
- if md_title not in UNAVAILABLE_VALUES:
- self._media_title = md_title
-
- if self._media_artist and self._media_title:
- # some radio stations put their name into the artist
- # name, e.g.:
- # media_title = "Station"
- # media_artist = "Station - Artist - Title"
- # detect this case and trim from the front of
- # media_artist for cosmetics
- trim = f"{self._media_title} - "
- chars = min(len(self._media_artist), len(trim))
-
- if self._media_artist[:chars].upper() == trim[:chars].upper():
- self._media_artist = self._media_artist[chars:]
+ # Radios without tagging can have part of the radio URI as title.
+ # Non-playing radios will not have a current title. In these cases we
+ # try to use the radio name instead.
+ try:
+ if self._media_title in self._uri or self.state != STATE_PLAYING:
+ self._media_title = variables["enqueued_transport_uri_meta_data"].title
+ except (TypeError, KeyError, AttributeError):
+ pass
# Check if currently playing radio station is in favorites
- self._source_name = None
+ media_info = self.soco.avTransport.GetMediaInfo([("InstanceID", 0)])
for fav in self._favorites:
if fav.reference.get_uri() == media_info["CurrentURI"]:
self._source_name = fav.title
@@ -685,37 +638,29 @@ class SonosEntity(MediaPlayerDevice):
)
rel_time = _timespan_secs(position_info.get("RelTime"))
- # player no longer reports position?
- update_media_position |= rel_time is None and self._media_position is not None
-
# player started reporting position?
update_media_position |= rel_time is not None and self._media_position is None
# position jumped?
- if (
- self.state == STATE_PLAYING
- and rel_time is not None
- and self._media_position is not None
- ):
- time_diff = utcnow() - self._media_position_updated_at
- time_diff = time_diff.total_seconds()
+ if rel_time is not None and self._media_position is not None:
+ if self.state == STATE_PLAYING:
+ time_diff = utcnow() - self._media_position_updated_at
+ time_diff = time_diff.total_seconds()
+ else:
+ time_diff = 0
calculated_position = self._media_position + time_diff
update_media_position |= abs(calculated_position - rel_time) > 1.5
- if update_media_position:
+ if rel_time is None:
+ self._clear_media_position()
+ elif update_media_position:
self._media_position = rel_time
self._media_position_updated_at = utcnow()
self._media_image_url = track_info.get("album_art")
- self._media_artist = track_info.get("artist")
- self._media_album_name = track_info.get("album")
- self._media_title = track_info.get("title")
-
- self._source_name = None
-
def update_volume(self, event=None):
"""Update information about currently volume settings."""
if event:
@@ -834,6 +779,7 @@ class SonosEntity(MediaPlayerDevice):
return self._shuffle
@property
+ @soco_coordinator
def media_content_id(self):
"""Content id of current playing media."""
return self._uri
@@ -936,7 +882,7 @@ class SonosEntity(MediaPlayerDevice):
if len(fav) == 1:
src = fav.pop()
uri = src.reference.get_uri()
- if _is_radio_uri(uri):
+ if self.soco.is_radio_uri(uri):
self.soco.play_uri(uri, title=source)
else:
self.soco.clear_queue()
diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py
index 1d82c38d088..2f64a2d3605 100644
--- a/homeassistant/components/soundtouch/media_player.py
+++ b/homeassistant/components/soundtouch/media_player.py
@@ -437,15 +437,25 @@ class SoundTouchDevice(MediaPlayerDevice):
# slaves for some reason. To compensate for this shortcoming we have to fetch
# the zone info from the master when the current device is a slave until this is
# fixed in the SoundTouch API or libsoundtouch, or of course until somebody has a
- # better idea on how to fix this
- if zone_status.is_master:
+ # better idea on how to fix this.
+ # In addition to this shortcoming, libsoundtouch seems to report the "is_master"
+ # property wrong on some slaves, so the only reliable way to detect if the current
+ # devices is the master, is by comparing the master_id of the zone with the device_id
+ if zone_status.master_id == self._device.config.device_id:
return self._build_zone_info(self.entity_id, zone_status.slaves)
- master_instance = self._get_instance_by_ip(zone_status.master_ip)
- master_zone_status = master_instance.device.zone_status()
- return self._build_zone_info(
- master_instance.entity_id, master_zone_status.slaves
- )
+ # The master device has to be searched by it's ID and not IP since libsoundtouch / BOSE API
+ # do not return the IP of the master for some slave objects/responses
+ master_instance = self._get_instance_by_id(zone_status.master_id)
+ if master_instance is not None:
+ master_zone_status = master_instance.device.zone_status()
+ return self._build_zone_info(
+ master_instance.entity_id, master_zone_status.slaves
+ )
+
+ # We should never end up here since this means we haven't found a master device to get the
+ # correct zone info from. In this case, assume current device is master
+ return self._build_zone_info(self.entity_id, zone_status.slaves)
def _get_instance_by_ip(self, ip_address):
"""Search and return a SoundTouchDevice instance by it's IP address."""
@@ -454,6 +464,13 @@ class SoundTouchDevice(MediaPlayerDevice):
return instance
return None
+ def _get_instance_by_id(self, instance_id):
+ """Search and return a SoundTouchDevice instance by it's ID (aka MAC address)."""
+ for instance in self.hass.data[DATA_SOUNDTOUCH]:
+ if instance and instance.device.config.device_id == instance_id:
+ return instance
+ return None
+
def _build_zone_info(self, master, zone_slaves):
"""Build the exposed zone attributes."""
slaves = []
diff --git a/homeassistant/components/spotify/.translations/fr.json b/homeassistant/components/spotify/.translations/fr.json
index b6ec983df76..9c233b9b947 100644
--- a/homeassistant/components/spotify/.translations/fr.json
+++ b/homeassistant/components/spotify/.translations/fr.json
@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_setup": "Vous ne pouvez configurer qu'un seul compte Spotify.",
+ "authorize_url_timeout": "D\u00e9lai d'expiration g\u00e9n\u00e9rant une URL d'autorisation.",
"missing_configuration": "L'int\u00e9gration Spotify n'est pas configur\u00e9e. Veuillez suivre la documentation."
},
"create_entry": {
diff --git a/homeassistant/components/spotify/.translations/no.json b/homeassistant/components/spotify/.translations/no.json
index 69b046cad0c..7a545d32bad 100644
--- a/homeassistant/components/spotify/.translations/no.json
+++ b/homeassistant/components/spotify/.translations/no.json
@@ -13,6 +13,6 @@
"title": "Velg autentiseringsmetode"
}
},
- "title": "Spotify"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json
index be58d2bab40..f246c657708 100644
--- a/homeassistant/components/spotify/manifest.json
+++ b/homeassistant/components/spotify/manifest.json
@@ -2,7 +2,7 @@
"domain": "spotify",
"name": "Spotify",
"documentation": "https://www.home-assistant.io/integrations/spotify",
- "requirements": ["spotipy==2.7.1"],
+ "requirements": ["spotipy==2.10.0"],
"zeroconf": ["_spotify-connect._tcp.local."],
"dependencies": ["http"],
"codeowners": ["@frenck"],
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
index 9588f428a66..7a00fb02146 100644
--- a/homeassistant/components/spotify/media_player.py
+++ b/homeassistant/components/spotify/media_player.py
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Optional
from aiohttp import ClientError
from spotipy import Spotify, SpotifyException
+from yarl import URL
from homeassistant.components.media_player import MediaPlayerDevice
from homeassistant.components.media_player.const import (
@@ -295,6 +296,10 @@ class SpotifyMediaPlayer(MediaPlayerDevice):
"""Play media."""
kwargs = {}
+ # Spotify can't handle URI's with query strings or anchors
+ # Yet, they do generate those types of URI in their official clients.
+ media_id = str(URL(media_id).with_query(None).with_fragment(None))
+
if media_type == MEDIA_TYPE_MUSIC:
kwargs["uris"] = [media_id]
elif media_type == MEDIA_TYPE_PLAYLIST:
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index de2fce5b1a1..9d6e7f7b62b 100644
--- a/homeassistant/components/sql/manifest.json
+++ b/homeassistant/components/sql/manifest.json
@@ -2,7 +2,7 @@
"domain": "sql",
"name": "SQL",
"documentation": "https://www.home-assistant.io/integrations/sql",
- "requirements": ["sqlalchemy==1.3.13"],
+ "requirements": ["sqlalchemy==1.3.15"],
"dependencies": [],
"codeowners": ["@dgomes"]
}
diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py
index 956e629dd9d..3712b47671f 100644
--- a/homeassistant/components/stiebel_eltron/__init__.py
+++ b/homeassistant/components/stiebel_eltron/__init__.py
@@ -5,11 +5,7 @@ import logging
from pystiebeleltron import pystiebeleltron
import voluptuous as vol
-from homeassistant.components.modbus import (
- CONF_HUB,
- DEFAULT_HUB,
- DOMAIN as MODBUS_DOMAIN,
-)
+from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN
from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
diff --git a/homeassistant/components/switch/.translations/bg.json b/homeassistant/components/switch/.translations/bg.json
index 64a3ea94e1b..19a853dba97 100644
--- a/homeassistant/components/switch/.translations/bg.json
+++ b/homeassistant/components/switch/.translations/bg.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d",
- "is_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d",
- "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}",
- "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}"
+ "is_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d"
},
"trigger_type": {
"turned_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}",
diff --git a/homeassistant/components/switch/.translations/ca.json b/homeassistant/components/switch/.translations/ca.json
index dbf5e152656..0f1101eca75 100644
--- a/homeassistant/components/switch/.translations/ca.json
+++ b/homeassistant/components/switch/.translations/ca.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} est\u00e0 apagat",
- "is_on": "{entity_name} est\u00e0 enc\u00e8s",
- "turn_off": "{entity_name} desactivat",
- "turn_on": "{entity_name} activat"
+ "is_on": "{entity_name} est\u00e0 enc\u00e8s"
},
"trigger_type": {
"turned_off": "{entity_name} desactivat",
diff --git a/homeassistant/components/switch/.translations/da.json b/homeassistant/components/switch/.translations/da.json
index 2514a56a010..eefa1e8bb6e 100644
--- a/homeassistant/components/switch/.translations/da.json
+++ b/homeassistant/components/switch/.translations/da.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} er fra",
- "is_on": "{entity_name} er til",
- "turn_off": "{entity_name} slukket",
- "turn_on": "{entity_name} t\u00e6ndt"
+ "is_on": "{entity_name} er til"
},
"trigger_type": {
"turned_off": "{entity_name} slukkede",
diff --git a/homeassistant/components/switch/.translations/de.json b/homeassistant/components/switch/.translations/de.json
index 5396facadd7..76496da6dc8 100644
--- a/homeassistant/components/switch/.translations/de.json
+++ b/homeassistant/components/switch/.translations/de.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} ist ausgeschaltet",
- "is_on": "{entity_name} ist eingeschaltet",
- "turn_off": "{entity_name} ausgeschaltet",
- "turn_on": "{entity_name} eingeschaltet"
+ "is_on": "{entity_name} ist eingeschaltet"
},
"trigger_type": {
"turned_off": "{entity_name} ausgeschaltet",
diff --git a/homeassistant/components/switch/.translations/en.json b/homeassistant/components/switch/.translations/en.json
index 391a071cb8f..3f37de5331e 100644
--- a/homeassistant/components/switch/.translations/en.json
+++ b/homeassistant/components/switch/.translations/en.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} is off",
- "is_on": "{entity_name} is on",
- "turn_off": "{entity_name} turned off",
- "turn_on": "{entity_name} turned on"
+ "is_on": "{entity_name} is on"
},
"trigger_type": {
"turned_off": "{entity_name} turned off",
diff --git a/homeassistant/components/switch/.translations/es-419.json b/homeassistant/components/switch/.translations/es-419.json
index f9607852036..b42b2ce56fa 100644
--- a/homeassistant/components/switch/.translations/es-419.json
+++ b/homeassistant/components/switch/.translations/es-419.json
@@ -6,9 +6,7 @@
},
"condition_type": {
"is_off": "{entity_name} est\u00e1 apagado",
- "is_on": "{entity_name} est\u00e1 encendido",
- "turn_off": "{entity_name} apagado",
- "turn_on": "{entity_name} encendido"
+ "is_on": "{entity_name} est\u00e1 encendido"
},
"trigger_type": {
"turned_off": "{entity_name} apagado",
diff --git a/homeassistant/components/switch/.translations/es.json b/homeassistant/components/switch/.translations/es.json
index 24dbc2cdc1f..c6790619182 100644
--- a/homeassistant/components/switch/.translations/es.json
+++ b/homeassistant/components/switch/.translations/es.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} est\u00e1 apagada",
- "is_on": "{entity_name} est\u00e1 encendida",
- "turn_off": "{entity_name} apagado",
- "turn_on": "{entity_name} encendido"
+ "is_on": "{entity_name} est\u00e1 encendida"
},
"trigger_type": {
"turned_off": "{entity_name} apagado",
diff --git a/homeassistant/components/switch/.translations/fr.json b/homeassistant/components/switch/.translations/fr.json
index 807b85c5fb5..adc91477a23 100644
--- a/homeassistant/components/switch/.translations/fr.json
+++ b/homeassistant/components/switch/.translations/fr.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} est \u00e9teint",
- "is_on": "{entity_name} est allum\u00e9",
- "turn_off": "{entity_name} \u00e9teint",
- "turn_on": "{entity_name} allum\u00e9"
+ "is_on": "{entity_name} est allum\u00e9"
},
"trigger_type": {
"turned_off": "{entity_name} \u00e9teint",
diff --git a/homeassistant/components/switch/.translations/hu.json b/homeassistant/components/switch/.translations/hu.json
index c3ea3190694..3fba61a4848 100644
--- a/homeassistant/components/switch/.translations/hu.json
+++ b/homeassistant/components/switch/.translations/hu.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} ki van kapcsolva",
- "is_on": "{entity_name} be van kapcsolva",
- "turn_off": "{entity_name} ki lett kapcsolva",
- "turn_on": "{entity_name} be lett kapcsolva"
+ "is_on": "{entity_name} be van kapcsolva"
},
"trigger_type": {
"turned_off": "{entity_name} ki lett kapcsolva",
diff --git a/homeassistant/components/switch/.translations/it.json b/homeassistant/components/switch/.translations/it.json
index ec742e4113b..32f479b8b5c 100644
--- a/homeassistant/components/switch/.translations/it.json
+++ b/homeassistant/components/switch/.translations/it.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} \u00e8 disattivato",
- "is_on": "{entity_name} \u00e8 attivo",
- "turn_off": "{entity_name} disattivato",
- "turn_on": "{entity_name} attivato"
+ "is_on": "{entity_name} \u00e8 attivo"
},
"trigger_type": {
"turned_off": "{entity_name} disattivato",
diff --git a/homeassistant/components/switch/.translations/ko.json b/homeassistant/components/switch/.translations/ko.json
index d3b9b1dd169..b923fdb210e 100644
--- a/homeassistant/components/switch/.translations/ko.json
+++ b/homeassistant/components/switch/.translations/ko.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74",
- "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74",
- "turn_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74",
- "turn_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74"
+ "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74"
},
"trigger_type": {
"turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c",
diff --git a/homeassistant/components/switch/.translations/lb.json b/homeassistant/components/switch/.translations/lb.json
index 8e974a0a8de..a7f807e8dcd 100644
--- a/homeassistant/components/switch/.translations/lb.json
+++ b/homeassistant/components/switch/.translations/lb.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} ass aus",
- "is_on": "{entity_name} ass un",
- "turn_off": "{entity_name} gouf ausgeschalt",
- "turn_on": "{entity_name} gouf ugeschalt"
+ "is_on": "{entity_name} ass un"
},
"trigger_type": {
"turned_off": "{entity_name} gouf ausgeschalt",
diff --git a/homeassistant/components/switch/.translations/lv.json b/homeassistant/components/switch/.translations/lv.json
index 784a9a37afa..7668dfa5ac8 100644
--- a/homeassistant/components/switch/.translations/lv.json
+++ b/homeassistant/components/switch/.translations/lv.json
@@ -1,9 +1,5 @@
{
"device_automation": {
- "condition_type": {
- "turn_off": "{entity_name} tika izsl\u0113gta",
- "turn_on": "{entity_name} tika iesl\u0113gta"
- },
"trigger_type": {
"turned_off": "{entity_name} tika izsl\u0113gta",
"turned_on": "{entity_name} tika iesl\u0113gta"
diff --git a/homeassistant/components/switch/.translations/nl.json b/homeassistant/components/switch/.translations/nl.json
index 5e2aa6747a4..905ad413090 100644
--- a/homeassistant/components/switch/.translations/nl.json
+++ b/homeassistant/components/switch/.translations/nl.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} is uitgeschakeld",
- "is_on": "{entity_name} is ingeschakeld",
- "turn_off": "{entity_name} uitgeschakeld",
- "turn_on": "{entity_name} ingeschakeld"
+ "is_on": "{entity_name} is ingeschakeld"
},
"trigger_type": {
"turned_off": "{entity_name} uitgeschakeld",
diff --git a/homeassistant/components/switch/.translations/no.json b/homeassistant/components/switch/.translations/no.json
index 3469079f230..785e9ca2912 100644
--- a/homeassistant/components/switch/.translations/no.json
+++ b/homeassistant/components/switch/.translations/no.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} er av",
- "is_on": "{entity_name} er p\u00e5",
- "turn_off": "{entity_name} sl\u00e5tt av",
- "turn_on": "{entity_name} sl\u00e5tt p\u00e5"
+ "is_on": "{entity_name} er p\u00e5"
},
"trigger_type": {
"turned_off": "{entity_name} sl\u00e5tt av",
diff --git a/homeassistant/components/switch/.translations/pl.json b/homeassistant/components/switch/.translations/pl.json
index 3d352aa2b58..930694de8ca 100644
--- a/homeassistant/components/switch/.translations/pl.json
+++ b/homeassistant/components/switch/.translations/pl.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "prze\u0142\u0105cznik {entity_name} jest wy\u0142\u0105czony",
- "is_on": "prze\u0142\u0105cznik {entity_name} jest w\u0142\u0105czony",
- "turn_off": "prze\u0142\u0105cznik {entity_name} wy\u0142\u0105czony",
- "turn_on": "prze\u0142\u0105cznik {entity_name} w\u0142\u0105czony"
+ "is_on": "prze\u0142\u0105cznik {entity_name} jest w\u0142\u0105czony"
},
"trigger_type": {
"turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}",
diff --git a/homeassistant/components/switch/.translations/ru.json b/homeassistant/components/switch/.translations/ru.json
index 74503eea60b..8ca964606ae 100644
--- a/homeassistant/components/switch/.translations/ru.json
+++ b/homeassistant/components/switch/.translations/ru.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438",
- "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438",
- "turn_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438",
- "turn_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438"
+ "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438"
},
"trigger_type": {
"turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f",
diff --git a/homeassistant/components/switch/.translations/sl.json b/homeassistant/components/switch/.translations/sl.json
index f1b851b05b6..bef4f1583b6 100644
--- a/homeassistant/components/switch/.translations/sl.json
+++ b/homeassistant/components/switch/.translations/sl.json
@@ -7,9 +7,7 @@
},
"condition_type": {
"is_off": "{entity_name} je izklopljen",
- "is_on": "{entity_name} je vklopljen",
- "turn_off": "{entity_name} izklopljen",
- "turn_on": "{entity_name} vklopljen"
+ "is_on": "{entity_name} je vklopljen"
},
"trigger_type": {
"turned_off": "{entity_name} izklopljen",
diff --git a/homeassistant/components/switch/.translations/sv.json b/homeassistant/components/switch/.translations/sv.json
index 3ec36265e52..ed5367e0013 100644
--- a/homeassistant/components/switch/.translations/sv.json
+++ b/homeassistant/components/switch/.translations/sv.json
@@ -7,9 +7,7 @@
},
"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"
+ "is_on": "{entity_name} \u00e4r p\u00e5"
},
"trigger_type": {
"turned_off": "{entity_name} st\u00e4ngdes av",
diff --git a/homeassistant/components/switch/.translations/zh-Hant.json b/homeassistant/components/switch/.translations/zh-Hant.json
index 3eaac840497..d8bda90de85 100644
--- a/homeassistant/components/switch/.translations/zh-Hant.json
+++ b/homeassistant/components/switch/.translations/zh-Hant.json
@@ -7,9 +7,7 @@
},
"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_on": "{entity_name}\u5df2\u958b\u555f"
},
"trigger_type": {
"turned_off": "{entity_name}\u5df2\u95dc\u9589",
diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py
index 5486b8d880c..92b64b36b93 100644
--- a/homeassistant/components/switch/light.py
+++ b/homeassistant/components/switch/light.py
@@ -1,6 +1,6 @@
"""Light support for switch entities."""
import logging
-from typing import Callable, Dict, Optional, Sequence, cast
+from typing import Callable, Optional, Sequence, cast
import voluptuous as vol
@@ -17,7 +17,11 @@ from homeassistant.core import CALLBACK_TYPE, State, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_state_change
-from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.helpers.typing import (
+ ConfigType,
+ DiscoveryInfoType,
+ HomeAssistantType,
+)
# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
@@ -37,7 +41,7 @@ async def async_setup_platform(
hass: HomeAssistantType,
config: ConfigType,
async_add_entities: Callable[[Sequence[Entity], bool], None],
- discovery_info: Optional[Dict] = None,
+ discovery_info: Optional[DiscoveryInfoType] = None,
) -> None:
"""Initialize Light Switch platform."""
async_add_entities(
diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
index 4193a88e3ed..b076b254b9f 100644
--- a/homeassistant/components/switchbot/manifest.json
+++ b/homeassistant/components/switchbot/manifest.json
@@ -2,7 +2,7 @@
"domain": "switchbot",
"name": "SwitchBot",
"documentation": "https://www.home-assistant.io/integrations/switchbot",
- "requirements": ["PySwitchbot==0.6.2"],
+ "requirements": ["PySwitchbot==0.8.0"],
"dependencies": [],
"codeowners": ["@danielhiversen"]
}
diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py
index ed7fba570a8..f0cbecc8968 100644
--- a/homeassistant/components/switchbot/switch.py
+++ b/homeassistant/components/switchbot/switch.py
@@ -7,7 +7,7 @@ import switchbot
import voluptuous as vol
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
-from homeassistant.const import CONF_MAC, CONF_NAME
+from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
@@ -19,6 +19,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_MAC): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
}
)
@@ -27,20 +28,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
"""Perform the setup for Switchbot devices."""
name = config.get(CONF_NAME)
mac_addr = config[CONF_MAC]
- add_entities([SwitchBot(mac_addr, name)])
+ password = config.get(CONF_PASSWORD)
+ add_entities([SwitchBot(mac_addr, name, password)])
class SwitchBot(SwitchDevice, RestoreEntity):
"""Representation of a Switchbot."""
- def __init__(self, mac, name) -> None:
+ def __init__(self, mac, name, password) -> None:
"""Initialize the Switchbot."""
self._state = None
self._last_run_success = None
self._name = name
self._mac = mac
- self._device = switchbot.Switchbot(mac=mac)
+ self._device = switchbot.Switchbot(mac=mac, password=password)
async def async_added_to_hass(self):
"""Run when entity about to be added."""
diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py
index 63f2aa47a3a..0545687b003 100644
--- a/homeassistant/components/switcher_kis/__init__.py
+++ b/homeassistant/components/switcher_kis/__init__.py
@@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import (
ContextType,
+ DiscoveryInfoType,
EventType,
HomeAssistantType,
ServiceCallType,
@@ -115,7 +116,7 @@ async def async_setup(hass: HomeAssistantType, config: Dict) -> bool:
hass.data[DOMAIN] = {DATA_DEVICE: device_data}
async def async_switch_platform_discovered(
- platform: str, discovery_info: Optional[Dict]
+ platform: str, discovery_info: DiscoveryInfoType
) -> None:
"""Use for registering services after switch platform is discovered."""
if platform != DOMAIN:
diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py
index 577a01c5148..af7a3ea5f63 100644
--- a/homeassistant/components/synology_srm/device_tracker.py
+++ b/homeassistant/components/synology_srm/device_tracker.py
@@ -37,6 +37,34 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
}
)
+ATTRIBUTE_ALIAS = {
+ "band": None,
+ "connection": None,
+ "current_rate": None,
+ "dev_type": None,
+ "hostname": None,
+ "ip6_addr": None,
+ "ip_addr": None,
+ "is_baned": "is_banned",
+ "is_beamforming_on": None,
+ "is_guest": None,
+ "is_high_qos": None,
+ "is_low_qos": None,
+ "is_manual_dev_type": None,
+ "is_manual_hostname": None,
+ "is_online": None,
+ "is_parental_controled": "is_parental_controlled",
+ "is_qos": None,
+ "is_wireless": None,
+ "mac": None,
+ "max_rate": None,
+ "mesh_node_id": None,
+ "rate_quality": None,
+ "signalstrength": "signal_strength",
+ "transferRXRate": "transfer_rx_rate",
+ "transferTXRate": "transfer_tx_rate",
+}
+
def get_scanner(hass, config):
"""Validate the configuration and return Synology SRM scanner."""
@@ -62,7 +90,7 @@ class SynologySrmDeviceScanner(DeviceScanner):
if not config[CONF_VERIFY_SSL]:
self.client.http.disable_https_verify()
- self.last_results = []
+ self.devices = []
self.success_init = self._update_info()
_LOGGER.info("Synology SRM scanner initialized")
@@ -71,14 +99,28 @@ class SynologySrmDeviceScanner(DeviceScanner):
"""Scan for new devices and return a list with found device IDs."""
self._update_info()
- return [device["mac"] for device in self.last_results]
+ return [device["mac"] for device in self.devices]
+
+ def get_extra_attributes(self, device) -> dict:
+ """Get the extra attributes of a device."""
+ device = next(
+ (result for result in self.devices if result["mac"] == device), None
+ )
+ filtered_attributes = {}
+ if not device:
+ return filtered_attributes
+ for attribute, alias in ATTRIBUTE_ALIAS.items():
+ value = device.get(attribute)
+ if value is None:
+ continue
+ attr = alias or attribute
+ filtered_attributes[attr] = value
+ return filtered_attributes
def get_device_name(self, device):
"""Return the name of the given device or None if we don't know."""
filter_named = [
- result["hostname"]
- for result in self.last_results
- if result["mac"] == device
+ result["hostname"] for result in self.devices if result["mac"] == device
]
if filter_named:
@@ -90,13 +132,8 @@ class SynologySrmDeviceScanner(DeviceScanner):
"""Check the router for connected devices."""
_LOGGER.debug("Scanning for connected devices")
- devices = self.client.core.network_nsm_device({"is_online": True})
- last_results = []
+ self.devices = self.client.core.network_nsm_device({"is_online": True})
- for device in devices:
- last_results.append({"mac": device["mac"], "hostname": device["hostname"]})
+ _LOGGER.debug("Found %d device(s) connected to the router", len(self.devices))
- _LOGGER.debug("Found %d device(s) connected to the router", len(devices))
-
- self.last_results = last_results
return True
diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py
index 27dc9d367c2..5ab8ac9f930 100644
--- a/homeassistant/components/systemmonitor/__init__.py
+++ b/homeassistant/components/systemmonitor/__init__.py
@@ -1 +1 @@
-"""The systemmonitor component."""
+"""The systemmonitor integration."""
diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json
index 81712edd404..de8228f09a9 100644
--- a/homeassistant/components/systemmonitor/manifest.json
+++ b/homeassistant/components/systemmonitor/manifest.json
@@ -2,7 +2,7 @@
"domain": "systemmonitor",
"name": "System Monitor",
"documentation": "https://www.home-assistant.io/integrations/systemmonitor",
- "requirements": ["psutil==5.6.7"],
+ "requirements": ["psutil==5.7.0"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
index 727fb868a33..1dba5f5f29e 100644
--- a/homeassistant/components/tado/__init__.py
+++ b/homeassistant/components/tado/__init__.py
@@ -1,11 +1,12 @@
"""Support for the (unofficial) Tado API."""
from datetime import timedelta
import logging
-import urllib
from PyTado.interface import Tado
+from requests import RequestException
import voluptuous as vol
+from homeassistant.components.climate.const import PRESET_AWAY, PRESET_HOME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
@@ -109,7 +110,7 @@ class TadoConnector:
"""Connect to Tado and fetch the zones."""
try:
self.tado = Tado(self._username, self._password)
- except (RuntimeError, urllib.error.HTTPError) as exc:
+ except (RuntimeError, RequestException) as exc:
_LOGGER.error("Unable to connect: %s", exc)
return False
@@ -134,9 +135,14 @@ class TadoConnector:
_LOGGER.debug("Updating %s %s", sensor_type, sensor)
try:
if sensor_type == "zone":
- data = self.tado.getState(sensor)
+ data = self.tado.getZoneState(sensor)
elif sensor_type == "device":
- data = self.tado.getDevices()[0]
+ devices_data = self.tado.getDevices()
+ if not devices_data:
+ _LOGGER.info("There are no devices to setup on this tado account.")
+ return
+
+ data = devices_data[0]
else:
_LOGGER.debug("Unknown sensor: %s", sensor_type)
return
@@ -162,31 +168,54 @@ class TadoConnector:
self.tado.resetZoneOverlay(zone_id)
self.update_sensor("zone", zone_id)
+ def set_presence(
+ self, presence=PRESET_HOME,
+ ):
+ """Set the presence to home or away."""
+ if presence == PRESET_AWAY:
+ self.tado.setAway()
+ elif presence == PRESET_HOME:
+ self.tado.setHome()
+
def set_zone_overlay(
self,
- zone_id,
- overlay_mode,
+ zone_id=None,
+ overlay_mode=None,
temperature=None,
duration=None,
device_type="HEATING",
mode=None,
+ fan_speed=None,
+ swing=None,
):
"""Set a zone overlay."""
_LOGGER.debug(
- "Set overlay for zone %s: mode=%s, temp=%s, duration=%s, type=%s, mode=%s",
+ "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s fan_speed=%s swing=%s",
zone_id,
overlay_mode,
temperature,
duration,
device_type,
mode,
+ fan_speed,
+ swing,
)
+
try:
self.tado.setZoneOverlay(
- zone_id, overlay_mode, temperature, duration, device_type, "ON", mode
+ zone_id,
+ overlay_mode,
+ temperature,
+ duration,
+ device_type,
+ "ON",
+ mode,
+ fanSpeed=fan_speed,
+ swing=swing,
)
- except urllib.error.HTTPError as exc:
- _LOGGER.error("Could not set zone overlay: %s", exc.read())
+
+ except RequestException as exc:
+ _LOGGER.error("Could not set zone overlay: %s", exc)
self.update_sensor("zone", zone_id)
@@ -196,7 +225,7 @@ class TadoConnector:
self.tado.setZoneOverlay(
zone_id, overlay_mode, None, None, device_type, "OFF"
)
- except urllib.error.HTTPError as exc:
- _LOGGER.error("Could not set zone overlay: %s", exc.read())
+ except RequestException as exc:
+ _LOGGER.error("Could not set zone overlay: %s", exc)
self.update_sensor("zone", zone_id)
diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py
index b92a54edd5e..2c6e49f3273 100644
--- a/homeassistant/components/tado/climate.py
+++ b/homeassistant/components/tado/climate.py
@@ -3,22 +3,15 @@ import logging
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
- CURRENT_HVAC_COOL,
- CURRENT_HVAC_HEAT,
- CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
- FAN_HIGH,
- FAN_LOW,
- FAN_MIDDLE,
- FAN_OFF,
- HVAC_MODE_AUTO,
- HVAC_MODE_COOL,
+ FAN_AUTO,
HVAC_MODE_HEAT,
- HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
PRESET_AWAY,
PRESET_HOME,
+ SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
+ SUPPORT_SWING_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, TEMP_CELSIUS
@@ -27,49 +20,31 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED
from .const import (
+ CONST_FAN_AUTO,
+ CONST_FAN_OFF,
+ CONST_MODE_AUTO,
+ CONST_MODE_COOL,
+ CONST_MODE_HEAT,
CONST_MODE_OFF,
CONST_MODE_SMART_SCHEDULE,
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_MODE,
- CONST_OVERLAY_TIMER,
DATA,
+ HA_TO_TADO_FAN_MODE_MAP,
+ HA_TO_TADO_HVAC_MODE_MAP,
+ ORDERED_KNOWN_TADO_MODES,
+ SUPPORT_PRESET,
+ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION,
+ TADO_MODES_WITH_NO_TEMP_SETTING,
+ TADO_SWING_OFF,
+ TADO_TO_HA_FAN_MODE_MAP,
+ TADO_TO_HA_HVAC_MODE_MAP,
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
)
_LOGGER = logging.getLogger(__name__)
-FAN_MAP_TADO = {"HIGH": FAN_HIGH, "MIDDLE": FAN_MIDDLE, "LOW": FAN_LOW}
-
-HVAC_MAP_TADO_HEAT = {
- 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 = {
- 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 = {
- 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
-SUPPORT_HVAC_HEAT = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF]
-SUPPORT_HVAC_COOL = [HVAC_MODE_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF]
-SUPPORT_HVAC_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF]
-SUPPORT_FAN = [FAN_HIGH, FAN_MIDDLE, FAN_LOW, FAN_OFF]
-SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME]
-
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Tado climate platform."""
@@ -96,29 +71,83 @@ def create_climate_entity(tado, name: str, zone_id: int):
_LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities)
zone_type = capabilities["type"]
+ support_flags = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
+ supported_hvac_modes = [
+ TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF],
+ TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE],
+ ]
+ supported_fan_modes = None
+ heat_temperatures = None
+ cool_temperatures = None
- ac_support_heat = False
if zone_type == TYPE_AIR_CONDITIONING:
- # Only use heat if available
- # (you don't have to setup a heat mode, but cool is required)
# Heat is preferred as it generally has a lower minimum temperature
- if "HEAT" in capabilities:
- temperatures = capabilities["HEAT"]["temperatures"]
- ac_support_heat = True
- else:
- temperatures = capabilities["COOL"]["temperatures"]
- elif "temperatures" in capabilities:
- temperatures = capabilities["temperatures"]
+ for mode in ORDERED_KNOWN_TADO_MODES:
+ if mode not in capabilities:
+ continue
+
+ supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode])
+ if capabilities[mode].get("swings"):
+ support_flags |= SUPPORT_SWING_MODE
+
+ if not capabilities[mode].get("fanSpeeds"):
+ continue
+
+ support_flags |= SUPPORT_FAN_MODE
+
+ if supported_fan_modes:
+ continue
+
+ supported_fan_modes = [
+ TADO_TO_HA_FAN_MODE_MAP[speed]
+ for speed in capabilities[mode]["fanSpeeds"]
+ ]
+
+ cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"]
else:
- _LOGGER.debug("Not adding zone %s since it has no temperature", name)
+ supported_hvac_modes.append(HVAC_MODE_HEAT)
+
+ if CONST_MODE_HEAT in capabilities:
+ heat_temperatures = capabilities[CONST_MODE_HEAT]["temperatures"]
+
+ if heat_temperatures is None and "temperatures" in capabilities:
+ heat_temperatures = capabilities["temperatures"]
+
+ if cool_temperatures is None and heat_temperatures is None:
+ _LOGGER.debug("Not adding zone %s since it has no temperatures", name)
return None
- min_temp = float(temperatures["celsius"]["min"])
- max_temp = float(temperatures["celsius"]["max"])
- step = temperatures["celsius"].get("step", PRECISION_TENTHS)
+ heat_min_temp = None
+ heat_max_temp = None
+ heat_step = None
+ cool_min_temp = None
+ cool_max_temp = None
+ cool_step = None
+
+ if heat_temperatures is not None:
+ heat_min_temp = float(heat_temperatures["celsius"]["min"])
+ heat_max_temp = float(heat_temperatures["celsius"]["max"])
+ heat_step = heat_temperatures["celsius"].get("step", PRECISION_TENTHS)
+
+ if cool_temperatures is not None:
+ cool_min_temp = float(cool_temperatures["celsius"]["min"])
+ cool_max_temp = float(cool_temperatures["celsius"]["max"])
+ cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS)
entity = TadoClimate(
- tado, name, zone_id, zone_type, min_temp, max_temp, step, ac_support_heat,
+ tado,
+ name,
+ zone_id,
+ zone_type,
+ heat_min_temp,
+ heat_max_temp,
+ heat_step,
+ cool_min_temp,
+ cool_max_temp,
+ cool_step,
+ supported_hvac_modes,
+ supported_fan_modes,
+ support_flags,
)
return entity
@@ -132,10 +161,15 @@ class TadoClimate(ClimateDevice):
zone_name,
zone_id,
zone_type,
- min_temp,
- max_temp,
- step,
- ac_support_heat,
+ heat_min_temp,
+ heat_max_temp,
+ heat_step,
+ cool_min_temp,
+ cool_max_temp,
+ cool_step,
+ supported_hvac_modes,
+ supported_fan_modes,
+ support_flags,
):
"""Initialize of Tado climate entity."""
self._tado = tado
@@ -146,49 +180,52 @@ class TadoClimate(ClimateDevice):
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
- self._cooling = False
+ self._supported_hvac_modes = supported_hvac_modes
+ self._supported_fan_modes = supported_fan_modes
+ self._support_flags = support_flags
- self._active = False
- self._device_is_active = False
+ self._available = False
self._cur_temp = None
self._cur_humidity = None
- self._is_away = False
- self._min_temp = min_temp
- self._max_temp = max_temp
- self._step = step
+
+ self._heat_min_temp = heat_min_temp
+ self._heat_max_temp = heat_max_temp
+ self._heat_step = heat_step
+
+ self._cool_min_temp = cool_min_temp
+ self._cool_max_temp = cool_max_temp
+ self._cool_step = cool_step
+
self._target_temp = None
- 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_tado_fan_speed = CONST_FAN_OFF
+ self._current_tado_hvac_mode = CONST_MODE_OFF
+ self._current_tado_hvac_action = CURRENT_HVAC_OFF
+ self._current_tado_swing_mode = TADO_SWING_OFF
- self._current_fan = CONST_MODE_OFF
- self._current_operation = CONST_MODE_SMART_SCHEDULE
- self._overlay_mode = CONST_MODE_SMART_SCHEDULE
+ self._undo_dispatcher = None
+ self._tado_zone_data = None
+ self._async_update_zone_data()
+
+ async def async_will_remove_from_hass(self):
+ """When entity will be removed from hass."""
+ if self._undo_dispatcher:
+ self._undo_dispatcher()
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._undo_dispatcher = async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id),
- async_update_callback,
+ self._async_update_callback,
)
@property
def supported_features(self):
"""Return the list of supported features."""
- return SUPPORT_FLAGS
+ return self._support_flags
@property
def name(self):
@@ -208,12 +245,12 @@ class TadoClimate(ClimateDevice):
@property
def current_humidity(self):
"""Return the current humidity."""
- return self._cur_humidity
+ return self._tado_zone_data.current_humidity
@property
def current_temperature(self):
"""Return the sensor temperature."""
- return self._cur_temp
+ return self._tado_zone_data.current_temp
@property
def hvac_mode(self):
@@ -221,11 +258,7 @@ class TadoClimate(ClimateDevice):
Need to be one of HVAC_MODE_*.
"""
- if self._ac_device and self._ac_support_heat:
- return HVAC_MAP_TADO_HEAT_COOL.get(self._current_operation)
- if self._ac_device and not self._ac_support_heat:
- return HVAC_MAP_TADO_COOL.get(self._current_operation)
- return HVAC_MAP_TADO_HEAT.get(self._current_operation)
+ return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVAC_MODE_OFF)
@property
def hvac_modes(self):
@@ -233,11 +266,7 @@ class TadoClimate(ClimateDevice):
Need to be a subset of HVAC_MODES.
"""
- if self._ac_device:
- if self._ac_support_heat:
- return SUPPORT_HVAC_HEAT_COOL
- return SUPPORT_HVAC_COOL
- return SUPPORT_HVAC_HEAT
+ return self._supported_hvac_modes
@property
def hvac_action(self):
@@ -245,40 +274,30 @@ class TadoClimate(ClimateDevice):
Need to be one of CURRENT_HVAC_*.
"""
- if not self._device_is_active:
- return CURRENT_HVAC_OFF
- if self._ac_device:
- if self._active:
- if self._ac_support_heat and not self._cooling:
- return CURRENT_HVAC_HEAT
- return CURRENT_HVAC_COOL
- return CURRENT_HVAC_IDLE
- if self._active:
- return CURRENT_HVAC_HEAT
- return CURRENT_HVAC_IDLE
+ return TADO_HVAC_ACTION_TO_HA_HVAC_ACTION.get(
+ self._tado_zone_data.current_hvac_action, CURRENT_HVAC_OFF
+ )
@property
def fan_mode(self):
"""Return the fan setting."""
if self._ac_device:
- return FAN_MAP_TADO.get(self._current_fan)
+ return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO)
return None
@property
def fan_modes(self):
"""List of available fan modes."""
- if self._ac_device:
- return SUPPORT_FAN
- return None
+ return self._supported_fan_modes
def set_fan_mode(self, fan_mode: str):
"""Turn fan on/off."""
- pass
+ self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
@property
def preset_mode(self):
"""Return the current preset mode (home, away)."""
- if self._is_away:
+ if self._tado_zone_data.is_away:
return PRESET_AWAY
return PRESET_HOME
@@ -289,7 +308,7 @@ class TadoClimate(ClimateDevice):
def set_preset_mode(self, preset_mode):
"""Set new preset mode."""
- pass
+ self._tado.set_presence(preset_mode)
@property
def temperature_unit(self):
@@ -299,12 +318,18 @@ class TadoClimate(ClimateDevice):
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
- return self._step
+ if self._tado_zone_data.current_hvac_mode == CONST_MODE_COOL:
+ return self._cool_step or self._heat_step
+ return self._heat_step or self._cool_step
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
- return self._target_temp
+ # If the target temperature will be None
+ # if the device is performing an action
+ # that does not affect the temperature or
+ # the device is switching states
+ return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
def set_temperature(self, **kwargs):
"""Set new target temperature."""
@@ -312,174 +337,177 @@ class TadoClimate(ClimateDevice):
if temperature is None:
return
- self._current_operation = self._default_overlay
- self._overlay_mode = None
- self._target_temp = temperature
- self._control_heating()
+ if self._current_tado_hvac_mode not in (
+ CONST_MODE_OFF,
+ CONST_MODE_AUTO,
+ CONST_MODE_SMART_SCHEDULE,
+ ):
+ self._control_hvac(target_temp=temperature)
+ return
+
+ new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT
+ self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode)
def set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
- mode = None
- if hvac_mode == HVAC_MODE_OFF:
- mode = CONST_MODE_OFF
- elif hvac_mode == HVAC_MODE_AUTO:
- mode = CONST_MODE_SMART_SCHEDULE
- elif hvac_mode == HVAC_MODE_HEAT:
- mode = self._default_overlay
- elif hvac_mode == HVAC_MODE_COOL:
- mode = self._default_overlay
- elif hvac_mode == HVAC_MODE_HEAT_COOL:
- mode = self._default_overlay
+ self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode])
- self._current_operation = mode
- self._overlay_mode = None
-
- # Set a target temperature if we don't have any
- # This can happen when we switch from Off to On
- if self._target_temp is None:
- if self._ac_device:
- self._target_temp = self.max_temp
- else:
- self._target_temp = self.min_temp
- self.schedule_update_ha_state()
-
- self._control_heating()
+ @property
+ def available(self):
+ """Return if the device is available."""
+ return self._tado_zone_data.available
@property
def min_temp(self):
"""Return the minimum temperature."""
- return self._min_temp
+ if (
+ self._current_tado_hvac_mode == CONST_MODE_COOL
+ and self._cool_min_temp is not None
+ ):
+ return self._cool_min_temp
+ if self._heat_min_temp is not None:
+ return self._heat_min_temp
+
+ return self._cool_min_temp
@property
def max_temp(self):
"""Return the maximum temperature."""
- return self._max_temp
-
- def update(self):
- """Handle update callbacks."""
- _LOGGER.debug("Updating climate platform for zone %d", self.zone_id)
- data = self._tado.data["zone"][self.zone_id]
-
- if "sensorDataPoints" in data:
- sensor_data = data["sensorDataPoints"]
-
- if "insideTemperature" in sensor_data:
- temperature = float(sensor_data["insideTemperature"]["celsius"])
- self._cur_temp = temperature
-
- if "humidity" in sensor_data:
- 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
+ self._current_tado_hvac_mode == CONST_MODE_HEAT
+ and self._heat_max_temp is not None
):
- setting = float(data["setting"]["temperature"]["celsius"])
- self._target_temp = setting
+ return self._heat_max_temp
+ if self._heat_max_temp is not None:
+ return self._heat_max_temp
- if "tadoMode" in data:
- mode = data["tadoMode"]
- self._is_away = mode == "AWAY"
+ return self._heat_max_temp
- if "setting" in data:
- power = data["setting"]["power"]
- if power == "OFF":
- self._current_operation = CONST_MODE_OFF
- self._current_fan = 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
+ @property
+ def swing_mode(self):
+ """Active swing mode for the device."""
+ return self._current_tado_swing_mode
- active = False
- if "activityDataPoints" in data:
- activity_data = data["activityDataPoints"]
- if self._ac_device:
- if "acPower" in activity_data and activity_data["acPower"] is not None:
- if not activity_data["acPower"]["value"] == "OFF":
- active = True
- else:
- if (
- "heatingPower" in activity_data
- and activity_data["heatingPower"] is not None
- ):
- if float(activity_data["heatingPower"]["percentage"]) > 0.0:
- active = True
- self._active = active
+ @property
+ def swing_modes(self):
+ """Swing modes for the device."""
+ if self._support_flags & SUPPORT_SWING_MODE:
+ # Currently we only support off.
+ # On will be added in the future in an update
+ # to PyTado
+ return [TADO_SWING_OFF]
+ return None
- overlay = False
- overlay_data = None
- termination = CONST_MODE_SMART_SCHEDULE
- cooling = False
- fan_speed = CONST_MODE_OFF
+ def set_swing_mode(self, swing_mode):
+ """Set swing modes for the device."""
+ self._control_hvac(swing_mode=swing_mode)
- if "overlay" in data:
- overlay_data = data["overlay"]
- overlay = overlay_data is not None
+ @callback
+ def _async_update_zone_data(self):
+ """Load tado data into zone."""
+ self._tado_zone_data = self._tado.data["zone"][self.zone_id]
+ self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
+ self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
+ self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
- if overlay:
- termination = overlay_data["termination"]["type"]
- setting = False
- setting_data = None
+ @callback
+ def _async_update_callback(self):
+ """Load tado data and update state."""
+ self._async_update_zone_data()
+ self.async_write_ha_state()
- if "setting" in overlay_data:
- setting_data = overlay_data["setting"]
- setting = setting_data is not None
+ def _normalize_target_temp_for_hvac_mode(self):
+ # Set a target temperature if we don't have any
+ # This can happen when we switch from Off to On
+ if self._target_temp is None:
+ self._target_temp = self._tado_zone_data.current_temp
+ elif self._current_tado_hvac_mode == CONST_MODE_COOL:
+ if self._target_temp > self._cool_max_temp:
+ self._target_temp = self._cool_max_temp
+ elif self._target_temp < self._cool_min_temp:
+ self._target_temp = self._cool_min_temp
+ elif self._current_tado_hvac_mode == CONST_MODE_HEAT:
+ if self._target_temp > self._heat_max_temp:
+ self._target_temp = self._heat_max_temp
+ elif self._target_temp < self._heat_min_temp:
+ self._target_temp = self._heat_min_temp
- if setting:
- if "mode" in setting_data:
- cooling = setting_data["mode"] == "COOL"
-
- if "fanSpeed" in setting_data:
- fan_speed = setting_data["fanSpeed"]
-
- 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
-
- self._cooling = cooling
- self._current_fan = fan_speed
-
- def _control_heating(self):
+ def _control_hvac(
+ self, hvac_mode=None, target_temp=None, fan_mode=None, swing_mode=None
+ ):
"""Send new target temperature to Tado."""
- if self._current_operation == CONST_MODE_SMART_SCHEDULE:
+
+ if hvac_mode:
+ self._current_tado_hvac_mode = hvac_mode
+
+ if target_temp:
+ self._target_temp = target_temp
+
+ if fan_mode:
+ self._current_tado_fan_speed = fan_mode
+
+ if swing_mode:
+ self._current_tado_swing_mode = swing_mode
+
+ self._normalize_target_temp_for_hvac_mode()
+
+ # tado does not permit setting the fan speed to
+ # off, you must turn off the device
+ if (
+ self._current_tado_fan_speed == CONST_FAN_OFF
+ and self._current_tado_hvac_mode != CONST_MODE_OFF
+ ):
+ self._current_tado_fan_speed = CONST_FAN_AUTO
+
+ if self._current_tado_hvac_mode == 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, self.zone_type)
+ return
+
+ if self._current_tado_hvac_mode == 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, self.zone_type)
- self._overlay_mode = self._current_operation
return
_LOGGER.debug(
"Switching to %s for zone %s (%d) with temperature %s °C",
- self._current_operation,
+ self._current_tado_hvac_mode,
self.zone_name,
self.zone_id,
self._target_temp,
)
- self._tado.set_zone_overlay(
- self.zone_id,
- self._current_operation,
- self._target_temp,
- None,
- self.zone_type,
- "COOL" if self._ac_device else None,
+
+ # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled
+ overlay_mode = (
+ CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL
+ )
+
+ temperature_to_send = self._target_temp
+ if self._current_tado_hvac_mode in TADO_MODES_WITH_NO_TEMP_SETTING:
+ # A temperature cannot be passed with these modes
+ temperature_to_send = None
+
+ fan_speed = None
+ if self._support_flags & SUPPORT_FAN_MODE:
+ fan_speed = self._current_tado_fan_speed
+ swing = None
+ if self._support_flags & SUPPORT_SWING_MODE:
+ swing = self._current_tado_swing_mode
+
+ self._tado.set_zone_overlay(
+ zone_id=self.zone_id,
+ overlay_mode=overlay_mode, # What to do when the period ends
+ temperature=temperature_to_send,
+ duration=None,
+ device_type=self.zone_type,
+ mode=self._current_tado_hvac_mode,
+ fan_speed=fan_speed, # api defaults to not sending fanSpeed if None specified
+ swing=swing, # api defaults to not sending swing if None specified
)
- self._overlay_mode = self._current_operation
diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py
index 8d67e3bf9f8..ab965de035a 100644
--- a/homeassistant/components/tado/const.py
+++ b/homeassistant/components/tado/const.py
@@ -1,5 +1,48 @@
"""Constant values for the Tado component."""
+from PyTado.const import (
+ CONST_HVAC_COOL,
+ CONST_HVAC_DRY,
+ CONST_HVAC_FAN,
+ CONST_HVAC_HEAT,
+ CONST_HVAC_HOT_WATER,
+ CONST_HVAC_IDLE,
+ CONST_HVAC_OFF,
+)
+
+from homeassistant.components.climate.const import (
+ CURRENT_HVAC_COOL,
+ CURRENT_HVAC_DRY,
+ CURRENT_HVAC_FAN,
+ CURRENT_HVAC_HEAT,
+ CURRENT_HVAC_IDLE,
+ CURRENT_HVAC_OFF,
+ FAN_AUTO,
+ FAN_HIGH,
+ FAN_LOW,
+ FAN_MEDIUM,
+ FAN_OFF,
+ HVAC_MODE_AUTO,
+ HVAC_MODE_COOL,
+ HVAC_MODE_DRY,
+ HVAC_MODE_FAN_ONLY,
+ HVAC_MODE_HEAT,
+ HVAC_MODE_HEAT_COOL,
+ HVAC_MODE_OFF,
+ PRESET_AWAY,
+ PRESET_HOME,
+)
+
+TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = {
+ CONST_HVAC_HEAT: CURRENT_HVAC_HEAT,
+ CONST_HVAC_DRY: CURRENT_HVAC_DRY,
+ CONST_HVAC_FAN: CURRENT_HVAC_FAN,
+ CONST_HVAC_COOL: CURRENT_HVAC_COOL,
+ CONST_HVAC_IDLE: CURRENT_HVAC_IDLE,
+ CONST_HVAC_OFF: CURRENT_HVAC_OFF,
+ CONST_HVAC_HOT_WATER: CURRENT_HVAC_HEAT,
+}
+
# Configuration
CONF_FALLBACK = "fallback"
DATA = "data"
@@ -10,10 +53,85 @@ TYPE_HEATING = "HEATING"
TYPE_HOT_WATER = "HOT_WATER"
# Base modes
+CONST_MODE_OFF = "OFF"
CONST_MODE_SMART_SCHEDULE = "SMART_SCHEDULE" # Use the schedule
-CONST_MODE_OFF = "OFF" # Switch off heating in a zone
+CONST_MODE_AUTO = "AUTO"
+CONST_MODE_COOL = "COOL"
+CONST_MODE_HEAT = "HEAT"
+CONST_MODE_DRY = "DRY"
+CONST_MODE_FAN = "FAN"
+
+CONST_LINK_OFFLINE = "OFFLINE"
+
+CONST_FAN_OFF = "OFF"
+CONST_FAN_AUTO = "AUTO"
+CONST_FAN_LOW = "LOW"
+CONST_FAN_MIDDLE = "MIDDLE"
+CONST_FAN_HIGH = "HIGH"
+
# When we change the temperature setting, we need an overlay mode
CONST_OVERLAY_TADO_MODE = "TADO_MODE" # wait until tado changes the mode automatic
CONST_OVERLAY_MANUAL = "MANUAL" # the user has change the temperature or mode manually
CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan
+
+
+# Heat always comes first since we get the
+# min and max tempatures for the zone from
+# it.
+# Heat is preferred as it generally has a lower minimum temperature
+ORDERED_KNOWN_TADO_MODES = [
+ CONST_MODE_HEAT,
+ CONST_MODE_COOL,
+ CONST_MODE_AUTO,
+ CONST_MODE_DRY,
+ CONST_MODE_FAN,
+]
+
+TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = {
+ CONST_MODE_HEAT: CURRENT_HVAC_HEAT,
+ CONST_MODE_DRY: CURRENT_HVAC_DRY,
+ CONST_MODE_FAN: CURRENT_HVAC_FAN,
+ CONST_MODE_COOL: CURRENT_HVAC_COOL,
+}
+
+# These modes will not allow a temp to be set
+TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN]
+#
+# HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO
+# This lets tado decide on a temp
+#
+# HVAC_MODE_AUTO is mapped to CONST_MODE_SMART_SCHEDULE
+# This runs the smart schedule
+#
+HA_TO_TADO_HVAC_MODE_MAP = {
+ HVAC_MODE_OFF: CONST_MODE_OFF,
+ HVAC_MODE_HEAT_COOL: CONST_MODE_AUTO,
+ HVAC_MODE_AUTO: CONST_MODE_SMART_SCHEDULE,
+ HVAC_MODE_HEAT: CONST_MODE_HEAT,
+ HVAC_MODE_COOL: CONST_MODE_COOL,
+ HVAC_MODE_DRY: CONST_MODE_DRY,
+ HVAC_MODE_FAN_ONLY: CONST_MODE_FAN,
+}
+
+HA_TO_TADO_FAN_MODE_MAP = {
+ FAN_AUTO: CONST_FAN_AUTO,
+ FAN_OFF: CONST_FAN_OFF,
+ FAN_LOW: CONST_FAN_LOW,
+ FAN_MEDIUM: CONST_FAN_MIDDLE,
+ FAN_HIGH: CONST_FAN_HIGH,
+}
+
+TADO_TO_HA_HVAC_MODE_MAP = {
+ value: key for key, value in HA_TO_TADO_HVAC_MODE_MAP.items()
+}
+
+TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.items()}
+
+DEFAULT_TADO_PRECISION = 0.1
+
+SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME]
+
+
+TADO_SWING_OFF = "OFF"
+TADO_SWING_ON = "ON"
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
index e51cc53caa5..ce4679a23e2 100644
--- a/homeassistant/components/tado/manifest.json
+++ b/homeassistant/components/tado/manifest.json
@@ -3,10 +3,10 @@
"name": "Tado",
"documentation": "https://www.home-assistant.io/integrations/tado",
"requirements": [
- "python-tado==0.3.0"
+ "python-tado==0.6.0"
],
"dependencies": [],
"codeowners": [
- "@michaelarnauts"
+ "@michaelarnauts", "@bdraco"
]
}
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
index 2cd40bee3fa..fea81dcb586 100644
--- a/homeassistant/components/tado/sensor.py
+++ b/homeassistant/components/tado/sensor.py
@@ -31,6 +31,7 @@ ZONE_SENSORS = {
"ac",
"tado mode",
"overlay",
+ "open window",
],
TYPE_HOT_WATER: ["power", "link", "tado mode", "overlay"],
}
@@ -46,20 +47,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for tado in api_list:
# Create zone sensors
+ zones = tado.zones
+ devices = tado.devices
+
+ for zone in zones:
+ zone_type = zone["type"]
+ if zone_type not in ZONE_SENSORS:
+ _LOGGER.warning("Unknown zone type skipped: %s", zone_type)
+ continue
- for zone in tado.zones:
entities.extend(
[
- create_zone_sensor(tado, zone["name"], zone["id"], variable)
- for variable in ZONE_SENSORS.get(zone["type"])
+ TadoZoneSensor(tado, zone["name"], zone["id"], variable)
+ for variable in ZONE_SENSORS[zone_type]
]
)
# Create device sensors
- for home in tado.devices:
+ for device in devices:
entities.extend(
[
- create_device_sensor(tado, home["name"], home["id"], variable)
+ TadoDeviceSensor(tado, device["name"], device["id"], variable)
for variable in DEVICE_SENSORS
]
)
@@ -67,46 +75,38 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
add_entities(entities, True)
-def create_zone_sensor(tado, name, zone_id, variable):
- """Create a zone sensor."""
- return TadoSensor(tado, name, "zone", zone_id, variable)
-
-
-def create_device_sensor(tado, name, device_id, variable):
- """Create a device sensor."""
- return TadoSensor(tado, name, "device", device_id, variable)
-
-
-class TadoSensor(Entity):
+class TadoZoneSensor(Entity):
"""Representation of a tado Sensor."""
- def __init__(self, tado, zone_name, sensor_type, zone_id, zone_variable):
+ def __init__(self, tado, zone_name, zone_id, zone_variable):
"""Initialize of the Tado Sensor."""
self._tado = tado
self.zone_name = zone_name
self.zone_id = zone_id
self.zone_variable = zone_variable
- self.sensor_type = sensor_type
self._unique_id = f"{zone_variable} {zone_id} {tado.device_id}"
self._state = None
self._state_attributes = None
+ self._tado_zone_data = None
+ self._undo_dispatcher = None
+
+ async def async_will_remove_from_hass(self):
+ """When entity will be removed from hass."""
+ if self._undo_dispatcher:
+ self._undo_dispatcher()
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._undo_dispatcher = async_dispatcher_connect(
self.hass,
- SIGNAL_TADO_UPDATE_RECEIVED.format(self.sensor_type, self.zone_id),
- async_update_callback,
+ SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id),
+ self._async_update_callback,
)
+ self._async_update_zone_data()
@property
def unique_id(self):
@@ -138,7 +138,7 @@ class TadoSensor(Entity):
if self.zone_variable == "heating":
return UNIT_PERCENTAGE
if self.zone_variable == "ac":
- return ""
+ return None
@property
def icon(self):
@@ -149,97 +149,143 @@ class TadoSensor(Entity):
return "mdi:water-percent"
@property
- def should_poll(self) -> bool:
+ def should_poll(self):
"""Do not poll."""
return False
- def update(self):
+ @callback
+ def _async_update_callback(self):
+ """Update and write state."""
+ self._async_update_zone_data()
+ self.async_write_ha_state()
+
+ @callback
+ def _async_update_zone_data(self):
"""Handle update callbacks."""
try:
- data = self._tado.data[self.sensor_type][self.zone_id]
+ self._tado_zone_data = self._tado.data["zone"][self.zone_id]
except KeyError:
return
- unit = TEMP_CELSIUS
-
if self.zone_variable == "temperature":
- if "sensorDataPoints" in data:
- sensor_data = data["sensorDataPoints"]
- temperature = float(sensor_data["insideTemperature"]["celsius"])
-
- self._state = self.hass.config.units.temperature(temperature, unit)
- self._state_attributes = {
- "time": sensor_data["insideTemperature"]["timestamp"],
- "setting": 0, # setting is used in climate device
- }
-
- # temperature setting will not exist when device is off
- if (
- "temperature" in data["setting"]
- and data["setting"]["temperature"] is not None
- ):
- temperature = float(data["setting"]["temperature"]["celsius"])
-
- self._state_attributes[
- "setting"
- ] = self.hass.config.units.temperature(temperature, unit)
+ self._state = self.hass.config.units.temperature(
+ self._tado_zone_data.current_temp, TEMP_CELSIUS
+ )
+ self._state_attributes = {
+ "time": self._tado_zone_data.current_temp_timestamp,
+ "setting": 0, # setting is used in climate device
+ }
elif self.zone_variable == "humidity":
- if "sensorDataPoints" in data:
- sensor_data = data["sensorDataPoints"]
- self._state = float(sensor_data["humidity"]["percentage"])
- self._state_attributes = {"time": sensor_data["humidity"]["timestamp"]}
+ self._state = self._tado_zone_data.current_humidity
+ self._state_attributes = {
+ "time": self._tado_zone_data.current_humidity_timestamp
+ }
elif self.zone_variable == "power":
- if "setting" in data:
- self._state = data["setting"]["power"]
+ self._state = self._tado_zone_data.power
elif self.zone_variable == "link":
- if "link" in data:
- self._state = data["link"]["state"]
+ self._state = self._tado_zone_data.link
elif self.zone_variable == "heating":
- if "activityDataPoints" in data:
- activity_data = data["activityDataPoints"]
-
- if (
- "heatingPower" in activity_data
- and activity_data["heatingPower"] is not None
- ):
- self._state = float(activity_data["heatingPower"]["percentage"])
- self._state_attributes = {
- "time": activity_data["heatingPower"]["timestamp"]
- }
+ self._state = self._tado_zone_data.heating_power_percentage
+ self._state_attributes = {
+ "time": self._tado_zone_data.heating_power_timestamp
+ }
elif self.zone_variable == "ac":
- if "activityDataPoints" in data:
- activity_data = data["activityDataPoints"]
-
- if "acPower" in activity_data and activity_data["acPower"] is not None:
- self._state = activity_data["acPower"]["value"]
- self._state_attributes = {
- "time": activity_data["acPower"]["timestamp"]
- }
+ self._state = self._tado_zone_data.ac_power
+ self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp}
elif self.zone_variable == "tado bridge status":
- if "connectionState" in data:
- self._state = data["connectionState"]["value"]
+ self._state = self._tado_zone_data.connection
elif self.zone_variable == "tado mode":
- if "tadoMode" in data:
- self._state = data["tadoMode"]
+ self._state = self._tado_zone_data.tado_mode
elif self.zone_variable == "overlay":
- self._state = "overlay" in data and data["overlay"] is not None
+ self._state = self._tado_zone_data.overlay_active
self._state_attributes = (
- {"termination": data["overlay"]["termination"]["type"]}
- if self._state
+ {"termination": self._tado_zone_data.overlay_termination_type}
+ if self._tado_zone_data.overlay_active
else {}
)
elif self.zone_variable == "early start":
- self._state = "preparation" in data and data["preparation"] is not None
+ self._state = self._tado_zone_data.preparation
elif self.zone_variable == "open window":
- self._state = "openWindow" in data and data["openWindow"] is not None
- self._state_attributes = data["openWindow"] if self._state else {}
+ self._state = self._tado_zone_data.open_window
+ self._state_attributes = self._tado_zone_data.open_window_attr
+
+
+class TadoDeviceSensor(Entity):
+ """Representation of a tado Sensor."""
+
+ def __init__(self, tado, device_name, device_id, device_variable):
+ """Initialize of the Tado Sensor."""
+ self._tado = tado
+
+ self.device_name = device_name
+ self.device_id = device_id
+ self.device_variable = device_variable
+
+ self._unique_id = f"{device_variable} {device_id} {tado.device_id}"
+
+ self._state = None
+ self._state_attributes = None
+ self._tado_device_data = None
+ self._undo_dispatcher = None
+
+ async def async_will_remove_from_hass(self):
+ """When entity will be removed from hass."""
+ if self._undo_dispatcher:
+ self._undo_dispatcher()
+
+ async def async_added_to_hass(self):
+ """Register for sensor updates."""
+
+ self._undo_dispatcher = async_dispatcher_connect(
+ self.hass,
+ SIGNAL_TADO_UPDATE_RECEIVED.format("device", self.device_id),
+ self._async_update_callback,
+ )
+ self._async_update_device_data()
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return f"{self.device_name} {self.device_variable}"
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Do not poll."""
+ return False
+
+ @callback
+ def _async_update_callback(self):
+ """Update and write state."""
+ self._async_update_device_data()
+ self.async_write_ha_state()
+
+ @callback
+ def _async_update_device_data(self):
+ """Handle update callbacks."""
+ try:
+ data = self._tado.data["device"][self.device_id]
+ except KeyError:
+ return
+
+ if self.device_variable == "tado bridge status":
+ self._state = data.get("connectionState", {}).get("value", False)
diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py
index fc3a9ce9cf4..51ff2ede57d 100644
--- a/homeassistant/components/tado/water_heater.py
+++ b/homeassistant/components/tado/water_heater.py
@@ -12,6 +12,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED
from .const import (
+ CONST_HVAC_HEAT,
+ CONST_MODE_AUTO,
+ CONST_MODE_HEAT,
CONST_MODE_OFF,
CONST_MODE_SMART_SCHEDULE,
CONST_OVERLAY_MANUAL,
@@ -33,6 +36,7 @@ WATER_HEATER_MAP_TADO = {
CONST_OVERLAY_MANUAL: MODE_HEAT,
CONST_OVERLAY_TIMER: MODE_HEAT,
CONST_OVERLAY_TADO_MODE: MODE_HEAT,
+ CONST_HVAC_HEAT: MODE_HEAT,
CONST_MODE_SMART_SCHEDULE: MODE_AUTO,
CONST_MODE_OFF: MODE_OFF,
}
@@ -50,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for tado in api_list:
for zone in tado.zones:
- if zone["type"] in [TYPE_HOT_WATER]:
+ if zone["type"] == TYPE_HOT_WATER:
entity = create_water_heater_entity(tado, zone["name"], zone["id"])
entities.append(entity)
@@ -61,6 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
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:
@@ -98,7 +103,6 @@ class TadoWaterHeater(WaterHeaterDevice):
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
@@ -110,29 +114,25 @@ class TadoWaterHeater(WaterHeaterDevice):
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._current_tado_hvac_mode = CONST_MODE_SMART_SCHEDULE
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
+ self._tado_zone_data = None
+ self._undo_dispatcher = None
+
+ async def async_will_remove_from_hass(self):
+ """When entity will be removed from hass."""
+ if self._undo_dispatcher:
+ self._undo_dispatcher()
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._undo_dispatcher = async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id),
- async_update_callback,
+ self._async_update_callback,
)
+ self._async_update_data()
@property
def supported_features(self):
@@ -157,17 +157,17 @@ class TadoWaterHeater(WaterHeaterDevice):
@property
def current_operation(self):
"""Return current readable operation mode."""
- return WATER_HEATER_MAP_TADO.get(self._current_operation)
+ return WATER_HEATER_MAP_TADO.get(self._current_tado_hvac_mode)
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
- return self._target_temp
+ return self._tado_zone_data.target_temp
@property
def is_away_mode_on(self):
"""Return true if away mode is on."""
- return self._is_away
+ return self._tado_zone_data.is_away
@property
def operation_list(self):
@@ -198,16 +198,9 @@ class TadoWaterHeater(WaterHeaterDevice):
elif operation_mode == MODE_AUTO:
mode = CONST_MODE_SMART_SCHEDULE
elif operation_mode == MODE_HEAT:
- mode = self._default_overlay
+ mode = CONST_MODE_HEAT
- 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()
+ self._control_heater(hvac_mode=mode)
def set_temperature(self, **kwargs):
"""Set new target temperature."""
@@ -215,88 +208,75 @@ class TadoWaterHeater(WaterHeaterDevice):
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
+ if self._current_tado_hvac_mode not in (
+ CONST_MODE_OFF,
+ CONST_MODE_AUTO,
+ CONST_MODE_SMART_SCHEDULE,
):
- setting = float(data["setting"]["temperature"]["celsius"])
- self._target_temp = setting
+ self._control_heater(target_temp=temperature)
+ return
- overlay = False
- overlay_data = None
- termination = CONST_MODE_SMART_SCHEDULE
+ self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT)
- if "overlay" in data:
- overlay_data = data["overlay"]
- overlay = overlay_data is not None
+ @callback
+ def _async_update_callback(self):
+ """Load tado data and update state."""
+ self._async_update_data()
+ self.async_write_ha_state()
- if overlay:
- termination = overlay_data["termination"]["type"]
+ @callback
+ def _async_update_data(self):
+ """Load tado data."""
+ _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id)
+ self._tado_zone_data = self._tado.data["zone"][self.zone_id]
+ self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
- 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):
+ def _control_heater(self, hvac_mode=None, target_temp=None):
"""Send new target temperature."""
- if self._current_operation == CONST_MODE_SMART_SCHEDULE:
+
+ if hvac_mode:
+ self._current_tado_hvac_mode = hvac_mode
+
+ if target_temp:
+ self._target_temp = target_temp
+
+ # Set a target temperature if we don't have any
+ if self._target_temp is None:
+ self._target_temp = self.min_temp
+
+ if self._current_tado_hvac_mode == 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:
+ if self._current_tado_hvac_mode == 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
+ # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled
+ overlay_mode = (
+ CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL
+ )
+
_LOGGER.debug(
"Switching to %s for zone %s (%d) with temperature %s",
- self._current_operation,
+ self._current_tado_hvac_mode,
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,
+ zone_id=self.zone_id,
+ overlay_mode=overlay_mode,
+ temperature=self._target_temp,
+ duration=None,
+ device_type=TYPE_HOT_WATER,
)
- self._overlay_mode = self._current_operation
+ self._overlay_mode = self._current_tado_hvac_mode
diff --git a/homeassistant/components/tellduslive/.translations/no.json b/homeassistant/components/tellduslive/.translations/no.json
index 3258cf2ddca..3977bde4a3c 100644
--- a/homeassistant/components/tellduslive/.translations/no.json
+++ b/homeassistant/components/tellduslive/.translations/no.json
@@ -22,6 +22,6 @@
"title": "Velg endepunkt."
}
},
- "title": "Telldus Live"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py
index 019c9cd8787..45cccef9766 100644
--- a/homeassistant/components/template/alarm_control_panel.py
+++ b/homeassistant/components/template/alarm_control_panel.py
@@ -24,6 +24,7 @@ from homeassistant.const import (
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING,
STATE_ALARM_TRIGGERED,
STATE_UNAVAILABLE,
)
@@ -38,9 +39,10 @@ _LOGGER = logging.getLogger(__name__)
_VALID_STATES = [
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
- STATE_ALARM_DISARMED,
- STATE_ALARM_TRIGGERED,
STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING,
+ STATE_ALARM_TRIGGERED,
STATE_UNAVAILABLE,
]
diff --git a/homeassistant/components/tesla/.translations/ca.json b/homeassistant/components/tesla/.translations/ca.json
index cb4840dea7a..2f0257d47a4 100644
--- a/homeassistant/components/tesla/.translations/ca.json
+++ b/homeassistant/components/tesla/.translations/ca.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "For\u00e7a el despertar del cotxe en la posada en marxa",
"scan_interval": "Segons entre escanejos"
}
}
diff --git a/homeassistant/components/tesla/.translations/en.json b/homeassistant/components/tesla/.translations/en.json
index 8c43f28e04e..4dbee73717e 100644
--- a/homeassistant/components/tesla/.translations/en.json
+++ b/homeassistant/components/tesla/.translations/en.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "Force cars awake on startup",
"scan_interval": "Seconds between scans"
}
}
diff --git a/homeassistant/components/tesla/.translations/es.json b/homeassistant/components/tesla/.translations/es.json
index 64bab24ee3f..ad456dd28b6 100644
--- a/homeassistant/components/tesla/.translations/es.json
+++ b/homeassistant/components/tesla/.translations/es.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "Forzar autom\u00f3viles despiertos al inicio",
"scan_interval": "Segundos entre escaneos"
}
}
diff --git a/homeassistant/components/tesla/.translations/fr.json b/homeassistant/components/tesla/.translations/fr.json
index 69742d3370c..ef9d5162899 100644
--- a/homeassistant/components/tesla/.translations/fr.json
+++ b/homeassistant/components/tesla/.translations/fr.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "Forcer les voitures \u00e0 se r\u00e9veiller au d\u00e9marrage",
"scan_interval": "Secondes entre les scans"
}
}
diff --git a/homeassistant/components/tesla/.translations/it.json b/homeassistant/components/tesla/.translations/it.json
index 0e254cf2843..e9bf5e2d4fe 100644
--- a/homeassistant/components/tesla/.translations/it.json
+++ b/homeassistant/components/tesla/.translations/it.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "Forza il risveglio delle auto all'avvio",
"scan_interval": "Secondi tra le scansioni"
}
}
diff --git a/homeassistant/components/tesla/.translations/ko.json b/homeassistant/components/tesla/.translations/ko.json
index 8b7dc9ce93c..a0f8d353349 100644
--- a/homeassistant/components/tesla/.translations/ko.json
+++ b/homeassistant/components/tesla/.translations/ko.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "\uc2dc\ub3d9 \uc2dc \ucc28\ub7c9 \uae68\uc6b0\uae30",
"scan_interval": "\uc2a4\uce94 \uac04\uaca9(\ucd08)"
}
}
diff --git a/homeassistant/components/tesla/.translations/lb.json b/homeassistant/components/tesla/.translations/lb.json
index fa63c5a289a..64bf528e95f 100644
--- a/homeassistant/components/tesla/.translations/lb.json
+++ b/homeassistant/components/tesla/.translations/lb.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "Forc\u00e9ier d'Erw\u00e4chen vun den Autoen beim starten",
"scan_interval": "Sekonnen t\u00ebscht Scannen"
}
}
diff --git a/homeassistant/components/tesla/.translations/no.json b/homeassistant/components/tesla/.translations/no.json
index 0d73908f417..8df2cdd2018 100644
--- a/homeassistant/components/tesla/.translations/no.json
+++ b/homeassistant/components/tesla/.translations/no.json
@@ -16,12 +16,13 @@
"title": "Tesla - Konfigurasjon"
}
},
- "title": "Tesla"
+ "title": ""
},
"options": {
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "Tving biler til \u00e5 v\u00e5kne ved oppstart",
"scan_interval": "Sekunder mellom skanninger"
}
}
diff --git a/homeassistant/components/tesla/.translations/ru.json b/homeassistant/components/tesla/.translations/ru.json
index 15eeabf6136..5354e4e6390 100644
--- a/homeassistant/components/tesla/.translations/ru.json
+++ b/homeassistant/components/tesla/.translations/ru.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "\u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u0442\u044c \u043c\u0430\u0448\u0438\u043d\u0443 \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0435",
"scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 (\u0441\u0435\u043a.)"
}
}
diff --git a/homeassistant/components/tesla/.translations/zh-Hant.json b/homeassistant/components/tesla/.translations/zh-Hant.json
index 776a80da7fb..c35cbfb944a 100644
--- a/homeassistant/components/tesla/.translations/zh-Hant.json
+++ b/homeassistant/components/tesla/.translations/zh-Hant.json
@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
+ "enable_wake_on_start": "\u65bc\u555f\u52d5\u6642\u5f37\u5236\u559a\u9192\u6c7d\u8eca",
"scan_interval": "\u6383\u63cf\u9593\u9694\u79d2\u6578"
}
}
diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py
index df0664b8f4c..2d08b48e0af 100644
--- a/homeassistant/components/tesla/__init__.py
+++ b/homeassistant/components/tesla/__init__.py
@@ -28,8 +28,10 @@ from .config_flow import (
validate_input,
)
from .const import (
+ CONF_WAKE_ON_START,
DATA_LISTENER,
DEFAULT_SCAN_INTERVAL,
+ DEFAULT_WAKE_ON_START,
DOMAIN,
ICONS,
MIN_SCAN_INTERVAL,
@@ -71,7 +73,10 @@ async def async_setup(hass, base_config):
def _update_entry(email, data=None, options=None):
data = data or {}
- options = options or {CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}
+ options = options or {
+ CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
+ CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START,
+ }
for entry in hass.config_entries.async_entries(DOMAIN):
if email != entry.title:
continue
@@ -131,7 +136,11 @@ async def async_setup_entry(hass, config_entry):
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
)
- (refresh_token, access_token) = await controller.connect()
+ (refresh_token, access_token) = await controller.connect(
+ wake_if_asleep=config_entry.options.get(
+ CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START
+ )
+ )
except TeslaException as ex:
_LOGGER.error("Unable to communicate with Tesla API: %s", ex.message)
return False
diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py
index c719807da9f..b8407653d1b 100644
--- a/homeassistant/components/tesla/config_flow.py
+++ b/homeassistant/components/tesla/config_flow.py
@@ -15,7 +15,13 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
-from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL
+from .const import (
+ CONF_WAKE_ON_START,
+ DEFAULT_SCAN_INTERVAL,
+ DEFAULT_WAKE_ON_START,
+ DOMAIN,
+ MIN_SCAN_INTERVAL,
+)
_LOGGER = logging.getLogger(__name__)
@@ -103,7 +109,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
default=self.config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
),
- ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL))
+ ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)),
+ vol.Optional(
+ CONF_WAKE_ON_START,
+ default=self.config_entry.options.get(
+ CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START
+ ),
+ ): bool,
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
diff --git a/homeassistant/components/tesla/const.py b/homeassistant/components/tesla/const.py
index 54cb7a2e071..2b8485c7616 100644
--- a/homeassistant/components/tesla/const.py
+++ b/homeassistant/components/tesla/const.py
@@ -1,7 +1,9 @@
"""Const file for Tesla cars."""
+CONF_WAKE_ON_START = "enable_wake_on_start"
DOMAIN = "tesla"
DATA_LISTENER = "listener"
DEFAULT_SCAN_INTERVAL = 660
+DEFAULT_WAKE_ON_START = False
MIN_SCAN_INTERVAL = 60
TESLA_COMPONENTS = [
"sensor",
@@ -23,4 +25,5 @@ ICONS = {
"temperature sensor": "mdi:thermometer",
"location tracker": "mdi:crosshairs-gps",
"charging rate sensor": "mdi:speedometer",
+ "sentry mode switch": "mdi:shield-car",
}
diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json
index 950a860b308..1bba8436312 100644
--- a/homeassistant/components/tesla/manifest.json
+++ b/homeassistant/components/tesla/manifest.json
@@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tesla",
"requirements": [
- "teslajsonpy==0.5.1"
+ "teslajsonpy==0.6.0"
],
"dependencies": [],
"codeowners": [
diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json
index 831406a0d63..3c8017a7d76 100644
--- a/homeassistant/components/tesla/strings.json
+++ b/homeassistant/components/tesla/strings.json
@@ -22,9 +22,10 @@
"step": {
"init": {
"data": {
- "scan_interval": "Seconds between scans"
+ "scan_interval": "Seconds between scans",
+ "enable_wake_on_start": "Force cars awake on startup"
}
}
}
}
-}
+}
\ No newline at end of file
diff --git a/homeassistant/components/tesla/switch.py b/homeassistant/components/tesla/switch.py
index 331f6bd8126..716836821c4 100644
--- a/homeassistant/components/tesla/switch.py
+++ b/homeassistant/components/tesla/switch.py
@@ -19,6 +19,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities.append(UpdateSwitch(device, controller, config_entry))
elif device.type == "maxrange switch":
entities.append(RangeSwitch(device, controller, config_entry))
+ elif device.type == "sentry mode switch":
+ entities.append(SentryModeSwitch(device, controller, config_entry))
async_add_entities(entities, True)
@@ -114,3 +116,32 @@ class UpdateSwitch(TeslaDevice, SwitchDevice):
_LOGGER.debug("Updating state for: %s %s", self._name, car_id)
await super().async_update()
self._state = bool(self.controller.get_updates(car_id))
+
+
+class SentryModeSwitch(TeslaDevice, SwitchDevice):
+ """Representation of a Tesla sentry mode switch."""
+
+ async def async_turn_on(self, **kwargs):
+ """Send the on command."""
+ _LOGGER.debug("Enable sentry mode: %s", self._name)
+ await self.tesla_device.enable_sentry_mode()
+
+ async def async_turn_off(self, **kwargs):
+ """Send the off command."""
+ _LOGGER.debug("Disable sentry mode: %s", self._name)
+ await self.tesla_device.disable_sentry_mode()
+
+ @property
+ def is_on(self):
+ """Get whether the switch is in on state."""
+ return self.tesla_device.is_on()
+
+ @property
+ def available(self):
+ """Indicate if Home Assistant is able to read the state and control the underlying device."""
+ return self.tesla_device.available()
+
+ async def async_update(self):
+ """Update the state of the switch."""
+ _LOGGER.debug("Updating state for: %s", self._name)
+ await super().async_update()
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index 78b358d70ec..48ff76a2b34 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.13.3"],
+ "requirements": ["pyTibber==0.13.6"],
"dependencies": [],
"codeowners": ["@danielhiversen"],
"quality_scale": "silver"
diff --git a/homeassistant/components/toon/.translations/no.json b/homeassistant/components/toon/.translations/no.json
index a033d2954d9..80a101ac67b 100644
--- a/homeassistant/components/toon/.translations/no.json
+++ b/homeassistant/components/toon/.translations/no.json
@@ -5,7 +5,7 @@
"client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.",
"no_agreements": "Denne kontoen har ingen Toon skjermer.",
"no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Uventet feil oppstod under autentisering."
+ "unknown_auth_fail": "Det oppstod en uventet feil under godkjenning."
},
"error": {
"credentials": "Den oppgitte kontoinformasjonen er ugyldig.",
@@ -29,6 +29,6 @@
"title": "Velg skjerm"
}
},
- "title": "Toon"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/sl.json b/homeassistant/components/toon/.translations/sl.json
index 18c1a739e5a..8fb71b80acc 100644
--- a/homeassistant/components/toon/.translations/sl.json
+++ b/homeassistant/components/toon/.translations/sl.json
@@ -5,7 +5,7 @@
"client_secret": "Skrivnost iz konfiguracije odjemalca ni veljaven.",
"no_agreements": "Ta ra\u010dun nima prikazov Toon.",
"no_app": "Toon morate konfigurirati, preden ga boste lahko uporabili za overitev. [Preberite navodila] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Pri preverjanju pristnosti je pri\u0161lo do nepri\u010dakovane napake."
+ "unknown_auth_fail": "Med preverjanjem pristnosti je pri\u0161lo do nepri\u010dakovane napake."
},
"error": {
"credentials": "Navedene poverilnice niso veljavne.",
diff --git a/homeassistant/components/tplink/.translations/no.json b/homeassistant/components/tplink/.translations/no.json
index 41148475a5f..2cb30df1a42 100644
--- a/homeassistant/components/tplink/.translations/no.json
+++ b/homeassistant/components/tplink/.translations/no.json
@@ -7,9 +7,9 @@
"step": {
"confirm": {
"description": "Vil du konfigurere TP-Link smart enheter?",
- "title": "TP-Link Smart Home"
+ "title": ""
}
},
- "title": "TP-Link Smart Home"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
index 0e7be471f43..7e07f7931f5 100644
--- a/homeassistant/components/tplink/light.py
+++ b/homeassistant/components/tplink/light.py
@@ -75,30 +75,26 @@ def brightness_from_percentage(percent):
return (percent * 255.0) / 100.0
-LightState = NamedTuple(
- "LightState",
- (
- ("state", bool),
- ("brightness", int),
- ("color_temp", float),
- ("hs", Tuple[int, int]),
- ("emeter_params", dict),
- ),
-)
+class LightState(NamedTuple):
+ """Light state."""
+
+ state: bool
+ brightness: int
+ color_temp: float
+ hs: Tuple[int, int]
+ emeter_params: dict
-LightFeatures = NamedTuple(
- "LightFeatures",
- (
- ("sysinfo", Dict[str, Any]),
- ("mac", str),
- ("alias", str),
- ("model", str),
- ("supported_features", int),
- ("min_mireds", float),
- ("max_mireds", float),
- ),
-)
+class LightFeatures(NamedTuple):
+ """Light features."""
+
+ sysinfo: Dict[str, Any]
+ mac: str
+ alias: str
+ model: str
+ supported_features: int
+ min_mireds: float
+ max_mireds: float
class TPLinkSmartBulb(Light):
diff --git a/homeassistant/components/transmission/.translations/bg.json b/homeassistant/components/transmission/.translations/bg.json
index 98160b89925..3278f7a3a4c 100644
--- a/homeassistant/components/transmission/.translations/bg.json
+++ b/homeassistant/components/transmission/.translations/bg.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u0410\u0434\u0440\u0435\u0441\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d.",
- "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ "already_configured": "\u0410\u0434\u0440\u0435\u0441\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d."
},
"error": {
"cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0430\u0434\u0440\u0435\u0441\u0430",
@@ -10,12 +9,6 @@
"wrong_credentials": "\u0413\u0440\u0435\u0448\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "\u0427\u0435\u0441\u0442\u043e\u0442\u0430 \u043d\u0430 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435"
- },
- "title": "\u041e\u043f\u0446\u0438\u0438 \u0437\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435"
- },
"user": {
"data": {
"host": "\u0410\u0434\u0440\u0435\u0441",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "\u0427\u0435\u0441\u0442\u043e\u0442\u0430 \u043d\u0430 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435"
},
- "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043e\u043f\u0446\u0438\u0438\u0442\u0435 \u0437\u0430 Transmission",
"title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043e\u043f\u0446\u0438\u0438\u0442\u0435 \u0437\u0430 Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/ca.json b/homeassistant/components/transmission/.translations/ca.json
index f621574683f..7630b50cdcf 100644
--- a/homeassistant/components/transmission/.translations/ca.json
+++ b/homeassistant/components/transmission/.translations/ca.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat.",
- "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat."
},
"error": {
"cannot_connect": "No s'ha pogut connectar amb l'amfitri\u00f3",
@@ -10,12 +9,6 @@
"wrong_credentials": "Nom d'usuari o contrasenya incorrectes"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Freq\u00fc\u00e8ncia d\u2019actualitzaci\u00f3"
- },
- "title": "Opcions de configuraci\u00f3"
- },
"user": {
"data": {
"host": "Amfitri\u00f3",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Freq\u00fc\u00e8ncia d\u2019actualitzaci\u00f3"
},
- "description": "Opcions de configuraci\u00f3 de Transmission",
"title": "Opcions de configuraci\u00f3 de Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/da.json b/homeassistant/components/transmission/.translations/da.json
index e84ec938ee2..feabb364344 100644
--- a/homeassistant/components/transmission/.translations/da.json
+++ b/homeassistant/components/transmission/.translations/da.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "V\u00e6rten er allerede konfigureret.",
- "one_instance_allowed": "Kun en enkelt instans er n\u00f8dvendig."
+ "already_configured": "V\u00e6rten er allerede konfigureret."
},
"error": {
"cannot_connect": "Kunne ikke oprette forbindelse til v\u00e6rt",
@@ -10,12 +9,6 @@
"wrong_credentials": "Ugyldigt brugernavn eller adgangskode"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Opdateringsfrekvens"
- },
- "title": "Konfigurationsmuligheder"
- },
"user": {
"data": {
"host": "V\u00e6rt",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Opdateringsfrekvens"
},
- "description": "Konfigurationsindstillinger for Transmission",
"title": "Konfigurationsindstillinger for Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/de.json b/homeassistant/components/transmission/.translations/de.json
index 736a6d72659..c3d912e5e77 100644
--- a/homeassistant/components/transmission/.translations/de.json
+++ b/homeassistant/components/transmission/.translations/de.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Host ist bereits konfiguriert.",
- "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ "already_configured": "Host ist bereits konfiguriert."
},
"error": {
"cannot_connect": "Verbindung zum Host nicht m\u00f6glich",
@@ -10,12 +9,6 @@
"wrong_credentials": "Falscher Benutzername oder Kennwort"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Aktualisierungsfrequenz"
- },
- "title": "Konfigurationsoptionen"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Aktualisierungsfrequenz"
},
- "description": "Konfigurieren von Optionen f\u00fcr Transmission",
"title": "Konfiguriere die Optionen f\u00fcr die \u00dcbertragung"
}
}
diff --git a/homeassistant/components/transmission/.translations/en.json b/homeassistant/components/transmission/.translations/en.json
index aa8b99a4914..3605f21e140 100644
--- a/homeassistant/components/transmission/.translations/en.json
+++ b/homeassistant/components/transmission/.translations/en.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Host is already configured.",
- "one_instance_allowed": "Only a single instance is necessary."
+ "already_configured": "Host is already configured."
},
"error": {
"cannot_connect": "Unable to Connect to host",
@@ -10,12 +9,6 @@
"wrong_credentials": "Wrong username or password"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Update frequency"
- },
- "title": "Configure Options"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Update frequency"
},
- "description": "Configure options for Transmission",
"title": "Configure options for Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/es.json b/homeassistant/components/transmission/.translations/es.json
index 06ea19e72b8..a1d0f364769 100644
--- a/homeassistant/components/transmission/.translations/es.json
+++ b/homeassistant/components/transmission/.translations/es.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "El host ya est\u00e1 configurado.",
- "one_instance_allowed": "S\u00f3lo se necesita una sola instancia."
+ "already_configured": "El host ya est\u00e1 configurado."
},
"error": {
"cannot_connect": "No se puede conectar al host",
@@ -10,12 +9,6 @@
"wrong_credentials": "Nombre de usuario o contrase\u00f1a incorrectos"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Frecuencia de actualizaci\u00f3n"
- },
- "title": "Configurar opciones"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Frecuencia de actualizaci\u00f3n"
},
- "description": "Configurar opciones para la transmisi\u00f3n",
"title": "Configurar opciones para la transmisi\u00f3n"
}
}
diff --git a/homeassistant/components/transmission/.translations/fr.json b/homeassistant/components/transmission/.translations/fr.json
index 3c267b36a08..c7a78201797 100644
--- a/homeassistant/components/transmission/.translations/fr.json
+++ b/homeassistant/components/transmission/.translations/fr.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9.",
- "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9."
},
"error": {
"cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te",
@@ -10,12 +9,6 @@
"wrong_credentials": "Mauvais nom d'utilisateur ou mot de passe"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Fr\u00e9quence de mise \u00e0 jour"
- },
- "title": "Configurer les options"
- },
"user": {
"data": {
"host": "H\u00f4te",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Fr\u00e9quence de mise \u00e0 jour"
},
- "description": "Configurer les options pour Transmission",
"title": "Configurer les options pour Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/hu.json b/homeassistant/components/transmission/.translations/hu.json
index 14bf5c28bdf..cbd2f44c340 100644
--- a/homeassistant/components/transmission/.translations/hu.json
+++ b/homeassistant/components/transmission/.translations/hu.json
@@ -1,20 +1,11 @@
{
"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",
diff --git a/homeassistant/components/transmission/.translations/it.json b/homeassistant/components/transmission/.translations/it.json
index a7c4c675856..8a1f01783c1 100644
--- a/homeassistant/components/transmission/.translations/it.json
+++ b/homeassistant/components/transmission/.translations/it.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "L'host \u00e8 gi\u00e0 configurato.",
- "one_instance_allowed": "\u00c8 necessaria solo una singola istanza."
+ "already_configured": "L'host \u00e8 gi\u00e0 configurato."
},
"error": {
"cannot_connect": "Impossibile connettersi all'host",
@@ -10,12 +9,6 @@
"wrong_credentials": "Nome utente o password non validi"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Frequenza di aggiornamento"
- },
- "title": "Configura opzioni"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Frequenza di aggiornamento"
},
- "description": "Configurare le opzioni per Trasmissione",
"title": "Configurare le opzioni per Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/ko.json b/homeassistant/components/transmission/.translations/ko.json
index 507d4e84789..4d3537818b7 100644
--- a/homeassistant/components/transmission/.translations/ko.json
+++ b/homeassistant/components/transmission/.translations/ko.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
},
"error": {
"cannot_connect": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
@@ -10,12 +9,6 @@
"wrong_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "\uc5c5\ub370\uc774\ud2b8 \ube48\ub3c4"
- },
- "title": "\uc635\uc158 \uc124\uc815"
- },
"user": {
"data": {
"host": "\ud638\uc2a4\ud2b8",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "\uc5c5\ub370\uc774\ud2b8 \ube48\ub3c4"
},
- "description": "Transmission \uc635\uc158 \uc124\uc815",
"title": "Transmission \uc635\uc158 \uc124\uc815"
}
}
diff --git a/homeassistant/components/transmission/.translations/lb.json b/homeassistant/components/transmission/.translations/lb.json
index a012bcd8cde..0533574efb0 100644
--- a/homeassistant/components/transmission/.translations/lb.json
+++ b/homeassistant/components/transmission/.translations/lb.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Apparat ass scho konfigur\u00e9iert",
- "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
},
"error": {
"cannot_connect": "Kann sech net mam Server verbannen.",
@@ -10,12 +9,6 @@
"wrong_credentials": "Falsche Benotzernumm oder Passwuert"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Intervalle vun de Mise \u00e0 jour"
- },
- "title": "Optioune konfigur\u00e9ieren"
- },
"user": {
"data": {
"host": "Server",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Intervalle vun de Mise \u00e0 jour"
},
- "description": "Optioune fir Transmission konfigur\u00e9ieren",
"title": "Optioune fir Transmission konfigur\u00e9ieren"
}
}
diff --git a/homeassistant/components/transmission/.translations/nl.json b/homeassistant/components/transmission/.translations/nl.json
index ccb9c569562..5abf25e286c 100644
--- a/homeassistant/components/transmission/.translations/nl.json
+++ b/homeassistant/components/transmission/.translations/nl.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Host is al geconfigureerd.",
- "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig."
+ "already_configured": "Host is al geconfigureerd."
},
"error": {
"cannot_connect": "Kan geen verbinding maken met host",
@@ -10,12 +9,6 @@
"wrong_credentials": "verkeerde gebruikersnaam of wachtwoord"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Update frequentie"
- },
- "title": "Configureer opties"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Update frequentie"
},
- "description": "Configureer opties voor Transmission",
"title": "Configureer de opties voor Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/no.json b/homeassistant/components/transmission/.translations/no.json
index 9cd19cd87f8..d18a854d6e3 100644
--- a/homeassistant/components/transmission/.translations/no.json
+++ b/homeassistant/components/transmission/.translations/no.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Verten er allerede konfigurert.",
- "one_instance_allowed": "Bare en enkel instans er n\u00f8dvendig."
+ "already_configured": "Verten er allerede konfigurert."
},
"error": {
"cannot_connect": "Kan ikke koble til vert",
@@ -10,24 +9,18 @@
"wrong_credentials": "Ugyldig brukernavn eller passord"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Oppdater frekvens"
- },
- "title": "Konfigurer alternativer"
- },
"user": {
"data": {
"host": "Vert",
"name": "Navn",
"password": "Passord",
- "port": "Port",
+ "port": "",
"username": "Brukernavn"
},
"title": "Oppsett av Transmission-klient"
}
},
- "title": "Transmission"
+ "title": ""
},
"options": {
"step": {
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Oppdater frekvens"
},
- "description": "Konfigurer alternativer for Transmission",
"title": "Konfigurer alternativer for Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/pl.json b/homeassistant/components/transmission/.translations/pl.json
index 5aac538766b..f3e8c01f3d7 100644
--- a/homeassistant/components/transmission/.translations/pl.json
+++ b/homeassistant/components/transmission/.translations/pl.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Host jest ju\u017c skonfigurowany.",
- "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ "already_configured": "Host jest ju\u017c skonfigurowany."
},
"error": {
"cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z hostem",
@@ -10,12 +9,6 @@
"wrong_credentials": "Nieprawid\u0142owa nazwa u\u017cytkownika lub has\u0142o"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji"
- },
- "title": "Opcje"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji"
},
- "description": "Konfiguracja opcji dla Transmission",
"title": "Konfiguracja opcji dla Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/pt-BR.json b/homeassistant/components/transmission/.translations/pt-BR.json
index de854e1273c..2c162e66ce7 100644
--- a/homeassistant/components/transmission/.translations/pt-BR.json
+++ b/homeassistant/components/transmission/.translations/pt-BR.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "O host j\u00e1 est\u00e1 configurado.",
- "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria."
+ "already_configured": "O host j\u00e1 est\u00e1 configurado."
},
"error": {
"cannot_connect": "N\u00e3o foi poss\u00edvel conectar ao host",
@@ -10,12 +9,6 @@
"wrong_credentials": "Nome de usu\u00e1rio ou senha incorretos"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Frequ\u00eancia de atualiza\u00e7\u00e3o"
- },
- "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o"
- },
"user": {
"data": {
"host": "Host",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Frequ\u00eancia de atualiza\u00e7\u00e3o"
},
- "description": "Configurar op\u00e7\u00f5es para transmiss\u00e3o",
"title": "Configurar op\u00e7\u00f5es para Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/ru.json b/homeassistant/components/transmission/.translations/ru.json
index 9f876dde505..ad43d3ee600 100644
--- a/homeassistant/components/transmission/.translations/ru.json
+++ b/homeassistant/components/transmission/.translations/ru.json
@@ -1,8 +1,7 @@
{
"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.",
- "one_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."
+ "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 \u0445\u043e\u0441\u0442\u0443.",
@@ -10,12 +9,6 @@
"wrong_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c."
},
"step": {
- "options": {
- "data": {
- "scan_interval": "\u0427\u0430\u0441\u0442\u043e\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f"
- },
- "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Transmission"
- },
"user": {
"data": {
"host": "\u0425\u043e\u0441\u0442",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "\u0427\u0430\u0441\u0442\u043e\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f"
},
- "description": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Transmission",
"title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/sl.json b/homeassistant/components/transmission/.translations/sl.json
index 37ce27e19f4..765fb284c3a 100644
--- a/homeassistant/components/transmission/.translations/sl.json
+++ b/homeassistant/components/transmission/.translations/sl.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Gostitelj je \u017ee konfiguriran.",
- "one_instance_allowed": "Potrebna je samo ena instanca."
+ "already_configured": "Gostitelj je \u017ee konfiguriran."
},
"error": {
"cannot_connect": "Ni mogo\u010de vzpostaviti povezave z gostiteljem",
@@ -10,12 +9,6 @@
"wrong_credentials": "Napa\u010dno uporabni\u0161ko ime ali geslo"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Pogostost posodabljanja"
- },
- "title": "Nastavite mo\u017enosti"
- },
"user": {
"data": {
"host": "Gostitelj",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Pogostost posodabljanja"
},
- "description": "Nastavite mo\u017enosti za Transmission",
"title": "Nastavite mo\u017enosti za Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/sv.json b/homeassistant/components/transmission/.translations/sv.json
index b2a00771e85..289c9f985e3 100644
--- a/homeassistant/components/transmission/.translations/sv.json
+++ b/homeassistant/components/transmission/.translations/sv.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "V\u00e4rden \u00e4r redan konfigurerad.",
- "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ "already_configured": "V\u00e4rden \u00e4r redan konfigurerad."
},
"error": {
"cannot_connect": "Det g\u00e5r inte att ansluta till v\u00e4rden",
@@ -10,12 +9,6 @@
"wrong_credentials": "Fel anv\u00e4ndarnamn eller l\u00f6senord"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "Uppdateringsfrekvens"
- },
- "title": "Konfigurera alternativ"
- },
"user": {
"data": {
"host": "V\u00e4rd",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "Uppdateringsfrekvens"
},
- "description": "Konfigurera alternativ f\u00f6r Transmission",
"title": "Konfigurera alternativ f\u00f6r Transmission"
}
}
diff --git a/homeassistant/components/transmission/.translations/zh-Hant.json b/homeassistant/components/transmission/.translations/zh-Hant.json
index 304babc991e..6ae573211c6 100644
--- a/homeassistant/components/transmission/.translations/zh-Hant.json
+++ b/homeassistant/components/transmission/.translations/zh-Hant.json
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002",
- "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002"
},
"error": {
"cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef",
@@ -10,12 +9,6 @@
"wrong_credentials": "\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc\u932f\u8aa4"
},
"step": {
- "options": {
- "data": {
- "scan_interval": "\u66f4\u65b0\u983b\u7387"
- },
- "title": "\u8a2d\u5b9a\u9078\u9805"
- },
"user": {
"data": {
"host": "\u4e3b\u6a5f\u7aef",
@@ -35,7 +28,6 @@
"data": {
"scan_interval": "\u66f4\u65b0\u983b\u7387"
},
- "description": "Transmission \u8a2d\u5b9a\u9078\u9805",
"title": "Transmission \u8a2d\u5b9a\u9078\u9805"
}
}
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index 3a456dec531..d9d513198ce 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -133,7 +133,7 @@ async def async_setup(hass, config):
hass, p_config, discovery_info
)
else:
- provider = await hass.async_add_job(
+ provider = await hass.async_add_executor_job(
platform.get_engine, hass, p_config, discovery_info
)
@@ -226,41 +226,17 @@ class SpeechManager:
self.time_memory = time_memory
self.base_url = base_url
- def init_tts_cache_dir(cache_dir):
- """Init cache folder."""
- if not os.path.isabs(cache_dir):
- cache_dir = self.hass.config.path(cache_dir)
- if not os.path.isdir(cache_dir):
- _LOGGER.info("Create cache dir %s.", cache_dir)
- os.mkdir(cache_dir)
- return cache_dir
-
try:
- self.cache_dir = await self.hass.async_add_job(
- init_tts_cache_dir, cache_dir
+ self.cache_dir = await self.hass.async_add_executor_job(
+ _init_tts_cache_dir, self.hass, cache_dir
)
except OSError as err:
raise HomeAssistantError(f"Can't init cache dir {err}")
- def get_cache_files():
- """Return a dict of given engine files."""
- cache = {}
-
- folder_data = os.listdir(self.cache_dir)
- for file_data in folder_data:
- record = _RE_VOICE_FILE.match(file_data)
- if record:
- key = KEY_PATTERN.format(
- record.group(1),
- record.group(2),
- record.group(3),
- record.group(4),
- )
- cache[key.lower()] = file_data.lower()
- return cache
-
try:
- cache_files = await self.hass.async_add_job(get_cache_files)
+ cache_files = await self.hass.async_add_executor_job(
+ _get_cache_files, self.cache_dir
+ )
except OSError as err:
raise HomeAssistantError(f"Can't read cache dir {err}")
@@ -273,13 +249,13 @@ class SpeechManager:
def remove_files():
"""Remove files from filesystem."""
- for _, filename in self.file_cache.items():
+ for filename in self.file_cache.values():
try:
os.remove(os.path.join(self.cache_dir, filename))
except OSError as err:
_LOGGER.warning("Can't remove cache file '%s': %s", filename, err)
- await self.hass.async_add_job(remove_files)
+ await self.hass.async_add_executor_job(remove_files)
self.file_cache = {}
@callback
@@ -312,6 +288,7 @@ class SpeechManager:
merged_options.update(options)
options = merged_options
options = options or provider.default_options
+
if options is not None:
invalid_opts = [
opt_name
@@ -378,10 +355,10 @@ class SpeechManager:
speech.write(data)
try:
- await self.hass.async_add_job(save_speech)
+ await self.hass.async_add_executor_job(save_speech)
self.file_cache[key] = filename
- except OSError:
- _LOGGER.error("Can't write %s", filename)
+ except OSError as err:
+ _LOGGER.error("Can't write %s: %s", filename, err)
async def async_file_to_mem(self, key):
"""Load voice from file cache into memory.
@@ -400,7 +377,7 @@ class SpeechManager:
return speech.read()
try:
- data = await self.hass.async_add_job(load_speech)
+ data = await self.hass.async_add_executor_job(load_speech)
except OSError:
del self.file_cache[key]
raise HomeAssistantError(f"Can't read {voice_file}")
@@ -506,11 +483,36 @@ class Provider:
Return a tuple of file extension and data as bytes.
"""
- return await self.hass.async_add_job(
+ return await self.hass.async_add_executor_job(
ft.partial(self.get_tts_audio, message, language, options=options)
)
+def _init_tts_cache_dir(hass, cache_dir):
+ """Init cache folder."""
+ if not os.path.isabs(cache_dir):
+ cache_dir = hass.config.path(cache_dir)
+ if not os.path.isdir(cache_dir):
+ _LOGGER.info("Create cache dir %s", cache_dir)
+ os.mkdir(cache_dir)
+ return cache_dir
+
+
+def _get_cache_files(cache_dir):
+ """Return a dict of given engine files."""
+ cache = {}
+
+ folder_data = os.listdir(cache_dir)
+ for file_data in folder_data:
+ record = _RE_VOICE_FILE.match(file_data)
+ if record:
+ key = KEY_PATTERN.format(
+ record.group(1), record.group(2), record.group(3), record.group(4),
+ )
+ cache[key.lower()] = file_data.lower()
+ return cache
+
+
class TextToSpeechUrlView(HomeAssistantView):
"""TTS view to get a url to a generated speech file."""
diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json
index 9444e33700e..da4dc074262 100644
--- a/homeassistant/components/twentemilieu/manifest.json
+++ b/homeassistant/components/twentemilieu/manifest.json
@@ -3,7 +3,6 @@
"name": "Twente Milieu",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/twentemilieu",
- "requirements": ["twentemilieu==0.2.0"],
- "dependencies": [],
+ "requirements": ["twentemilieu==0.3.0"],
"codeowners": ["@frenck"]
}
diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py
index 21d2fd10009..266acc49c09 100644
--- a/homeassistant/components/ubee/device_tracker.py
+++ b/homeassistant/components/ubee/device_tracker.py
@@ -24,7 +24,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): vol.Any(
- "EVW32C-0N", "EVW320B", "EVW321B", "EVW3200-Wifi", "EVW3226@UPC", "DVW32CB"
+ "EVW32C-0N",
+ "EVW320B",
+ "EVW321B",
+ "EVW3200-Wifi",
+ "EVW3226@UPC",
+ "DVW32CB",
+ "DDW36C",
),
}
)
diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json
index e853c7490db..446bc2c62d5 100644
--- a/homeassistant/components/ubee/manifest.json
+++ b/homeassistant/components/ubee/manifest.json
@@ -2,7 +2,7 @@
"domain": "ubee",
"name": "Ubee Router",
"documentation": "https://www.home-assistant.io/integrations/ubee",
- "requirements": ["pyubee==0.9"],
+ "requirements": ["pyubee==0.10"],
"dependencies": [],
"codeowners": ["@mzdrale"]
}
diff --git a/homeassistant/components/unifi/.translations/ca.json b/homeassistant/components/unifi/.translations/ca.json
index 89d299a2857..7eefb77b3d2 100644
--- a/homeassistant/components/unifi/.translations/ca.json
+++ b/homeassistant/components/unifi/.translations/ca.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Credencials d'usuari incorrectes",
- "service_unavailable": "Servei no disponible"
+ "service_unavailable": "Servei no disponible",
+ "unknown_client_mac": "No hi ha cap client disponible en aquesta adre\u00e7a MAC"
},
"step": {
"user": {
@@ -23,8 +24,20 @@
},
"title": "Controlador UniFi"
},
+ "error": {
+ "unknown_client_mac": "No hi ha cap client disponible a UniFi en aquesta adre\u00e7a MAC"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Clients controlats amb acc\u00e9s a la xarxa",
+ "new_client": "Afegeix un client nou per al control d\u2019acc\u00e9s a la xarxa",
+ "poe_clients": "Permet control POE dels clients"
+ },
+ "description": "Configura els controls del client \n\nConfigura interruptors per als n\u00fameros de s\u00e8rie als quals vulguis controlar l'acc\u00e9s a la xarxa.",
+ "title": "Opcions d'UniFi 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Temps (en segons) des de s'ha vist per \u00faltima vegada fins que es considera a fora",
@@ -36,12 +49,6 @@
"description": "Configuraci\u00f3 de seguiment de dispositius",
"title": "Opcions d'UniFi"
},
- "init": {
- "data": {
- "one": "un",
- "other": "altre"
- }
- },
"statistics_sensors": {
"data": {
"allow_bandwidth_sensors": "Crea sensors d'\u00fas d'ample de banda per a clients de la xarxa"
diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json
index 2f3db9d9b89..afdea87956b 100644
--- a/homeassistant/components/unifi/.translations/de.json
+++ b/homeassistant/components/unifi/.translations/de.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Ung\u00fcltige Anmeldeinformationen",
- "service_unavailable": "Kein Dienst verf\u00fcgbar"
+ "service_unavailable": "Kein Dienst verf\u00fcgbar",
+ "unknown_client_mac": "Unter dieser MAC-Adresse ist kein Client verf\u00fcgbar."
},
"step": {
"user": {
@@ -23,8 +24,20 @@
},
"title": "UniFi-Controller"
},
+ "error": {
+ "unknown_client_mac": "Unter dieser MAC-Adresse ist in UniFi kein Client verf\u00fcgbar"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Clients mit Netzwerkzugriffskontrolle",
+ "new_client": "F\u00fcgen Sie einen neuen Client f\u00fcr die Netzwerkzugangskontrolle hinzu",
+ "poe_clients": "POE-Kontrolle von Clients zulassen"
+ },
+ "description": "Konfigurieren Sie Client-Steuerelemente \n\nErstellen Sie Switches f\u00fcr Seriennummern, f\u00fcr die Sie den Netzwerkzugriff steuern m\u00f6chten.",
+ "title": "UniFi-Optionen 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Zeit in Sekunden vom letzten Gesehenen bis zur Entfernung",
@@ -34,7 +47,7 @@
"track_wired_clients": "Einbinden von kabelgebundenen Netzwerk-Clients"
},
"description": "Konfigurieren Sie die Ger\u00e4teverfolgung",
- "title": "UniFi-Optionen"
+ "title": "UniFi-Optionen 1/3"
},
"init": {
"data": {
@@ -44,10 +57,10 @@
},
"statistics_sensors": {
"data": {
- "allow_bandwidth_sensors": "Erstellen von Bandbreiten-Nutzungssensoren f\u00fcr Netzwerk-Clients"
+ "allow_bandwidth_sensors": "Bandbreitennutzungssensoren f\u00fcr Netzwerkclients"
},
"description": "Konfigurieren Sie Statistiksensoren",
- "title": "UniFi-Optionen"
+ "title": "UniFi-Optionen 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json
index 9ac01e514bf..d42a647c82f 100644
--- a/homeassistant/components/unifi/.translations/en.json
+++ b/homeassistant/components/unifi/.translations/en.json
@@ -24,8 +24,20 @@
},
"title": "UniFi Controller"
},
+ "error": {
+ "unknown_client_mac": "No client available in UniFi on that MAC address"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Network access controlled clients",
+ "new_client": "Add new client for network access control",
+ "poe_clients": "Allow POE control of clients"
+ },
+ "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.",
+ "title": "UniFi options 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Time in seconds from last seen until considered away",
@@ -37,14 +49,6 @@
"description": "Configure device tracking",
"title": "UniFi options 1/3"
},
- "client_control": {
- "data": {
- "block_client": "Network access controlled clients",
- "new_client": "Add new client (MAC) for network access control"
- },
- "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.",
- "title": "UniFi options 2/3"
- },
"statistics_sensors": {
"data": {
"allow_bandwidth_sensors": "Bandwidth usage sensors for network clients"
@@ -52,9 +56,6 @@
"description": "Configure statistics sensors",
"title": "UniFi options 3/3"
}
- },
- "error": {
- "unknown_client_mac": "No client available in UniFi on that MAC address"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json
index 6c5e9d677c2..31c7e6c0bcd 100644
--- a/homeassistant/components/unifi/.translations/es.json
+++ b/homeassistant/components/unifi/.translations/es.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Credenciales de usuario incorrectas",
- "service_unavailable": "Servicio No disponible"
+ "service_unavailable": "Servicio No disponible",
+ "unknown_client_mac": "Ning\u00fan cliente disponible en esa direcci\u00f3n MAC"
},
"step": {
"user": {
@@ -23,8 +24,20 @@
},
"title": "Controlador UniFi"
},
+ "error": {
+ "unknown_client_mac": "Ning\u00fan cliente disponible en UniFi en esa direcci\u00f3n MAC"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Clientes con acceso controlado a la red",
+ "new_client": "A\u00f1adir nuevo cliente para el control de acceso a la red",
+ "poe_clients": "Permitir control PoE de clientes"
+ },
+ "description": "Configurar controles de cliente\n\nCrea conmutadores para los n\u00fameros de serie para los que deseas controlar el acceso a la red.",
+ "title": "Opciones UniFi 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Tiempo en segundos desde la \u00faltima vez que se vio hasta considerarlo desconectado",
@@ -34,7 +47,7 @@
"track_wired_clients": "Incluir clientes de red cableada"
},
"description": "Configurar dispositivo de seguimiento",
- "title": "Opciones UniFi"
+ "title": "Opciones UniFi 1/3"
},
"init": {
"data": {
@@ -47,7 +60,7 @@
"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"
+ "title": "Opciones UniFi 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/fr.json b/homeassistant/components/unifi/.translations/fr.json
index 0a100be0a11..659a567a91f 100644
--- a/homeassistant/components/unifi/.translations/fr.json
+++ b/homeassistant/components/unifi/.translations/fr.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Mauvaises informations d'identification de l'utilisateur",
- "service_unavailable": "Aucun service disponible"
+ "service_unavailable": "Aucun service disponible",
+ "unknown_client_mac": "Aucun client disponible sur cette adresse MAC"
},
"step": {
"user": {
@@ -23,15 +24,27 @@
},
"title": "Contr\u00f4leur UniFi"
},
+ "error": {
+ "unknown_client_mac": "Aucun client disponible dans UniFi sur cette adresse MAC"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Clients contr\u00f4l\u00e9s par acc\u00e8s r\u00e9seau",
+ "new_client": "Ajouter un nouveau client pour le contr\u00f4le d'acc\u00e8s au r\u00e9seau"
+ },
+ "title": "Options UniFi 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Temps en secondes depuis la derni\u00e8re vue avant de consid\u00e9rer comme absent",
"track_clients": "Suivre les clients du r\u00e9seau",
"track_devices": "Suivre les p\u00e9riph\u00e9riques r\u00e9seau (p\u00e9riph\u00e9riques Ubiquiti)",
"track_wired_clients": "Inclure les clients du r\u00e9seau filaire"
- }
+ },
+ "description": "Configurer le suivi des appareils",
+ "title": "Options UniFi 1/3"
},
"init": {
"data": {
@@ -42,7 +55,9 @@
"statistics_sensors": {
"data": {
"allow_bandwidth_sensors": "Cr\u00e9er des capteurs d'utilisation de la bande passante pour les clients r\u00e9seau"
- }
+ },
+ "description": "Configurer des capteurs de statistiques",
+ "title": "Options UniFi 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/it.json b/homeassistant/components/unifi/.translations/it.json
index c1aa9afe54f..9439715fe79 100644
--- a/homeassistant/components/unifi/.translations/it.json
+++ b/homeassistant/components/unifi/.translations/it.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Credenziali utente non valide",
- "service_unavailable": "Servizio non disponibile"
+ "service_unavailable": "Servizio non disponibile",
+ "unknown_client_mac": "Nessun client disponibile su quell'indirizzo MAC"
},
"step": {
"user": {
@@ -23,8 +24,19 @@
},
"title": "UniFi Controller"
},
+ "error": {
+ "unknown_client_mac": "Nessun client disponibile in UniFi su quell'indirizzo MAC"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Client controllati per l'accesso alla rete",
+ "new_client": "Aggiungere un nuovo client per il controllo dell'accesso alla rete"
+ },
+ "description": "Configurare i controlli client \n\nCreare interruttori per i numeri di serie dei quali si desidera controllare l'accesso alla rete.",
+ "title": "Opzioni UniFi 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Tempo in secondi dall'ultima volta che viene visto fino a quando non \u00e8 considerato lontano",
@@ -34,12 +46,12 @@
"track_wired_clients": "Includi i client di rete cablata"
},
"description": "Configurare il tracciamento del dispositivo",
- "title": "Opzioni UniFi"
+ "title": "Opzioni UniFi 1/3"
},
"init": {
"data": {
"one": "uno",
- "other": "altro"
+ "other": "altri"
}
},
"statistics_sensors": {
@@ -47,7 +59,7 @@
"allow_bandwidth_sensors": "Sensori di utilizzo della larghezza di banda per i client di rete"
},
"description": "Configurare i sensori delle statistiche",
- "title": "Opzioni UniFi"
+ "title": "Opzioni UniFi 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json
index dbcd4d7feee..d57d80c7911 100644
--- a/homeassistant/components/unifi/.translations/ko.json
+++ b/homeassistant/components/unifi/.translations/ko.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "\uc0ac\uc6a9\uc790 \uc790\uaca9\uc99d\uba85\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
- "service_unavailable": "\uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4"
+ "service_unavailable": "\uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "unknown_client_mac": "\ud574\ub2f9 MAC \uc8fc\uc18c\uc5d0\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ud074\ub77c\uc774\uc5b8\ud2b8\uac00 \uc5c6\uc2b5\ub2c8\ub2e4."
},
"step": {
"user": {
@@ -23,8 +24,20 @@
},
"title": "UniFi \ucee8\ud2b8\ub864\ub7ec"
},
+ "error": {
+ "unknown_client_mac": "\ud574\ub2f9 MAC \uc8fc\uc18c\uc758 UniFi \uc5d0\uc11c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \ud074\ub77c\uc774\uc5b8\ud2b8\uac00 \uc5c6\uc2b5\ub2c8\ub2e4."
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4 \uc81c\uc5b4 \ud074\ub77c\uc774\uc5b8\ud2b8",
+ "new_client": "\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4 \uc81c\uc5b4\ub97c \uc704\ud55c \uc0c8\ub85c\uc6b4 \ud074\ub77c\uc774\uc5b8\ud2b8 \ucd94\uac00",
+ "poe_clients": "\ud074\ub77c\uc774\uc5b8\ud2b8\uc758 POE \uc81c\uc5b4 \ud5c8\uc6a9"
+ },
+ "description": "\ud074\ub77c\uc774\uc5b8\ud2b8 \ucee8\ud2b8\ub864 \uad6c\uc131 \n\n\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4\ub97c \uc81c\uc5b4\ud558\ub824\ub294 \uc2dc\ub9ac\uc5bc \ubc88\ud638\uc5d0 \ub300\ud55c \uc2a4\uc704\uce58\ub97c \ub9cc\ub4ed\ub2c8\ub2e4.",
+ "title": "UniFi \uc635\uc158 2/3"
+ },
"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)",
@@ -33,15 +46,15 @@
"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"
+ "description": "\uae30\uae30 \ucd94\uc801 \uad6c\uc131",
+ "title": "UniFi \uc635\uc158 1/3"
},
"statistics_sensors": {
"data": {
"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"
+ "title": "UniFi \uc635\uc158 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/lb.json b/homeassistant/components/unifi/.translations/lb.json
index 9707432540d..a3d7d685ed2 100644
--- a/homeassistant/components/unifi/.translations/lb.json
+++ b/homeassistant/components/unifi/.translations/lb.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Ong\u00eblteg Login Informatioune",
- "service_unavailable": "Keen Service disponibel"
+ "service_unavailable": "Keen Service disponibel",
+ "unknown_client_mac": "Kee Cliwent mat der MAC Adress disponibel"
},
"step": {
"user": {
@@ -23,16 +24,30 @@
},
"title": "Unifi Kontroller"
},
+ "error": {
+ "unknown_client_mac": "Kee Client am Unifi disponibel mat der MAC Adress"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Netzwierk Zougang kontroll\u00e9iert Clienten",
+ "new_client": "Neie Client fir Netzwierk Zougang Kontroll b\u00e4isetzen",
+ "poe_clients": "POE Kontroll vun Clienten erlaben"
+ },
+ "description": "Client Kontroll konfigur\u00e9ieren\n\nErstell Schalter fir Serienummer d\u00e9i sollen fir Netzwierk Zougangs Kontroll kontroll\u00e9iert ginn.",
+ "title": "UniFi Optiounen 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Z\u00e4it a Sekonne vum leschten Z\u00e4itpunkt un bis den Apparat als \u00ebnnerwee consider\u00e9iert g\u00ebtt",
+ "ssid_filter": "SSIDs auswielen fir Clienten ze verfollegen",
"track_clients": "Netzwierk Cliente verfollegen",
"track_devices": "Netzwierk Apparater (Ubiquiti Apparater) verfollegen",
"track_wired_clients": "Kabel Netzwierk Cliente abez\u00e9ien"
},
- "title": "UniFi Optiounen"
+ "description": "Apparate verfollegen ariichten",
+ "title": "UniFi Optiounen 1/3"
},
"init": {
"data": {
@@ -45,7 +60,7 @@
"allow_bandwidth_sensors": "Bandbreet Benotzung Sensore fir Netzwierk Cliente erstellen"
},
"description": "Statistik Sensoren konfigur\u00e9ieren",
- "title": "UniFi Optiounen"
+ "title": "UniFi Optiounen 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json
index 65730c7ab8b..156faf83d92 100644
--- a/homeassistant/components/unifi/.translations/no.json
+++ b/homeassistant/components/unifi/.translations/no.json
@@ -6,14 +6,15 @@
},
"error": {
"faulty_credentials": "Ugyldig brukerlegitimasjon",
- "service_unavailable": "Ingen tjeneste tilgjengelig"
+ "service_unavailable": "Ingen tjeneste tilgjengelig",
+ "unknown_client_mac": "Ingen klient tilgjengelig p\u00e5 den MAC-adressen"
},
"step": {
"user": {
"data": {
"host": "Vert",
"password": "Passord",
- "port": "Port",
+ "port": "",
"site": "Nettsted-ID",
"username": "Brukernavn",
"verify_ssl": "Kontroller bruker riktig sertifikat"
@@ -23,8 +24,19 @@
},
"title": "UniFi kontroller"
},
+ "error": {
+ "unknown_client_mac": "Ingen klient tilgjengelig i UniFi p\u00e5 den MAC-adressen"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Nettverkskontrollerte klienter",
+ "new_client": "Legg til ny klient for nettverkstilgangskontroll"
+ },
+ "description": "Konfigurere klient-kontroller\n\nOpprette brytere for serienumre du \u00f8nsker \u00e5 kontrollere tilgang til nettverk for.",
+ "title": "UniFi-alternativ 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Tid i sekunder fra sist sett til den ble ansett borte",
@@ -34,14 +46,14 @@
"track_wired_clients": "Inkluder kablede nettverksklienter"
},
"description": "Konfigurere enhetssporing",
- "title": "UniFi-alternativer"
+ "title": "UniFi-alternativ 1/3"
},
"statistics_sensors": {
"data": {
"allow_bandwidth_sensors": "B\u00e5ndbreddebrukssensorer for nettverksklienter"
},
"description": "Konfigurer statistikk sensorer",
- "title": "UniFi-alternativer"
+ "title": "UniFi-alternativ 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/pl.json b/homeassistant/components/unifi/.translations/pl.json
index e016fbc7cce..08329aed574 100644
--- a/homeassistant/components/unifi/.translations/pl.json
+++ b/homeassistant/components/unifi/.translations/pl.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce",
- "service_unavailable": "Brak dost\u0119pnych us\u0142ug"
+ "service_unavailable": "Brak dost\u0119pnych us\u0142ug",
+ "unknown_client_mac": "Brak klienta z tym adresem MAC"
},
"step": {
"user": {
@@ -23,8 +24,19 @@
},
"title": "Kontroler UniFi"
},
+ "error": {
+ "unknown_client_mac": "Brak klienta w UniFi z tym adresem MAC"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Klienci z kontrol\u0105 dost\u0119pu do sieci",
+ "new_client": "Dodaj nowego klienta do kontroli dost\u0119pu do sieci"
+ },
+ "description": "Konfigurowanie kontroli klienta\n\nUtw\u00f3rz prze\u0142\u0105czniki dla numer\u00f3w seryjnych, dla kt\u00f3rych chcesz kontrolowa\u0107 dost\u0119p do sieci.",
+ "title": "UniFi opcje 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "Czas w sekundach od momentu, kiedy ostatnio widziano, a\u017c do momentu, kiedy uznano go za nieobecny.",
diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json
index 0080474cf64..6cd09a947eb 100644
--- a/homeassistant/components/unifi/.translations/ru.json
+++ b/homeassistant/components/unifi/.translations/ru.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.",
- "service_unavailable": "\u0421\u043b\u0443\u0436\u0431\u0430 \u043d\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430."
+ "service_unavailable": "\u0421\u043b\u0443\u0436\u0431\u0430 \u043d\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430.",
+ "unknown_client_mac": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043d\u0430 \u044d\u0442\u043e\u043c MAC-\u0430\u0434\u0440\u0435\u0441\u0435."
},
"step": {
"user": {
@@ -25,6 +26,14 @@
},
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "\u041a\u043b\u0438\u0435\u043d\u0442\u044b \u0441 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435\u043c \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0434\u043e\u0441\u0442\u0443\u043f\u0430",
+ "new_client": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043d\u043e\u0432\u043e\u0433\u043e \u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u0434\u043b\u044f \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0434\u043e\u0441\u0442\u0443\u043f\u0430",
+ "poe_clients": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c POE \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432"
+ },
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi. \u0428\u0430\u0433 2"
+ },
"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\".",
@@ -34,7 +43,7 @@
"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"
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi. \u0428\u0430\u0433 1"
},
"init": {
"data": {
@@ -49,7 +58,7 @@
"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"
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi. \u0428\u0430\u0433 3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/sl.json b/homeassistant/components/unifi/.translations/sl.json
index 7084c4609c5..a2c37f027b2 100644
--- a/homeassistant/components/unifi/.translations/sl.json
+++ b/homeassistant/components/unifi/.translations/sl.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki",
- "service_unavailable": "Nobena storitev ni na voljo"
+ "service_unavailable": "Nobena storitev ni na voljo",
+ "unknown_client_mac": "Na tem MAC naslovu ni na voljo nobenega odjemalca"
},
"step": {
"user": {
@@ -23,15 +24,29 @@
},
"title": "UniFi Krmilnik"
},
+ "error": {
+ "unknown_client_mac": "V UniFi na tem naslovu MAC ni na voljo nobenega odjemalca"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "Odjemalci pod nadzorom dostopa do omre\u017eja",
+ "new_client": "Dodajte novega odjemalca za nadzor dostopa do omre\u017eja"
+ },
+ "description": "Konfigurirajte nadzor odjemalcev \n\n Ustvarite stikala za serijske \u0161tevilke, za katere \u017eelite nadzirati dostop do omre\u017eja.",
+ "title": "Mo\u017enosti UniFi 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "\u010cas v sekundah od zadnjega videnja na omre\u017eju do odsotnosti",
+ "ssid_filter": "Izberite SSID-e za sledenje brez\u017ei\u010dnim odjemalcem",
"track_clients": "Sledite odjemalcem omre\u017eja",
"track_devices": "Sledite omre\u017enim napravam (naprave Ubiquiti)",
"track_wired_clients": "Vklju\u010dite kliente iz o\u017ei\u010denega omre\u017eja"
- }
+ },
+ "description": "Konfigurirajte sledenje napravam",
+ "title": "Mo\u017enosti UniFi 1/3"
},
"init": {
"data": {
@@ -43,8 +58,10 @@
},
"statistics_sensors": {
"data": {
- "allow_bandwidth_sensors": "Ustvarite senzorje porabe pasovne \u0161irine za omre\u017ene odjemalce"
- }
+ "allow_bandwidth_sensors": "Senzorji uporabe pasovne \u0161irine za omre\u017ene odjemalce"
+ },
+ "description": "Konfigurirajte statisti\u010dne senzorje",
+ "title": "Mo\u017enosti UniFi 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/.translations/zh-Hant.json b/homeassistant/components/unifi/.translations/zh-Hant.json
index cce150a6765..e91bfca407c 100644
--- a/homeassistant/components/unifi/.translations/zh-Hant.json
+++ b/homeassistant/components/unifi/.translations/zh-Hant.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548",
- "service_unavailable": "\u7121\u670d\u52d9\u53ef\u7528"
+ "service_unavailable": "\u7121\u670d\u52d9\u53ef\u7528",
+ "unknown_client_mac": "\u8a72 Mac \u4f4d\u5740\u7121\u53ef\u7528\u5ba2\u6236\u7aef"
},
"step": {
"user": {
@@ -23,8 +24,20 @@
},
"title": "UniFi \u63a7\u5236\u5668"
},
+ "error": {
+ "unknown_client_mac": "\u8a72 Mac \u4f4d\u5740\u7121\u53ef\u7528 UniFi \u5ba2\u6236\u7aef"
+ },
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "block_client": "\u7db2\u8def\u5b58\u53d6\u63a7\u5236\u5ba2\u6236\u7aef",
+ "new_client": "\u65b0\u589e\u9396\u8981\u63a7\u5236\u7db2\u8def\u5b58\u53d6\u7684\u5ba2\u6236\u7aef",
+ "poe_clients": "\u5141\u8a31 POE \u63a7\u5236\u5ba2\u6236\u7aef"
+ },
+ "description": "\u8a2d\u5b9a\u5ba2\u6236\u7aef\u63a7\u5236\n\n\u65b0\u589e\u9396\u8981\u63a7\u5236\u7db2\u8def\u5b58\u53d6\u7684\u958b\u95dc\u5e8f\u865f\u3002",
+ "title": "UniFi \u9078\u9805 2/3"
+ },
"device_tracker": {
"data": {
"detection_time": "\u6700\u7d42\u51fa\u73fe\u5f8c\u8996\u70ba\u96e2\u958b\u7684\u6642\u9593\uff08\u4ee5\u79d2\u70ba\u55ae\u4f4d\uff09",
@@ -34,14 +47,14 @@
"track_wired_clients": "\u5305\u542b\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef"
},
"description": "\u8a2d\u5b9a\u8a2d\u5099\u8ffd\u8e64",
- "title": "UniFi \u9078\u9805"
+ "title": "UniFi \u9078\u9805 1/3"
},
"statistics_sensors": {
"data": {
"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"
+ "title": "UniFi \u9078\u9805 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py
index fc69eca3022..e9f534360d7 100644
--- a/homeassistant/components/unifi/__init__.py
+++ b/homeassistant/components/unifi/__init__.py
@@ -1,69 +1,26 @@
"""Support for devices connected to UniFi POE."""
import voluptuous as vol
-from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from .config_flow import get_controller_id_from_config_entry
-from .const import (
- ATTR_MANUFACTURER,
- CONF_BLOCK_CLIENT,
- CONF_DETECTION_TIME,
- CONF_DONT_TRACK_CLIENTS,
- CONF_DONT_TRACK_DEVICES,
- CONF_DONT_TRACK_WIRED_CLIENTS,
- CONF_SITE_ID,
- CONF_SSID_FILTER,
- DOMAIN,
- UNIFI_CONFIG,
- UNIFI_WIRELESS_CLIENTS,
-)
+from .const import ATTR_MANUFACTURER, DOMAIN, UNIFI_WIRELESS_CLIENTS
from .controller import UniFiController
SAVE_DELAY = 10
STORAGE_KEY = "unifi_data"
STORAGE_VERSION = 1
-CONF_CONTROLLERS = "controllers"
-
-CONTROLLER_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_SITE_ID): cv.string,
- vol.Optional(CONF_BLOCK_CLIENT, default=[]): vol.All(
- cv.ensure_list, [cv.string]
- ),
- vol.Optional(CONF_DONT_TRACK_CLIENTS): cv.boolean,
- vol.Optional(CONF_DONT_TRACK_DEVICES): cv.boolean,
- vol.Optional(CONF_DONT_TRACK_WIRED_CLIENTS): cv.boolean,
- vol.Optional(CONF_DETECTION_TIME): cv.positive_int,
- vol.Optional(CONF_SSID_FILTER): vol.All(cv.ensure_list, [cv.string]),
- }
-)
-
CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_CONTROLLERS): vol.All(
- cv.ensure_list, [CONTROLLER_SCHEMA]
- )
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
+ cv.deprecated(DOMAIN, invalidation_version="0.109"), {DOMAIN: cv.match_all}
)
async def async_setup(hass, config):
"""Component doesn't support configuration through configuration.yaml."""
- hass.data[UNIFI_CONFIG] = []
-
- if DOMAIN in config:
- hass.data[UNIFI_CONFIG] = config[DOMAIN][CONF_CONTROLLERS]
-
hass.data[UNIFI_WIRELESS_CLIENTS] = wireless_clients = UnifiWirelessClients(hass)
await wireless_clients.async_load()
diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py
index 341364063f2..fd94601db50 100644
--- a/homeassistant/components/unifi/const.py
+++ b/homeassistant/components/unifi/const.py
@@ -9,7 +9,6 @@ CONTROLLER_ID = "{host}-{site}"
CONF_CONTROLLER = "controller"
CONF_SITE_ID = "site"
-UNIFI_CONFIG = "unifi_config"
UNIFI_WIRELESS_CLIENTS = "unifi_wireless_clients"
CONF_ALLOW_BANDWIDTH_SENSORS = "allow_bandwidth_sensors"
@@ -20,10 +19,6 @@ CONF_TRACK_DEVICES = "track_devices"
CONF_TRACK_WIRED_CLIENTS = "track_wired_clients"
CONF_SSID_FILTER = "ssid_filter"
-CONF_DONT_TRACK_CLIENTS = "dont_track_clients"
-CONF_DONT_TRACK_DEVICES = "dont_track_devices"
-CONF_DONT_TRACK_WIRED_CLIENTS = "dont_track_wired_clients"
-
DEFAULT_ALLOW_BANDWIDTH_SENSORS = False
DEFAULT_TRACK_CLIENTS = True
DEFAULT_TRACK_DEVICES = True
diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py
index a6981aeddee..50b758f01af 100644
--- a/homeassistant/components/unifi/controller.py
+++ b/homeassistant/components/unifi/controller.py
@@ -10,6 +10,9 @@ from aiounifi.events import WIRELESS_CLIENT_CONNECTED, WIRELESS_GUEST_CONNECTED
from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING
import async_timeout
+from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -21,9 +24,6 @@ from .const import (
CONF_BLOCK_CLIENT,
CONF_CONTROLLER,
CONF_DETECTION_TIME,
- CONF_DONT_TRACK_CLIENTS,
- CONF_DONT_TRACK_DEVICES,
- CONF_DONT_TRACK_WIRED_CLIENTS,
CONF_SITE_ID,
CONF_SSID_FILTER,
CONF_TRACK_CLIENTS,
@@ -37,13 +37,12 @@ from .const import (
DEFAULT_TRACK_WIRED_CLIENTS,
DOMAIN,
LOGGER,
- UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
)
from .errors import AuthenticationRequired, CannotConnect
RETRY_TIMER = 15
-SUPPORTED_PLATFORMS = ["device_tracker", "sensor", "switch"]
+SUPPORTED_PLATFORMS = [DT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
class UniFiController:
@@ -225,8 +224,6 @@ class UniFiController:
self.wireless_clients = wireless_clients.get_data(self.config_entry)
self.update_wireless_clients()
- self.import_configuration()
-
for platform in SUPPORTED_PLATFORMS:
self.hass.async_create_task(
self.hass.config_entries.async_forward_entry_setup(
@@ -251,46 +248,6 @@ class UniFiController:
async_dispatcher_send(hass, controller.signal_options_update)
- def import_configuration(self):
- """Import configuration to config entry options."""
- import_config = {}
-
- for config in self.hass.data[UNIFI_CONFIG]:
- if (
- self.host == config[CONF_HOST]
- and self.site_name == config[CONF_SITE_ID]
- ):
- import_config = config
- break
-
- old_options = dict(self.config_entry.options)
- new_options = {}
-
- for config, option in (
- (CONF_BLOCK_CLIENT, CONF_BLOCK_CLIENT),
- (CONF_DONT_TRACK_CLIENTS, CONF_TRACK_CLIENTS),
- (CONF_DONT_TRACK_WIRED_CLIENTS, CONF_TRACK_WIRED_CLIENTS),
- (CONF_DONT_TRACK_DEVICES, CONF_TRACK_DEVICES),
- (CONF_DETECTION_TIME, CONF_DETECTION_TIME),
- (CONF_SSID_FILTER, CONF_SSID_FILTER),
- ):
- if config in import_config:
- if config == option and import_config[
- config
- ] != self.config_entry.options.get(option):
- new_options[option] = import_config[config]
- elif config != option and (
- option not in self.config_entry.options
- or import_config[config] == self.config_entry.options.get(option)
- ):
- new_options[option] = not import_config[config]
-
- if new_options:
- options = {**old_options, **new_options}
- self.hass.config_entries.async_update_entry(
- self.config_entry, options=options
- )
-
@callback
def reconnect(self) -> None:
"""Prepare to reconnect UniFi session."""
diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py
index e5d3bcfa82b..07e96a45fce 100644
--- a/homeassistant/components/unifi/device_tracker.py
+++ b/homeassistant/components/unifi/device_tracker.py
@@ -45,6 +45,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
option_track_clients = controller.option_track_clients
option_track_devices = controller.option_track_devices
option_track_wired_clients = controller.option_track_wired_clients
+ option_ssid_filter = controller.option_ssid_filter
registry = await hass.helpers.entity_registry.async_get_registry()
@@ -86,6 +87,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
nonlocal option_track_clients
nonlocal option_track_devices
nonlocal option_track_wired_clients
+ nonlocal option_ssid_filter
update = False
remove = set()
@@ -116,6 +118,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if isinstance(entity, UniFiClientTracker) and entity.is_wired:
remove.add(mac)
+ if option_ssid_filter != controller.option_ssid_filter:
+ option_ssid_filter = controller.option_ssid_filter
+ update = True
+
+ for mac, entity in tracked.items():
+ if (
+ isinstance(entity, UniFiClientTracker)
+ and not entity.is_wired
+ and entity.client.essid not in option_ssid_filter
+ ):
+ 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
@@ -157,10 +171,18 @@ def add_entities(controller, async_add_entities, tracked):
if item_id in tracked:
continue
- if tracker_class is UniFiClientTracker and (
- not controller.option_track_wired_clients and items[item_id].is_wired
- ):
- continue
+ if tracker_class is UniFiClientTracker:
+ client = items[item_id]
+
+ if not controller.option_track_wired_clients and client.is_wired:
+ continue
+
+ if (
+ controller.option_ssid_filter
+ and not client.is_wired
+ and client.essid not in controller.option_ssid_filter
+ ):
+ continue
tracked[item_id] = tracker_class(items[item_id], controller)
new_tracked.append(tracked[item_id])
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index b4fe49a88e7..72ffda48b57 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -6,13 +6,13 @@ import velbus
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_NAME, CONF_PORT
+from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PORT
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
-from .const import DOMAIN
+from .const import CONF_MEMO_TEXT, DOMAIN, SERVICE_SET_MEMO_TEXT
_LOGGER = logging.getLogger(__name__)
@@ -80,6 +80,32 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
hass.services.async_register(DOMAIN, "sync_clock", syn_clock, schema=vol.Schema({}))
+ def set_memo_text(service):
+ """Handle Memo Text service call."""
+ module_address = service.data[CONF_ADDRESS]
+ memo_text = service.data[CONF_MEMO_TEXT]
+ memo_text.hass = hass
+ try:
+ controller.get_module(module_address).set_memo_text(
+ memo_text.async_render()
+ )
+ except velbus.util.VelbusException as err:
+ _LOGGER.error("An error occurred while setting memo text: %s", err)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_MEMO_TEXT,
+ set_memo_text,
+ vol.Schema(
+ {
+ vol.Required(CONF_ADDRESS): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ vol.Optional(CONF_MEMO_TEXT, default=""): cv.template,
+ }
+ ),
+ )
+
return True
diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py
index 0d3a66fa743..d3987295fce 100644
--- a/homeassistant/components/velbus/const.py
+++ b/homeassistant/components/velbus/const.py
@@ -1,3 +1,7 @@
"""Const for Velbus."""
DOMAIN = "velbus"
+
+CONF_MEMO_TEXT = "memo_text"
+
+SERVICE_SET_MEMO_TEXT = "set_memo_text"
diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml
index 273cc8b4caa..ea31b951a18 100644
--- a/homeassistant/components/velbus/services.yaml
+++ b/homeassistant/components/velbus/services.yaml
@@ -1,2 +1,18 @@
sync_clock:
description: Sync the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink
+
+set_memo_text:
+ description: >
+ Set the memo text to the display of modules like VMBGPO, VMBGPOD
+ Be sure the page(s) of the module is configured to display the memo text.
+ fields:
+ address:
+ description: >
+ The module address in decimal format.
+ The decimal addresses are displayed in front of the modules listed at the integration page.
+ example: '11'
+ memo_text:
+ description: >
+ The actual text to be displayed.
+ Text is limited to 64 characters.
+ example: 'Do not forget trash'
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/ca.json b/homeassistant/components/vera/.translations/ca.json
new file mode 100644
index 00000000000..d15d12ce6c3
--- /dev/null
+++ b/homeassistant/components/vera/.translations/ca.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ja hi ha un controlador configurat.",
+ "cannot_connect": "No s'ha pogut connectar amb el controlador amb l'URL {base_url}"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "Identificadors de dispositiu Vera a excloure de Home Assistant.",
+ "lights": "Identificadors de dispositiu dels commutadors Vera a tractar com a llums a Home Assistant.",
+ "vera_controller_url": "URL del controlador"
+ },
+ "description": "Proporciona un URL pel controlador Vera. Hauria de quedar aix\u00ed: http://192.168.1.161:3480.",
+ "title": "Configuraci\u00f3 del controlador Vera"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "Identificadors de dispositiu Vera a excloure de Home Assistant.",
+ "lights": "Identificadors de dispositiu dels commutadors Vera a tractar com a llums a Home Assistant."
+ },
+ "description": "Consulta la documentaci\u00f3 de Vera per veure els detalls sobre els par\u00e0metres opcionals a: https://www.home-assistant.io/integrations/vera/. Nota: tots els canvis fets aqu\u00ed necessitaran un reinici de Home Assistant. Per esborrar valors, posa-hi un espai.",
+ "title": "Opcions del controlador Vera"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/de.json b/homeassistant/components/vera/.translations/de.json
new file mode 100644
index 00000000000..91f61c9c2bc
--- /dev/null
+++ b/homeassistant/components/vera/.translations/de.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ein Controller ist bereits konfiguriert.",
+ "cannot_connect": "Konnte keine Verbindung zum Controller mit url {base_url} herstellen"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "Vera-Ger\u00e4te-IDs, die vom Home Assistant ausgeschlossen werden sollen.",
+ "lights": "Vera Switch-Ger\u00e4te-IDs, die im Home Assistant als Lichter behandelt werden sollen.",
+ "vera_controller_url": "Controller-URL"
+ },
+ "description": "Stellen Sie unten eine Vera-Controller-Url zur Verf\u00fcgung. Sie sollte wie folgt aussehen: http://192.168.1.161:3480.",
+ "title": "Richten Sie den Vera-Controller ein"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "Vera-Ger\u00e4te-IDs, die vom Home Assistant ausgeschlossen werden sollen."
+ },
+ "description": "Weitere Informationen zu optionalen Parametern finden Sie in der Vera-Dokumentation: https://www.home-assistant.io/integrations/vera/. Hinweis: Alle \u00c4nderungen hier erfordern einen Neustart des Home Assistant-Servers. Geben Sie ein Leerzeichen ein, um Werte zu l\u00f6schen.",
+ "title": "Vera Controller Optionen"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/en.json b/homeassistant/components/vera/.translations/en.json
new file mode 100644
index 00000000000..0578daa4c0b
--- /dev/null
+++ b/homeassistant/components/vera/.translations/en.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A controller is already configured.",
+ "cannot_connect": "Could not connect to controller with url {base_url}"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "Vera device ids to exclude from Home Assistant.",
+ "lights": "Vera switch device ids to treat as lights in Home Assistant.",
+ "vera_controller_url": "Controller URL"
+ },
+ "description": "Provide a Vera controller url below. It should look like this: http://192.168.1.161:3480.",
+ "title": "Setup Vera controller"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "Vera device ids to exclude from Home Assistant.",
+ "lights": "Vera switch device ids to treat as lights in Home Assistant."
+ },
+ "description": "See the vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here will need a restart to the home assistant server. To clear values, provide a space.",
+ "title": "Vera controller options"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/es.json b/homeassistant/components/vera/.translations/es.json
new file mode 100644
index 00000000000..672bcc9056e
--- /dev/null
+++ b/homeassistant/components/vera/.translations/es.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Un controlador ya est\u00e1 configurado.",
+ "cannot_connect": "No se pudo conectar con el controlador con url {base_url}"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "Identificadores de dispositivos Vera a excluir de Home Assistant",
+ "lights": "Identificadores de interruptores Vera que deben ser tratados como luces en Home Assistant",
+ "vera_controller_url": "URL del controlador"
+ },
+ "description": "Introduce una URL para el controlador Vera a continuaci\u00f3n. Ser\u00eda algo como: http://192.168.1.161:3480.",
+ "title": "Configurar el controlador Vera"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "Identificadores de dispositivos Vera a excluir de Home Assistant",
+ "lights": "Identificadores de interruptores Vera que deben ser tratados como luces en Home Assistant"
+ },
+ "description": "Consulte la documentaci\u00f3n de Vera para obtener detalles sobre los par\u00e1metros opcionales: https://www.home-assistant.io/integrations/vera/. Nota: Cualquier cambio aqu\u00ed necesitar\u00e1 un reinicio del servidor de Home Assistant. Para borrar valores, introduce un espacio.",
+ "title": "Opciones del controlador Vera"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/ko.json b/homeassistant/components/vera/.translations/ko.json
new file mode 100644
index 00000000000..cecde6b9183
--- /dev/null
+++ b/homeassistant/components/vera/.translations/ko.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ucee8\ud2b8\ub864\ub7ec\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "cannot_connect": "URL {base_url} \uc5d0 \ucee8\ud2b8\ub864\ub7ec\ub97c \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "Home Assistant \uc5d0\uc11c \uc81c\uc678\ud560 Vera \uae30\uae30 ID.",
+ "lights": "Vera \uc2a4\uc704\uce58 \uae30\uae30 ID \ub294 Home Assistant \uc5d0\uc11c \uc870\uba85\uc73c\ub85c \ucde8\uae09\ub429\ub2c8\ub2e4.",
+ "vera_controller_url": "\ucee8\ud2b8\ub864\ub7ec URL"
+ },
+ "description": "\uc544\ub798\uc5d0 Vera \ucee8\ud2b8\ub864\ub7ec URL \uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694. http://192.168.1.161:3480 \uacfc \uac19\uc740 \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4.",
+ "title": "Vera \ucee8\ud2b8\ub864\ub7ec \uc124\uc815"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "Home Assistant \uc5d0\uc11c \uc81c\uc678\ud560 Vera \uae30\uae30 ID.",
+ "lights": "Vera \uc2a4\uc704\uce58 \uae30\uae30 ID \ub294 Home Assistant \uc5d0\uc11c \uc870\uba85\uc73c\ub85c \ucde8\uae09\ub429\ub2c8\ub2e4."
+ },
+ "description": "\ub9e4\uac1c \ubcc0\uc218 \uc120\ud0dd\uc0ac\ud56d\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 vera \uc124\uba85\uc11c\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694: https://www.home-assistant.io/integrations/vera/. \ucc38\uace0: \uc5ec\uae30\uc5d0\uc11c \ubcc0\uacbd\ud558\uba74 Home Assistant \uc11c\ubc84\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud574\uc57c \ud569\ub2c8\ub2e4. \uac12\uc744 \uc9c0\uc6b0\ub824\uba74 \uc785\ub825\ub780\uc744 \uacf5\ubc31\uc73c\ub85c \ub450\uc138\uc694.",
+ "title": "Vera \ucee8\ud2b8\ub864\ub7ec \uc635\uc158"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/lb.json b/homeassistant/components/vera/.translations/lb.json
new file mode 100644
index 00000000000..440c576596f
--- /dev/null
+++ b/homeassistant/components/vera/.translations/lb.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ee Kontroller ass scho konfigur\u00e9iert",
+ "cannot_connect": "Et konnt keng Verbindung mam Kontroller mat der URL {base_url} hiergestallt ginn"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "IDs vu Vera Apparater d\u00e9i vun Home Assistant ausgeschloss solle ginn.",
+ "lights": "IDs vun Apparater vu Vera Schalter d\u00e9i als Luuchten am Home Assistant trait\u00e9iert ginn.",
+ "vera_controller_url": "Kontroller URL"
+ },
+ "description": "Vera Kontroller URL uginn: D\u00e9i sollt sou ausgesinn:\nhttp://192.168.1.161:3480.",
+ "title": "Vera Kontroller ariichten"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "IDs vu Vera Apparater d\u00e9i vun Home Assistant ausgeschloss solle ginn.",
+ "lights": "IDs vun Apparater vu Vera Schalter d\u00e9i als Luuchten am Home Assistant trait\u00e9iert ginn."
+ },
+ "description": "Kuck Vera Dokumentatioun fir Detailer zu den optionellle Parameter: https://www.home-assistant.io/integrations/vera/. Hiweis: All \u00c4nnerunge ginn er\u00e9ischt no engem Neistart vum Home Assistant aktiv. Fir W\u00e4rter ze l\u00e4schen, einfach een \"Leerzeichen\" am Feld uginn.",
+ "title": "Vera Kontroller Optiounen"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/ru.json b/homeassistant/components/vera/.translations/ru.json
new file mode 100644
index 00000000000..de374358e84
--- /dev/null
+++ b/homeassistant/components/vera/.translations/ru.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0443 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 {base_url}."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Vera \u0434\u043b\u044f \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u0437 Home Assistant.",
+ "lights": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Vera \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0438\u0437 \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432 \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u0432 Home Assistant.",
+ "vera_controller_url": "URL-\u0430\u0434\u0440\u0435\u0441 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430"
+ },
+ "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 URL-\u0430\u0434\u0440\u0435\u0441 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430 Vera. \u0410\u0434\u0440\u0435\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0443\u043a\u0430\u0437\u0430\u043d \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 'http://192.168.1.161:3480'.",
+ "title": "Vera"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Vera \u0434\u043b\u044f \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u0437 Home Assistant.",
+ "lights": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 Vera \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0438\u0437 \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432 \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u0432 Home Assistant."
+ },
+ "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0445: https://www.home-assistant.io/integrations/vera/.\n\u0414\u043b\u044f \u0432\u043d\u0435\u0441\u0435\u043d\u0438\u044f \u043b\u044e\u0431\u044b\u0445 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439 \u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430 Home Assistant. \u0427\u0442\u043e\u0431\u044b \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f, \u043f\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0440\u043e\u0431\u0435\u043b.",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430 Vera"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vera/.translations/zh-Hant.json b/homeassistant/components/vera/.translations/zh-Hant.json
new file mode 100644
index 00000000000..6fb71a57abe
--- /dev/null
+++ b/homeassistant/components/vera/.translations/zh-Hant.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64\u63a7\u5236\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002",
+ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u63a7\u5236\u5668 URL {base_url}"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "exclude": "\u5f9e Home Assistant \u6392\u9664\u7684 Vera \u8a2d\u5099 ID\u3002",
+ "lights": "\u65bc Home Assistant \u4e2d\u8996\u70ba\u71c8\u5149\u7684 Vera \u958b\u95dc\u8a2d\u5099 ID\u3002",
+ "vera_controller_url": "\u63a7\u5236\u5668 URL"
+ },
+ "description": "\u65bc\u4e0b\u65b9\u63d0\u4f9b Vera \u63a7\u5236\u5668 URL\u3002\u683c\u5f0f\u61c9\u8a72\u70ba\uff1ahttp://192.168.1.161:3480\u3002",
+ "title": "\u8a2d\u5b9a Vera \u63a7\u5236\u5668"
+ }
+ },
+ "title": "Vera"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "exclude": "\u5f9e Home Assistant \u6392\u9664\u7684 Vera \u8a2d\u5099 ID\u3002",
+ "lights": "\u65bc Home Assistant \u4e2d\u8996\u70ba\u71c8\u5149\u7684 Vera \u958b\u95dc\u8a2d\u5099 ID\u3002"
+ },
+ "description": "\u8acb\u53c3\u95b1 Vera \u6587\u4ef6\u4ee5\u7372\u5f97\u8a73\u7d30\u7684\u9078\u9805\u53c3\u6578\u8cc7\u6599\uff1ahttps://www.home-assistant.io/integrations/vera/\u3002\u8acb\u6ce8\u610f\uff1a\u4efb\u4f55\u8b8a\u66f4\u90fd\u9700\u8981\u91cd\u555f Home Assistant\u3002\u6b32\u6e05\u9664\u8a2d\u5b9a\u503c\u3001\u8acb\u8f38\u5165\u7a7a\u683c\u3002",
+ "title": "Vera \u63a7\u5236\u5668\u9078\u9805"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json
index 37f88d16654..8d79234375c 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.2.0"],
+ "requirements": ["pyhaversion==3.3.0"],
"dependencies": [],
"codeowners": ["@fabaff"],
"quality_scale": "internal"
diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py
index 7632a101769..335e89eb873 100644
--- a/homeassistant/components/vicare/__init__.py
+++ b/homeassistant/components/vicare/__init__.py
@@ -7,7 +7,12 @@ from PyViCare.PyViCareGazBoiler import GazBoiler
from PyViCare.PyViCareHeatPump import HeatPump
import voluptuous as vol
-from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import (
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_SCAN_INTERVAL,
+ CONF_USERNAME,
+)
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.storage import STORAGE_DIR
@@ -40,6 +45,9 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=60): vol.All(
+ cv.time_period, lambda value: value.total_seconds()
+ ),
vol.Optional(CONF_CIRCUIT): int,
vol.Optional(CONF_NAME, default="ViCare"): cv.string,
vol.Optional(CONF_HEATING_TYPE, default=DEFAULT_HEATING_TYPE): cv.enum(
@@ -59,6 +67,8 @@ def setup(hass, config):
if conf.get(CONF_CIRCUIT) is not None:
params["circuit"] = conf[CONF_CIRCUIT]
+ params["cacheDuration"] = conf.get(CONF_SCAN_INTERVAL)
+
heating_type = conf[CONF_HEATING_TYPE]
try:
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index ef5533523f8..1b101cc7612 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -1,5 +1,4 @@
"""Viessmann ViCare climate device."""
-from datetime import timedelta
import logging
import requests
@@ -80,9 +79,6 @@ HA_TO_VICARE_PRESET_HEATING = {
PYVICARE_ERROR = "error"
-# Scan interval of 15 minutes seems to be safe to not hit the ViCare server rate limit
-SCAN_INTERVAL = timedelta(seconds=900)
-
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Create the ViCare climate devices."""
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 66fd15d3a90..a03c927c2ac 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.7"]
+ "requirements": ["PyViCare==0.1.10"]
}
diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py
index fdac2962739..eea3d81faf6 100644
--- a/homeassistant/components/vicare/water_heater.py
+++ b/homeassistant/components/vicare/water_heater.py
@@ -1,5 +1,4 @@
"""Viessmann ViCare water_heater device."""
-from datetime import timedelta
import logging
import requests
@@ -43,9 +42,6 @@ HA_TO_VICARE_HVAC_DHW = {
PYVICARE_ERROR = "error"
-# Scan interval of 15 minutes seems to be safe to not hit the ViCare server rate limit
-SCAN_INTERVAL = timedelta(seconds=900)
-
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Create the ViCare water_heater devices."""
diff --git a/homeassistant/components/vilfo/.translations/de.json b/homeassistant/components/vilfo/.translations/de.json
index 9c0f938b679..fed2265def2 100644
--- a/homeassistant/components/vilfo/.translations/de.json
+++ b/homeassistant/components/vilfo/.translations/de.json
@@ -14,6 +14,7 @@
"access_token": "Zugriffstoken f\u00fcr die Vilfo Router-API",
"host": "Router-Hostname oder IP"
},
+ "description": "Richten Sie die Vilfo Router-Integration ein. Sie ben\u00f6tigen Ihren Vilfo Router-Hostnamen / Ihre IP-Adresse und ein API-Zugriffstoken. Weitere Informationen zu dieser Integration und wie Sie diese Details erhalten, finden Sie unter: https://www.home-assistant.io/integrations/vilfo",
"title": "Stellen Sie eine Verbindung zum Vilfo Router her"
}
},
diff --git a/homeassistant/components/vilfo/.translations/fr.json b/homeassistant/components/vilfo/.translations/fr.json
index 6abeb789f23..64e48adc573 100644
--- a/homeassistant/components/vilfo/.translations/fr.json
+++ b/homeassistant/components/vilfo/.translations/fr.json
@@ -1,5 +1,10 @@
{
"config": {
+ "step": {
+ "user": {
+ "title": "Connectez-vous au routeur Vilfo"
+ }
+ },
"title": "Routeur Vilfo"
}
}
\ No newline at end of file
diff --git a/homeassistant/components/vilfo/.translations/no.json b/homeassistant/components/vilfo/.translations/no.json
index af72a4bd7b0..61b9c56f496 100644
--- a/homeassistant/components/vilfo/.translations/no.json
+++ b/homeassistant/components/vilfo/.translations/no.json
@@ -18,6 +18,6 @@
"title": "Koble til Vilfo Ruteren"
}
},
- "title": "Vilfo Router"
+ "title": ""
}
}
\ No newline at end of file
diff --git a/homeassistant/components/vilfo/.translations/pl.json b/homeassistant/components/vilfo/.translations/pl.json
index aef0c14703f..e9cd91209a4 100644
--- a/homeassistant/components/vilfo/.translations/pl.json
+++ b/homeassistant/components/vilfo/.translations/pl.json
@@ -15,7 +15,7 @@
"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": "Po\u0142\u0105czenie z routerem Vilfo"
}
},
"title": "Router Vilfo"
diff --git a/homeassistant/components/vizio/.translations/ca.json b/homeassistant/components/vizio/.translations/ca.json
index 007834a08e3..6b9a3a89134 100644
--- a/homeassistant/components/vizio/.translations/ca.json
+++ b/homeassistant/components/vizio/.translations/ca.json
@@ -1,34 +1,30 @@
{
"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."
+ "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."
},
"error": {
"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.",
"complete_pairing failed": "No s'ha pogut completar l'emparellament. Verifica que el PIN proporcionat sigui el correcte i que el televisor segueix connectat a la xarxa abans de provar-ho de nou.",
"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)."
+ "name_exists": "Dispositiu Vizio amb aquest nom ja configurat."
},
"step": {
"pair_tv": {
"data": {
"pin": "PIN"
},
- "description": "El televisor hauria d'estar mostrant un codi. Introdueix aquest codi al formulari i segueix amb els seg\u00fcents passos per completar l'emparellament."
+ "description": "El televisor hauria d'estar mostrant un codi. Introdueix aquest codi al formulari i segueix amb els seg\u00fcents passos per completar l'emparellament.",
+ "title": "Proc\u00e9s d'aparellament complet"
},
"pairing_complete": {
- "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant."
+ "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant.",
+ "title": "Emparellament completat"
},
"pairing_complete_import": {
- "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant.\n\nEl testimoni d'acc\u00e9s (Access Token) \u00e9s '**{access_token}**'."
+ "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant.\n\nEl testimoni d'acc\u00e9s (Access Token) \u00e9s '**{access_token}**'.",
+ "title": "Emparellament completat"
},
"user": {
"data": {
@@ -37,6 +33,7 @@
"host": ":",
"name": "Nom"
},
+ "description": "Nom\u00e9s es necessita testimoni d'acc\u00e9s per als televisors. Si est\u00e0s configurant un televisor i encara no tens un testimoni d'acc\u00e9s, deixeu-ho en blanc per poder fer el proc\u00e9s d'emparellament.",
"title": "Configuraci\u00f3 del client de Vizio SmartCast"
}
},
@@ -46,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "Temps d'espera de les sol\u00b7licituds API (en segons)",
+ "apps_to_include_or_exclude": "Aplicacions a incloure o excloure",
+ "include_or_exclude": "Incloure o excloure aplicacions?",
"volume_step": "Mida del pas de volum"
},
+ "description": "Si tens una Smart TV, pots filtrar de manera opcional la teva llista de canals escollint quines aplicacions vols incloure o excloure de la llista.",
"title": "Actualitzaci\u00f3 de les opcions de Vizo SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/da.json b/homeassistant/components/vizio/.translations/da.json
index 9bfd5864025..5317c1c2adb 100644
--- a/homeassistant/components/vizio/.translations/da.json
+++ b/homeassistant/components/vizio/.translations/da.json
@@ -1,20 +1,13 @@
{
"config": {
"abort": {
- "already_in_progress": "Konfigurationsproces for Vizio-komponenten er allerede i gang.",
"already_setup": "Denne post er allerede blevet konfigureret.",
- "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."
+ "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."
},
"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": "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."
+ "name_exists": "Vizio-enhed med det specificerede navn er allerede konfigureret."
},
"step": {
"user": {
@@ -33,7 +26,6 @@
"step": {
"init": {
"data": {
- "timeout": "Timeout for API-anmodning (sekunder)",
"volume_step": "Lydstyrkestrinst\u00f8rrelse"
},
"title": "Opdater Vizo SmartCast-indstillinger"
diff --git a/homeassistant/components/vizio/.translations/de.json b/homeassistant/components/vizio/.translations/de.json
index 6162a27805e..2197d27de71 100644
--- a/homeassistant/components/vizio/.translations/de.json
+++ b/homeassistant/components/vizio/.translations/de.json
@@ -1,30 +1,29 @@
{
"config": {
"abort": {
- "already_in_progress": "Konfigurationsablauf f\u00fcr die Vizio-Komponente wird bereits ausgef\u00fchrt.",
"already_setup": "Dieser Eintrag wurde bereits eingerichtet.",
- "host_exists": "Vizio-Komponent mit bereits konfiguriertem Host.",
- "name_exists": "Vizio-Komponent mit bereits konfiguriertem Namen.",
- "updated_options": "Dieser Eintrag wurde bereits eingerichtet, aber die in der Konfiguration definierten Optionen stimmen nicht mit den zuvor importierten Optionswerten \u00fcberein, daher wurde der Konfigurationseintrag entsprechend aktualisiert.",
- "updated_volume_step": "Dieser Eintrag wurde bereits eingerichtet, aber die Lautst\u00e4rken-Schrittgr\u00f6\u00dfe in der Konfiguration stimmt nicht mit dem Konfigurationseintrag \u00fcberein, sodass der Konfigurationseintrag entsprechend aktualisiert wurde."
+ "updated_entry": "Dieser Eintrag wurde bereits eingerichtet, aber der Name, die Apps und / oder die in der Konfiguration definierten Optionen stimmen nicht mit der zuvor importierten Konfiguration \u00fcberein, sodass der Konfigurationseintrag entsprechend aktualisiert wurde."
},
"error": {
"cant_connect": "Es konnte keine Verbindung zum Ger\u00e4t hergestellt werden. [\u00dcberpr\u00fcfen Sie die Dokumentation] (https://www.home-assistant.io/integrations/vizio/) und \u00fcberpr\u00fcfen Sie Folgendes erneut:\n- Das Ger\u00e4t ist eingeschaltet\n- Das Ger\u00e4t ist mit dem Netzwerk verbunden\n- Die von Ihnen eingegebenen Werte sind korrekt\nbevor sie versuchen, erneut zu \u00fcbermitteln.",
- "host_exists": "Host bereits konfiguriert.",
- "name_exists": "Name bereits konfiguriert.",
- "tv_needs_token": "Wenn der Ger\u00e4tetyp \"TV\" ist, wird ein g\u00fcltiger Zugriffstoken ben\u00f6tigt."
+ "complete_pairing failed": "Das Pairing kann nicht abgeschlossen werden. Stellen Sie sicher, dass die von Ihnen angegebene PIN korrekt ist und das Fernsehger\u00e4t weiterhin mit Strom versorgt und mit dem Netzwerk verbunden ist, bevor Sie es erneut versuchen.",
+ "host_exists": "Vizio-Ger\u00e4t mit angegebenem Host bereits konfiguriert.",
+ "name_exists": "Vizio-Ger\u00e4t mit angegebenem Namen bereits konfiguriert."
},
"step": {
"pair_tv": {
"data": {
"pin": "PIN"
},
+ "description": "Ihr Fernseher sollte einen Code anzeigen. Geben Sie diesen Code in das Formular ein und fahren Sie mit dem n\u00e4chsten Schritt fort, um die Kopplung abzuschlie\u00dfen.",
"title": "Schlie\u00dfen Sie den Pairing-Prozess ab"
},
"pairing_complete": {
+ "description": "Ihr Vizio SmartCast-Ger\u00e4t ist jetzt mit Home Assistant verbunden.",
"title": "Kopplung abgeschlossen"
},
"pairing_complete_import": {
+ "description": "Ihr Vizio SmartCast-Fernseher ist jetzt mit Home Assistant verbunden. \n\n Ihr Zugriffstoken ist '**{access_token}**'.",
"title": "Kopplung abgeschlossen"
},
"user": {
@@ -34,7 +33,8 @@
"host": ":",
"name": "Name"
},
- "title": "Richten Sie den Vizio SmartCast-Client ein"
+ "description": "Ein Zugriffstoken wird nur f\u00fcr Fernsehger\u00e4te ben\u00f6tigt. Wenn Sie ein Fernsehger\u00e4t konfigurieren und noch kein Zugriffstoken haben, lassen Sie es leer, um einen Pairing-Vorgang durchzuf\u00fchren.",
+ "title": "Richten Sie das Vizio SmartCast-Ger\u00e4t ein"
}
},
"title": "Vizio SmartCast"
@@ -43,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "API Request Timeout (Sekunden)",
+ "apps_to_include_or_exclude": "Apps zum Einschlie\u00dfen oder Ausschlie\u00dfen",
+ "include_or_exclude": "Apps einschlie\u00dfen oder ausschlie\u00dfen?",
"volume_step": "Lautst\u00e4rken-Schrittgr\u00f6\u00dfe"
},
+ "description": "Wenn Sie \u00fcber ein Smart-TV-Ger\u00e4t verf\u00fcgen, k\u00f6nnen Sie Ihre Quellliste optional filtern, indem Sie ausw\u00e4hlen, welche Apps in Ihre Quellliste aufgenommen oder ausgeschlossen werden sollen.",
"title": "Aktualisieren Sie die Vizo SmartCast-Optionen"
}
},
diff --git a/homeassistant/components/vizio/.translations/en.json b/homeassistant/components/vizio/.translations/en.json
index 294025fddc8..f4b03e1eb82 100644
--- a/homeassistant/components/vizio/.translations/en.json
+++ b/homeassistant/components/vizio/.translations/en.json
@@ -1,21 +1,14 @@
{
"config": {
"abort": {
- "already_in_progress": "Config flow for vizio component already in progress.",
"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.",
- "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."
+ "updated_entry": "This entry has already been setup but the name, apps, and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly."
},
"error": {
"cant_connect": "Could not connect to the device. [Review the docs](https://www.home-assistant.io/integrations/vizio/) and re-verify that:\n- The device is powered on\n- The device is connected to the network\n- The values you filled in are accurate\nbefore attempting to resubmit.",
"complete_pairing failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.",
"host_exists": "Vizio device with specified host already configured.",
- "name_exists": "Vizio device with specified name already configured.",
- "tv_needs_token": "When Device Type is `tv` then a valid Access Token is needed."
+ "name_exists": "Vizio device with specified name already configured."
},
"step": {
"pair_tv": {
@@ -30,7 +23,7 @@
"title": "Pairing Complete"
},
"pairing_complete_import": {
- "description": "Your Vizio SmartCast device is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'.",
+ "description": "Your Vizio SmartCast TV is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'.",
"title": "Pairing Complete"
},
"user": {
@@ -40,7 +33,7 @@
"host": ":",
"name": "Name"
},
- "description": "All fields are required except Access Token. If you choose not to provide an Access Token, and your Device Type is 'tv', you will go through a pairing process with your device so an Access Token can be retrieved.\n\nTo go through the pairing process, before clicking Submit, ensure your TV is powered on and connected to the network. You also need to be able to see the screen.",
+ "description": "An Access Token is only needed for TVs. If you are configuring a TV and do not have an Access Token yet, leave it blank to go through a pairing process.",
"title": "Setup Vizio SmartCast Device"
}
},
@@ -50,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "API Request Timeout (seconds)",
+ "apps_to_include_or_exclude": "Apps to Include or Exclude",
+ "include_or_exclude": "Include or Exclude Apps?",
"volume_step": "Volume Step Size"
},
+ "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.",
"title": "Update Vizo SmartCast Options"
}
},
diff --git a/homeassistant/components/vizio/.translations/es.json b/homeassistant/components/vizio/.translations/es.json
index af3cc1750ab..eb35fbb0b5b 100644
--- a/homeassistant/components/vizio/.translations/es.json
+++ b/homeassistant/components/vizio/.translations/es.json
@@ -1,21 +1,14 @@
{
"config": {
"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."
+ "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."
},
"error": {
"cant_connect": "No se pudo conectar al dispositivo. [Revise los documentos] (https://www.home-assistant.io/integrations/vizio/) y vuelva a verificar que:\n- El dispositivo est\u00e1 encendido\n- El dispositivo est\u00e1 conectado a la red\n- Los valores que ha rellenado son precisos\nantes de intentar volver a enviar.",
"complete_pairing failed": "No se pudo completar el emparejamiento. Aseg\u00farate de que el PIN que has proporcionado es correcto y que el televisor sigue encendido y conectado a la red antes de volver a enviarlo.",
"host_exists": "El host ya est\u00e1 configurado.",
- "name_exists": "Nombre ya configurado.",
- "tv_needs_token": "Cuando el tipo de dispositivo es `tv`, se necesita un token de acceso v\u00e1lido."
+ "name_exists": "Nombre ya configurado."
},
"step": {
"pair_tv": {
@@ -30,7 +23,7 @@
"title": "Emparejamiento Completado"
},
"pairing_complete_import": {
- "description": "Su dispositivo Vizio SmartCast ahora est\u00e1 conectado a Home Assistant.\n\nTu Token de Acceso es '**{access_token}**'.",
+ "description": "Su dispositivo Vizio SmartCast TV ahora est\u00e1 conectado a Home Assistant.\n\nEl Token de Acceso es '**{access_token}**'.",
"title": "Emparejamiento Completado"
},
"user": {
@@ -50,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "Tiempo de espera de solicitud de API (segundos)",
+ "apps_to_include_or_exclude": "Aplicaciones para incluir o excluir",
+ "include_or_exclude": "\u00bfIncluir o excluir aplicaciones?",
"volume_step": "Tama\u00f1o del paso de volumen"
},
+ "description": "Si tienes un Smart TV, opcionalmente puedes filtrar su lista de fuentes eligiendo qu\u00e9 aplicaciones incluir o excluir en su lista de fuentes.",
"title": "Actualizar las opciones de SmartCast de Vizo"
}
},
diff --git a/homeassistant/components/vizio/.translations/fr.json b/homeassistant/components/vizio/.translations/fr.json
index cf0cdea787f..0c0ff56af69 100644
--- a/homeassistant/components/vizio/.translations/fr.json
+++ b/homeassistant/components/vizio/.translations/fr.json
@@ -1,22 +1,29 @@
{
"config": {
"abort": {
- "already_in_progress": "Flux de configuration pour le composant Vizio d\u00e9j\u00e0 en cours.",
"already_setup": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e.",
- "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."
+ "updated_entry": "Cette entr\u00e9e a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e mais le nom et/ou les options d\u00e9finis dans la configuration ne correspondent pas \u00e0 la configuration pr\u00e9c\u00e9demment import\u00e9e, de sorte que l'entr\u00e9e de configuration a \u00e9t\u00e9 mise \u00e0 jour en cons\u00e9quence."
},
"error": {
"cant_connect": "Impossible de se connecter \u00e0 l'appareil. [V\u00e9rifier les documents](https://www.home-assistant.io/integrations/vizio/) et rev\u00e9rifier que:\n- L'appareil est sous tension\n- L'appareil est connect\u00e9 au r\u00e9seau\n- Les valeurs que vous avez saisies sont exactes\navant d'essayer de le soumettre \u00e0 nouveau.",
+ "complete_pairing failed": "Impossible de terminer l'appariement. Assurez-vous que le code PIN que vous avez fourni est correct et que le t\u00e9l\u00e9viseur est toujours aliment\u00e9 et connect\u00e9 au r\u00e9seau avant de soumettre \u00e0 nouveau.",
"host_exists": "H\u00f4te d\u00e9j\u00e0 configur\u00e9.",
- "name_exists": "Nom d\u00e9j\u00e0 configur\u00e9.",
- "tv_needs_token": "Lorsque le type de p\u00e9riph\u00e9rique est \" TV \", un jeton d'acc\u00e8s valide est requis."
+ "name_exists": "Nom d\u00e9j\u00e0 configur\u00e9."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "title": "Processus de couplage complet"
+ },
+ "pairing_complete": {
+ "description": "Votre appareil Vizio SmartCast est maintenant connect\u00e9 \u00e0 Home Assistant.",
+ "title": "Appairage termin\u00e9"
+ },
+ "pairing_complete_import": {
+ "title": "Appairage termin\u00e9"
+ },
"user": {
"data": {
"access_token": "Jeton d'acc\u00e8s",
@@ -24,6 +31,7 @@
"host": ":",
"name": "Nom"
},
+ "description": "Un jeton d'acc\u00e8s n'est n\u00e9cessaire que pour les t\u00e9l\u00e9viseurs. Si vous configurez un t\u00e9l\u00e9viseur et que vous n'avez pas encore de jeton d'acc\u00e8s, laissez-le vide pour passer par un processus de couplage.",
"title": "Configurer le client Vizio SmartCast"
}
},
@@ -33,9 +41,11 @@
"step": {
"init": {
"data": {
- "timeout": "D\u00e9lai d'expiration de la demande d'API (secondes)",
+ "apps_to_include_or_exclude": "Applications \u00e0 inclure ou \u00e0 exclure",
+ "include_or_exclude": "Inclure ou exclure des applications?",
"volume_step": "Taille du pas de volume"
},
+ "description": "Si vous avez une Smart TV, vous pouvez \u00e9ventuellement filtrer votre liste de sources en choisissant les applications \u00e0 inclure ou \u00e0 exclure dans votre liste de sources.",
"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
index 650d5133dbd..c8b74f33e3d 100644
--- a/homeassistant/components/vizio/.translations/hu.json
+++ b/homeassistant/components/vizio/.translations/hu.json
@@ -1,20 +1,13 @@
{
"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."
+ "updated_entry": "Ez a bejegyz\u00e9s m\u00e1r be van \u00e1ll\u00edtva, de a konfigur\u00e1ci\u00f3ban defini\u00e1lt n\u00e9v, appok \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."
},
"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."
+ "name_exists": "A megadott n\u00e9vvel rendelkez\u0151 Vizio-eszk\u00f6z m\u00e1r konfigur\u00e1lva van."
},
"step": {
"user": {
@@ -32,7 +25,6 @@
"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"
diff --git a/homeassistant/components/vizio/.translations/it.json b/homeassistant/components/vizio/.translations/it.json
index 77c905d7cf5..4a26a40ad56 100644
--- a/homeassistant/components/vizio/.translations/it.json
+++ b/homeassistant/components/vizio/.translations/it.json
@@ -1,21 +1,14 @@
{
"config": {
"abort": {
- "already_in_progress": "Il flusso di configurazione per vizio \u00e8 gi\u00e0 in corso.",
"already_setup": "Questa voce \u00e8 gi\u00e0 stata configurata.",
- "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."
+ "updated_entry": "Questa voce \u00e8 gi\u00e0 stata configurata, ma il nome, le app e/o le opzioni definite nella configurazione non corrispondono alla configurazione importata in precedenza, pertanto la voce di configurazione \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.",
"complete_pairing failed": "Impossibile completare l'associazione. Assicurarsi che il PIN fornito sia corretto e che il televisore sia ancora alimentato e connesso alla rete prima di inviarlo di nuovo.",
"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."
+ "name_exists": "Dispositivo Vizio con il nome specificato gi\u00e0 configurato."
},
"step": {
"pair_tv": {
@@ -30,7 +23,7 @@
"title": "Associazione completata"
},
"pairing_complete_import": {
- "description": "Il dispositivo Vizio SmartCast \u00e8 ora connesso a Home Assistant. \n\n Il token di accesso \u00e8 '**{access_token}**'.",
+ "description": "Il dispositivo Vizio SmartCast TV \u00e8 ora connesso a Home Assistant. \n\nIl tuo Token di Accesso \u00e8 '**{access_token}**'.",
"title": "Associazione completata"
},
"user": {
@@ -40,7 +33,7 @@
"host": "< Host / IP >: ",
"name": "Nome"
},
- "description": "Tutti i campi sono obbligatori tranne il token di accesso. Se si sceglie di non fornire un token di accesso e il tipo di dispositivo \u00e8 \"tv\", si passer\u00e0 attraverso un processo di associazione con il dispositivo in modo da poter recuperare un token di accesso. \n\n Per completare il processo di associazione, prima di fare clic su Invia, assicurarsi che il televisore sia acceso e collegato alla rete. Devi anche essere in grado di vedere lo schermo.",
+ "description": "Un Token di Accesso \u00e8 necessario solo per i televisori. Se si sta configurando un televisore e non si dispone ancora di un Token di Accesso, lasciarlo vuoto per passare attraverso un processo di associazione.",
"title": "Configurazione del dispositivo SmartCast Vizio"
}
},
@@ -50,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "Timeout richiesta API (secondi)",
+ "apps_to_include_or_exclude": "App da includere o escludere",
+ "include_or_exclude": "Includere o escludere app?",
"volume_step": "Dimensione del passo del volume"
},
+ "description": "Se si dispone di una Smart TV, \u00e8 possibile filtrare l'elenco di origine scegliendo le app da includere o escludere in esso.",
"title": "Aggiornamento delle opzioni di Vizo SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/ko.json b/homeassistant/components/vizio/.translations/ko.json
index 64c0887b3f8..df2fb243f88 100644
--- a/homeassistant/components/vizio/.translations/ko.json
+++ b/homeassistant/components/vizio/.translations/ko.json
@@ -1,22 +1,31 @@
{
"config": {
"abort": {
- "already_in_progress": "vizio \uad6c\uc131 \uc694\uc18c\uc5d0 \ub300\ud55c \uad6c\uc131 \ud50c\ub85c\uc6b0\uac00 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.",
"already_setup": "\uc774 \ud56d\ubaa9\uc740 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "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."
+ "updated_entry": "\uc774 \ud56d\ubaa9\uc740 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc9c0\ub9cc \uad6c\uc131\uc5d0 \uc815\uc758\ub41c \uc774\ub984, \uc571 \ud639\uc740 \uc635\uc158\uc774 \uc774\uc804\uc5d0 \uac00\uc838\uc628 \uad6c\uc131 \ub0b4\uc6a9\uacfc \uc77c\uce58\ud558\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uad6c\uc131 \ud56d\ubaa9\uc774 \uadf8\uc5d0 \ub530\ub77c \uc5c5\ub370\uc774\ud2b8\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
},
"error": {
"cant_connect": "\uae30\uae30\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. [\uc548\ub0b4\ub97c \ucc38\uace0] (https://www.home-assistant.io/integrations/vizio/)\ud558\uace0 \uc591\uc2dd\uc744 \ub2e4\uc2dc \uc81c\ucd9c\ud558\uae30 \uc804\uc5d0 \ub2e4\uc74c\uc744 \ub2e4\uc2dc \ud655\uc778\ud574\uc8fc\uc138\uc694.\n- \uae30\uae30 \uc804\uc6d0\uc774 \ucf1c\uc838 \uc788\uc2b5\ub2c8\uae4c?\n- \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc2b5\ub2c8\uae4c?\n- \uc785\ub825\ud55c \ub0b4\uc6a9\uc774 \uc62c\ubc14\ub985\ub2c8\uae4c?",
+ "complete_pairing failed": "\ud398\uc5b4\ub9c1\uc744 \uc644\ub8cc\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc785\ub825\ud55c PIN \uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud558\uace0 \ub2e4\uc74c \uacfc\uc815\uc744 \uc9c4\ud589\ud558\uae30 \uc804\uc5d0 TV \uc758 \uc804\uc6d0\uc774 \ucf1c\uc838 \uc788\uace0 \ub124\ud2b8\uc6cc\ud06c\uc5d0 \uc5f0\uacb0\ub418\uc5b4 \uc788\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.",
"host_exists": "\uc124\uc815\ub41c \ud638\uc2a4\ud2b8\uc758 Vizio \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "name_exists": "\uc124\uc815\ub41c \uc774\ub984\uc758 Vizio \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "tv_needs_token": "\uae30\uae30 \uc720\ud615\uc774 'tv' \uc778 \uacbd\uc6b0 \uc720\ud6a8\ud55c \uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \ud544\uc694\ud569\ub2c8\ub2e4."
+ "name_exists": "\uc124\uc815\ub41c \uc774\ub984\uc758 Vizio \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "TV \uc5d0 \ucf54\ub4dc\uac00 \ud45c\uc2dc\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ucf54\ub4dc\ub97c \uc591\uc2dd\uc5d0 \uc785\ub825\ud55c \ud6c4 \ub2e4\uc74c \ub2e8\uacc4\ub97c \uacc4\uc18d\ud558\uc5ec \ud398\uc5b4\ub9c1\uc744 \uc644\ub8cc\ud574\uc8fc\uc138\uc694.",
+ "title": "\ud398\uc5b4\ub9c1 \uacfc\uc815 \uc644\ub8cc"
+ },
+ "pairing_complete": {
+ "description": "Vizio SmartCast \uae30\uae30\uac00 Home Assistant \uc5d0 \uc5f0\uacb0\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "title": "\ud398\uc5b4\ub9c1 \uc644\ub8cc"
+ },
+ "pairing_complete_import": {
+ "description": "Vizio SmartCast TV \uac00 Home Assistant \uc5d0 \uc5f0\uacb0\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \n\n\uc561\uc138\uc2a4 \ud1a0\ud070\uc740 '**{access_token}**' \uc785\ub2c8\ub2e4.",
+ "title": "\ud398\uc5b4\ub9c1 \uc644\ub8cc"
+ },
"user": {
"data": {
"access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070",
@@ -24,6 +33,7 @@
"host": "<\ud638\uc2a4\ud2b8/ip>:",
"name": "\uc774\ub984"
},
+ "description": "\uc561\uc138\uc2a4 \ud1a0\ud070\uc740 TV \uc5d0\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4. TV \ub97c \uad6c\uc131\ud558\uace0 \uc788\uace0 \uc544\uc9c1 \uc561\uc138\uc2a4 \ud1a0\ud070\uc774 \uc5c6\ub294 \uacbd\uc6b0 \ud398\uc5b4\ub9c1 \uacfc\uc815\uc744 \uc9c4\ud589\ud558\ub824\uba74 \ube44\uc6cc\ub450\uc138\uc694.",
"title": "Vizio SmartCast \uae30\uae30 \uc124\uc815"
}
},
@@ -33,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "API \uc694\uccad \uc2dc\uac04 \ucd08\uacfc (\ucd08)",
+ "apps_to_include_or_exclude": "\ud3ec\ud568 \ub610\ub294 \uc81c\uc678 \ud560 \uc571",
+ "include_or_exclude": "\uc571\uc744 \ud3ec\ud568 \ub610\ub294 \uc81c\uc678\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
"volume_step": "\ubcfc\ub968 \ub2e8\uacc4 \ud06c\uae30"
},
+ "description": "\uc2a4\ub9c8\ud2b8 TV \uac00 \uc788\ub294 \uacbd\uc6b0 \uc120\ud0dd\uc0ac\ud56d\uc73c\ub85c \uc18c\uc2a4 \ubaa9\ub85d\uc5d0 \ud3ec\ud568 \ub610\ub294 \uc81c\uc678\ud560 \uc571\uc744 \uc120\ud0dd\ud558\uc5ec \uc18c\uc2a4 \ubaa9\ub85d\uc744 \ud544\ud130\ub9c1\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
"title": "Vizo SmartCast \uc635\uc158 \uc5c5\ub370\uc774\ud2b8"
}
},
diff --git a/homeassistant/components/vizio/.translations/lb.json b/homeassistant/components/vizio/.translations/lb.json
index 11df333ce4b..3146c8756a8 100644
--- a/homeassistant/components/vizio/.translations/lb.json
+++ b/homeassistant/components/vizio/.translations/lb.json
@@ -1,21 +1,14 @@
{
"config": {
"abort": {
- "already_in_progress": "Konfiguratioun's Oflaf fir Vizio Komponent ass schonn am gaangen.",
"already_setup": "D\u00ebs Entr\u00e9e ass scho konfigur\u00e9iert.",
- "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."
+ "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."
},
"error": {
"cant_connect": "Konnt sech net mam Apparat verbannen. [Iwwerpr\u00e9ift Dokumentatioun] (https://www.home-assistant.io/integrations/vizio/) a stellt s\u00e9cher dass:\n- Den Apparat ass un\n- Den Apparat ass mam Netzwierk verbonnen\n- D'Optiounen d\u00e9i dir aginn hutt si korrekt\nier dir d'Verbindung nees prob\u00e9iert",
"complete_pairing failed": "Feeler beim ofschl\u00e9isse vun der Kopplung. Iwwerpr\u00e9if dass de PIN korrekt an da de Fernsee nach \u00ebmmer ugeschalt a mam Netzwierk verbonnen ass ier de n\u00e4chste Versuch gestart g\u00ebtt.",
"host_exists": "Vizio Apparat mat d\u00ebsem Host ass scho konfigur\u00e9iert.",
- "name_exists": "Vizio Apparat mat d\u00ebsen Numm ass scho konfigur\u00e9iert.",
- "tv_needs_token": "Wann den Typ vum Apparat `tv`ass da g\u00ebtt ee g\u00ebltegen Acc\u00e8s Jeton ben\u00e9idegt."
+ "name_exists": "Vizio Apparat mat d\u00ebsen Numm ass scho konfigur\u00e9iert."
},
"step": {
"pair_tv": {
@@ -40,8 +33,8 @@
"host": ":",
"name": "Numm"
},
- "description": "All Felder sinn noutwendeg ausser Acc\u00e8s Jeton. Wann keen Acc\u00e8s Jeton uginn ass, an den Typ vun Apparat ass 'TV', da g\u00ebtt e Kopplungs Prozess mam Apparat gestart fir een Acc\u00e8s Jeton z'erstellen.\n\nFir de Kopplung Prozess ofzesch\u00e9issen,ier op \"ofsch\u00e9cken\" klickt, pr\u00e9ift datt de Fernsee ugeschalt a mam Netzwierk verbonnen ass. Du muss och k\u00ebnnen op de Bildschierm gesinn.",
- "title": "Vizo Smartcast ariichten"
+ "description": "Een Access Jeton g\u00ebtt nn\u00ebmme fir Fernseher gebraucht. Wann Dir e Fernseh konfigur\u00e9iert a keen Access Jeton hutt, da loosst et eidel fir duerch dee Pairing Prozess ze goen.",
+ "title": "Vizo Smartcast Apparat ariichten"
}
},
"title": "Vizio SmartCast"
@@ -50,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "Z\u00e4itiwwerscheidung bei der Ufro vun der API (sekonnen)",
+ "apps_to_include_or_exclude": "Apps fir mat abegr\u00e4ifen oder auszeschl\u00e9issen",
+ "include_or_exclude": "Apps mat abez\u00e9ien oder auschl\u00e9issen?",
"volume_step": "Lautst\u00e4erkt Schr\u00ebtt Gr\u00e9isst"
},
+ "description": "Falls du ee Smart TV hues kanns du d'Quelle L\u00ebscht optionell filteren andeems du d'Apps auswiels d\u00e9i soll mat abegraff oder ausgeschloss ginn.",
"title": "Vizo Smartcast Optiounen aktualis\u00e9ieren"
}
},
diff --git a/homeassistant/components/vizio/.translations/nl.json b/homeassistant/components/vizio/.translations/nl.json
index bbc95d73bbc..797836e0145 100644
--- a/homeassistant/components/vizio/.translations/nl.json
+++ b/homeassistant/components/vizio/.translations/nl.json
@@ -1,20 +1,13 @@
{
"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."
+ "updated_entry": "Dit item is al ingesteld, maar de naam en/of opties die zijn gedefinieerd in de configuratie komen niet overeen met de eerder ge\u00efmporteerde configuratie, dus het configuratie-item is dienovereenkomstig bijgewerkt."
},
"error": {
"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."
+ "name_exists": "Vizio apparaat met opgegeven naam al geconfigureerd."
},
"step": {
"user": {
@@ -33,7 +26,6 @@
"step": {
"init": {
"data": {
- "timeout": "Time-out van API-aanvragen (seconden)",
"volume_step": "Volume Stapgrootte"
},
"title": "Update Vizo SmartCast Opties"
diff --git a/homeassistant/components/vizio/.translations/no.json b/homeassistant/components/vizio/.translations/no.json
index dababdd53f2..65e96945e46 100644
--- a/homeassistant/components/vizio/.translations/no.json
+++ b/homeassistant/components/vizio/.translations/no.json
@@ -1,21 +1,14 @@
{
"config": {
"abort": {
- "already_in_progress": "Konfigurasjons flyt for Vizio komponent er allerede i gang.",
"already_setup": "Denne oppf\u00f8ringen er allerede konfigurert.",
- "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."
+ "updated_entry": "Dette innlegget har allerede v\u00e6rt oppsett, men navnet, apps, og/eller alternativer som er definert i konfigurasjon som ikke stemmer med det som tidligere er importert konfigurasjon, s\u00e5 konfigurasjonen innlegget har blitt oppdatert i henhold til dette."
},
"error": {
"cant_connect": "Kunne ikke koble til enheten. [Se gjennom dokumentene] (https://www.home-assistant.io/integrations/vizio/) og bekreft at: \n - Enheten er sl\u00e5tt p\u00e5 \n - Enheten er koblet til nettverket \n - Verdiene du fylte ut er n\u00f8yaktige \n f\u00f8r du pr\u00f8ver \u00e5 sende inn p\u00e5 nytt.",
"complete_pairing failed": "Kan ikke fullf\u00f8re sammenkoblingen. Forsikre deg om at PIN-koden du oppga er riktig, og at TV-en fortsatt er p\u00e5 og tilkoblet nettverket f\u00f8r du sender inn p\u00e5 nytt.",
"host_exists": "Vizio-enhet med spesifisert vert allerede konfigurert.",
- "name_exists": "Vizio-enhet med spesifisert navn allerede konfigurert.",
- "tv_needs_token": "N\u00e5r enhetstype er `tv`, er det n\u00f8dvendig med en gyldig tilgangstoken."
+ "name_exists": "Vizio-enhet med spesifisert navn allerede konfigurert."
},
"step": {
"pair_tv": {
@@ -30,7 +23,7 @@
"title": "Sammenkoblingen Er Fullf\u00f8rt"
},
"pairing_complete_import": {
- "description": "Vizio SmartCast-enheten er n\u00e5 koblet til Home Assistant.\n\nTilgangstokenet er **{access_token}**.",
+ "description": "Din Vizio SmartCast TV er n\u00e5 koblet til Home Assistant.\n\nTilgangstokenet er **{access_token}**.",
"title": "Sammenkoblingen Er Fullf\u00f8rt"
},
"user": {
@@ -40,19 +33,21 @@
"host": ":",
"name": "Navn"
},
- "description": "Alle felt er obligatoriske unntatt Access Token. Hvis du velger \u00e5 ikke oppgi et Access-token, og enhetstypen din er \u00abtv\u00bb, g\u00e5r du gjennom en sammenkoblingsprosess med enheten slik at et Tilgangstoken kan hentes.\n\nHvis du vil g\u00e5 gjennom paringsprosessen, m\u00e5 du kontrollere at TV-en er sl\u00e5tt p\u00e5 og koblet til nettverket f\u00f8r du klikker p\u00e5 Send. Du m\u00e5 ogs\u00e5 kunne se skjermen.",
+ "description": "En tilgangstoken er bare n\u00f8dvendig for TV-er. Hvis du konfigurerer en TV og ikke har tilgangstoken enn\u00e5, m\u00e5 du la den st\u00e5 tom for \u00e5 g\u00e5 gjennom en sammenkoblingsprosess.",
"title": "Sett opp Vizio SmartCast-enhet"
}
},
- "title": "Vizio SmartCast"
+ "title": ""
},
"options": {
"step": {
"init": {
"data": {
- "timeout": "Tidsavbrudd for API-foresp\u00f8rsel (sekunder)",
+ "apps_to_include_or_exclude": "Apper \u00e5 inkludere eller ekskludere",
+ "include_or_exclude": "Inkluder eller ekskludere apper?",
"volume_step": "St\u00f8rrelse p\u00e5 volum trinn"
},
+ "description": "Hvis du har en Smart-TV, kan du eventuelt filtrere kildelisten ved \u00e5 velge hvilke apper som skal inkluderes eller utelates i kildelisten.",
"title": "Oppdater Vizo SmartCast alternativer"
}
},
diff --git a/homeassistant/components/vizio/.translations/pl.json b/homeassistant/components/vizio/.translations/pl.json
index cba9f4319f5..2537279d998 100644
--- a/homeassistant/components/vizio/.translations/pl.json
+++ b/homeassistant/components/vizio/.translations/pl.json
@@ -1,22 +1,26 @@
{
"config": {
"abort": {
- "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."
+ "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."
},
"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 - 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` potrzebny jest prawid\u0142owy token dost\u0119pu."
+ "name_exists": "Urz\u0105dzenie Vizio o okre\u015blonej nazwie jest ju\u017c skonfigurowane."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ }
+ },
+ "pairing_complete": {
+ "title": "Parowanie zako\u0144czone"
+ },
+ "pairing_complete_import": {
+ "title": "Parowanie zako\u0144czone"
+ },
"user": {
"data": {
"access_token": "Token dost\u0119pu",
@@ -33,9 +37,11 @@
"step": {
"init": {
"data": {
- "timeout": "Limit czasu \u017c\u0105dania API (sekundy)",
+ "apps_to_include_or_exclude": "Aplikacje do do\u0142\u0105czenia lub wykluczenia",
+ "include_or_exclude": "Do\u0142\u0105czanie lub wykluczanie aplikacji",
"volume_step": "Skok g\u0142o\u015bno\u015bci"
},
+ "description": "Je\u015bli telewizor obs\u0142uguje aplikacje, mo\u017cesz opcjonalnie filtrowa\u0107 aplikacje, kt\u00f3re maj\u0105 zosta\u0107 uwzgl\u0119dnione lub wykluczone z listy \u017ar\u00f3de\u0142. Mo\u017cesz pomin\u0105\u0107 ten krok dla telewizor\u00f3w, kt\u00f3re nie obs\u0142uguj\u0105 aplikacji.",
"title": "Aktualizacja opcji Vizo SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/ru.json b/homeassistant/components/vizio/.translations/ru.json
index 3e14dd3d750..e1e6ac73b9d 100644
--- a/homeassistant/components/vizio/.translations/ru.json
+++ b/homeassistant/components/vizio/.translations/ru.json
@@ -1,21 +1,14 @@
{
"config": {
"abort": {
- "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.",
"already_setup": "\u042d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0431\u044b\u043b\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.",
- "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."
+ "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."
},
"error": {
"cant_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. \u041f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c \u043f\u043e\u043f\u044b\u0442\u043a\u0443, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c \u0432 \u0442\u043e\u043c, \u0447\u0442\u043e:\n- \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e;\n- \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a \u0441\u0435\u0442\u0438;\n- \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u0432\u0432\u0435\u043b\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f.\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/integrations/vizio/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438.",
"complete_pairing failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c \u043f\u043e\u043f\u044b\u0442\u043a\u0443, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0432\u0432\u0435\u0434\u0435\u043d\u043d\u044b\u0439 \u0412\u0430\u043c\u0438 PIN-\u043a\u043e\u0434 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439, \u0430 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d \u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a \u0441\u0435\u0442\u0438.",
"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.",
- "tv_needs_token": "\u0414\u043b\u044f \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f `tv` \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\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."
},
"step": {
"pair_tv": {
@@ -40,7 +33,7 @@
"host": "<\u0425\u043e\u0441\u0442/IP>:<\u041f\u043e\u0440\u0442>",
"name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435"
},
- "description": "\u0412\u0441\u0435 \u043f\u043e\u043b\u044f \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b \u0434\u043b\u044f \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f, \u043a\u0440\u043e\u043c\u0435 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u0440\u0435\u0448\u0438\u0442\u0435 \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0442\u044c \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430, \u0430 \u0442\u0438\u043f \u0432\u0430\u0448\u0435\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 - 'tv', \u0412\u044b \u043f\u0440\u043e\u0439\u0434\u0435\u0442\u0435 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u0441 \u0432\u0430\u0448\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c, \u0447\u0442\u043e\u0431\u044b \u043c\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430. \n\n\u0427\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0439\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c '\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c', \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d \u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a \u0441\u0435\u0442\u0438. \u0412\u044b \u0442\u0430\u043a\u0436\u0435 \u0434\u043e\u043b\u0436\u043d\u044b \u0438\u043c\u0435\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u044d\u043a\u0440\u0430\u043d\u0443 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430.",
+ "description": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u043e\u0432. \u0415\u0441\u043b\u0438 \u0412\u044b \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u0442\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0438 \u0443 \u0412\u0430\u0441 \u0435\u0449\u0435 \u043d\u0435\u0442 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430, \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u044d\u0442\u043e \u043f\u043e\u043b\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0447\u0442\u043e\u0431\u044b \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f.",
"title": "Vizio SmartCast"
}
},
@@ -50,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 \u0437\u0430\u043f\u0440\u043e\u0441\u0430 API (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)",
+ "apps_to_include_or_exclude": "\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439",
+ "include_or_exclude": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0438\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f?",
"volume_step": "\u0428\u0430\u0433 \u0433\u0440\u043e\u043c\u043a\u043e\u0441\u0442\u0438"
},
+ "description": "\u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0435\u0441\u0442\u044c Smart TV, \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u0440\u0438 \u0436\u0435\u043b\u0430\u043d\u0438\u0438 \u043e\u0442\u0444\u0438\u043b\u044c\u0442\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u043f\u0438\u0441\u043e\u043a \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432, \u0432\u043a\u043b\u044e\u0447\u0438\u0432 \u0438\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0438\u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430.",
"title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Vizio SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/sk.json b/homeassistant/components/vizio/.translations/sk.json
new file mode 100644
index 00000000000..e0c0076ddc2
--- /dev/null
+++ b/homeassistant/components/vizio/.translations/sk.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "step": {
+ "tv_apps": {
+ "data": {
+ "apps_to_include_or_exclude": "Aplik\u00e1cie, ktor\u00e9 chcete zahrn\u00fa\u0165 alebo vyl\u00fa\u010di\u0165",
+ "include_or_exclude": "Zahrn\u00fa\u0165 alebo vyl\u00fa\u010di\u0165 aplik\u00e1cie?"
+ },
+ "description": "Ak m\u00e1te Smart TV, m\u00f4\u017eete volite\u013ene filtrova\u0165 svoj zoznam zdrojov v\u00fdberom aplik\u00e1ci\u00ed, ktor\u00e9 chcete zahrn\u00fa\u0165 alebo vyl\u00fa\u010di\u0165 do zoznamu zdrojov. Tento krok m\u00f4\u017eete presko\u010di\u0165 pre telev\u00edzory, ktor\u00e9 nepodporuj\u00fa aplik\u00e1cie.",
+ "title": "Konfigur\u00e1cia aplik\u00e1ci\u00ed pre Smart TV"
+ },
+ "user_tv": {
+ "data": {
+ "apps_to_include_or_exclude": "Aplik\u00e1cie, ktor\u00e9 chcete zahrn\u00fa\u0165 alebo vyl\u00fa\u010di\u0165",
+ "include_or_exclude": "Zahrn\u00fa\u0165 alebo vyl\u00fa\u010di\u0165 aplik\u00e1cie?"
+ },
+ "description": "Ak m\u00e1te Smart TV, m\u00f4\u017eete volite\u013ene filtrova\u0165 svoj zoznam zdrojov v\u00fdberom aplik\u00e1ci\u00ed, ktor\u00e9 chcete zahrn\u00fa\u0165 alebo vyl\u00fa\u010di\u0165 do zoznamu zdrojov. Tento krok m\u00f4\u017eete presko\u010di\u0165 pre telev\u00edzory, ktor\u00e9 nepodporuj\u00fa aplik\u00e1cie.",
+ "title": "Konfigur\u00e1cia aplik\u00e1ci\u00ed pre Smart TV"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/vizio/.translations/sl.json b/homeassistant/components/vizio/.translations/sl.json
index 55faaaf26a8..ed325acd868 100644
--- a/homeassistant/components/vizio/.translations/sl.json
+++ b/homeassistant/components/vizio/.translations/sl.json
@@ -1,22 +1,31 @@
{
"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."
+ "updated_entry": "Ta vnos je bil \u017ee nastavljen, vendar se ime, aplikacije in/ali mo\u017enosti, dolo\u010dene v konfiguraciji, ne ujemajo s predhodno uvo\u017eeno konfiguracijo, zato je bil konfiguracijski vnos 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.",
+ "complete_pairing failed": "Seznanjanja ni mogo\u010de dokon\u010dati. Zagotovite, da je PIN, ki ste ga vnesli, pravilen in da je televizor \u0161e vedno vklopljen in priklju\u010den na omre\u017eje, preden ponovno poizkusite.",
"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."
+ "name_exists": "Naprava Vizio z navedenim imenom je \u017ee konfigurirana."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "Va\u0161 TV naj bi prikazoval kodo. Vnesite to kodo v obrazec in nato nadaljujte z naslednjim korakom za dokon\u010danje zdru\u017eevanja.",
+ "title": "Dokon\u010dajte proces zdru\u017eevanja"
+ },
+ "pairing_complete": {
+ "description": "Va\u0161a naprava Vizio SmartCast je zdaj povezana s Home Assistant-om.",
+ "title": "Seznanjanje je kon\u010dano"
+ },
+ "pairing_complete_import": {
+ "description": "Va\u0161 VIZIO SmartCast TV je zdaj priklju\u010den na Home Assistant.\n\n\u017deton za dostop je '**{access_token}**'.",
+ "title": "Seznanjanje je kon\u010dano"
+ },
"user": {
"data": {
"access_token": "\u017deton za dostop",
@@ -24,7 +33,8 @@
"host": ":",
"name": "Ime"
},
- "title": "Nastavite odjemalec Vizio SmartCast"
+ "description": "Dostopni \u017eeton je potreben samo za televizorje. \u010ce konfigurirate televizor in \u0161e nimate \u017eetona za dostop, ga pustite prazno in boste \u0161li, da bo \u0161el skozi postopek seznanjanja.",
+ "title": "Namestite Vizio SmartCast napravo"
}
},
"title": "Vizio SmartCast"
@@ -33,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "\u010casovna omejitev zahteve za API (sekunde)",
+ "apps_to_include_or_exclude": "Aplikacije za vklju\u010ditev ali izklju\u010ditev",
+ "include_or_exclude": "Vklju\u010di ali Izklju\u010di Aplikacije?",
"volume_step": "Velikost koraka glasnosti"
},
+ "description": "\u010ce imate pametni TV, lahko po izbiri filtrirate seznam virov tako, da izberete, katere aplikacije \u017eelite vklju\u010diti ali izklju\u010diti na seznamu virov.",
"title": "Posodobite mo\u017enosti Vizo SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/sv.json b/homeassistant/components/vizio/.translations/sv.json
index 072b441a071..bafd7d1bd2f 100644
--- a/homeassistant/components/vizio/.translations/sv.json
+++ b/homeassistant/components/vizio/.translations/sv.json
@@ -1,20 +1,13 @@
{
"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."
+ "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."
},
"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."
+ "name_exists": "Vizio-enheten med angivet namn \u00e4r redan konfigurerad."
},
"step": {
"user": {
@@ -33,7 +26,6 @@
"step": {
"init": {
"data": {
- "timeout": "Timeout f\u00f6r API-anrop (sekunder)",
"volume_step": "Storlek p\u00e5 volymsteg"
},
"title": "Uppdatera Vizo SmartCast-alternativ"
diff --git a/homeassistant/components/vizio/.translations/zh-Hant.json b/homeassistant/components/vizio/.translations/zh-Hant.json
index d2404a80620..eb396428e68 100644
--- a/homeassistant/components/vizio/.translations/zh-Hant.json
+++ b/homeassistant/components/vizio/.translations/zh-Hant.json
@@ -1,21 +1,14 @@
{
"config": {
"abort": {
- "already_in_progress": "Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002",
"already_setup": "\u6b64\u7269\u4ef6\u5df2\u8a2d\u5b9a\u904e\u3002",
- "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"
+ "updated_entry": "\u6b64\u7269\u4ef6\u5df2\u7d93\u8a2d\u5b9a\uff0c\u4f46\u8a2d\u5b9a\u4e4b\u540d\u7a31\u3001App \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"
},
"error": {
"cant_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u8a2d\u5099\u3002[\u8acb\u53c3\u8003\u8aaa\u660e\u6587\u4ef6](https://www.home-assistant.io/integrations/vizio/) \u4e26\u78ba\u8a8d\u4ee5\u4e0b\u9805\u76ee\uff1a\n- \u8a2d\u5099\u5df2\u958b\u6a5f\n- \u8a2d\u5099\u5df2\u9023\u7dda\u81f3\u7db2\u8def\n- \u586b\u5beb\u8cc7\u6599\u6b63\u78ba\n\u7136\u5f8c\u518d\u91cd\u65b0\u50b3\u9001\u3002",
"complete_pairing failed": "\u7121\u6cd5\u5b8c\u6210\u914d\u5c0d\uff0c\u50b3\u9001\u524d\u3001\u8acb\u78ba\u5b9a\u6240\u8f38\u5165\u7684 PIN \u78bc\u3001\u540c\u6642\u96fb\u8996\u5df2\u7d93\u958b\u555f\u4e26\u9023\u7dda\u81f3\u7db2\u8def\u3002",
"host_exists": "\u4f9d\u4e3b\u6a5f\u7aef\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002",
- "name_exists": "\u4f9d\u540d\u7a31\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002",
- "tv_needs_token": "\u7576\u8a2d\u5099\u985e\u5225\u70ba\u300cTV\u300d\u6642\uff0c\u9700\u8981\u5b58\u53d6\u5bc6\u9470\u3002"
+ "name_exists": "\u4f9d\u540d\u7a31\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002"
},
"step": {
"pair_tv": {
@@ -30,7 +23,7 @@
"title": "\u914d\u5c0d\u5b8c\u6210"
},
"pairing_complete_import": {
- "description": "Vizio SmartCast \u8a2d\u5099\u5df2\u9023\u7dda\u81f3 Home Assistant\u3002\n\n\u5b58\u53d6\u5bc6\u9470\u70ba\u300c**{access_token}**\u300d\u3002",
+ "description": "Vizio SmartCast TV \u8a2d\u5099\u5df2\u9023\u7dda\u81f3 Home Assistant\u3002\n\n\u5b58\u53d6\u5bc6\u9470\u70ba\u300c**{access_token}**\u300d\u3002",
"title": "\u914d\u5c0d\u5b8c\u6210"
},
"user": {
@@ -40,7 +33,7 @@
"host": "<\u4e3b\u6a5f\u7aef/IP>:",
"name": "\u540d\u7a31"
},
- "description": "\u9664\u4e86\u5b58\u53d6\u5bc6\u9470\u5916\u3001\u8207\u8a2d\u5099\u985e\u5225\u70ba\u300cTV\u300d\u5916\u3001\u6240\u6709\u6b04\u4f4d\u90fd\u70ba\u5fc5\u586b\u3002\u5c07\u6703\u4ee5\u8a2d\u5099\u9032\u884c\u914d\u5c0d\u904e\u7a0b\uff0c\u56e0\u6b64\u5b58\u53d6\u5bc6\u9470\u53ef\u4ee5\u6536\u56de\u3002\n\n\u6b32\u5b8c\u6210\u914d\u5c0d\u904e\u7a0b\uff0c\u50b3\u9001\u524d\u3001\u8acb\u5148\u78ba\u5b9a\u96fb\u8996\u5df2\u7d93\u958b\u6a5f\u3001\u4e26\u9023\u7dda\u81f3\u7db2\u8def\u3002\u540c\u6642\u3001\u4f60\u4e5f\u5fc5\u9808\u80fd\u770b\u5230\u96fb\u8996\u756b\u9762\u3002",
+ "description": "\u6b64\u96fb\u8996\u50c5\u9700\u5b58\u53d6\u5bc6\u9470\u3002\u5047\u5982\u60a8\u6b63\u5728\u8a2d\u5b9a\u96fb\u8996\u3001\u5c1a\u672a\u53d6\u5f97\u5bc6\u9470\uff0c\u4fdd\u6301\u7a7a\u767d\u4ee5\u9032\u884c\u914d\u5c0d\u904e\u7a0b\u3002",
"title": "\u8a2d\u5b9a Vizio SmartCast \u8a2d\u5099"
}
},
@@ -50,9 +43,11 @@
"step": {
"init": {
"data": {
- "timeout": "API \u8acb\u6c42\u903e\u6642\uff08\u79d2\uff09",
+ "apps_to_include_or_exclude": "\u6240\u8981\u5305\u542b\u6216\u6392\u9664\u7684 App",
+ "include_or_exclude": "\u5305\u542b\u6216\u6392\u9664 App\uff1f",
"volume_step": "\u97f3\u91cf\u5927\u5c0f"
},
+ "description": "\u5047\u5982\u60a8\u64c1\u6709 Smart TV\u3001\u53ef\u7531\u4f86\u6e90\u5217\u8868\u4e2d\u9078\u64c7\u6240\u8981\u904e\u6ffe\u5305\u542b\u6216\u6392\u9664\u7684 App\u3002\u3002",
"title": "\u66f4\u65b0 Vizo SmartCast \u9078\u9805"
}
},
diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py
index 1eb58c96670..ba3ac5107bb 100644
--- a/homeassistant/components/vizio/config_flow.py
+++ b/homeassistant/components/vizio/config_flow.py
@@ -1,7 +1,7 @@
"""Config flow for Vizio."""
import copy
import logging
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from pyvizio import VizioAsync, async_guess_device_type
import voluptuous as vol
@@ -23,6 +23,7 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import DiscoveryInfoType
from .const import (
CONF_APPS,
@@ -43,7 +44,8 @@ def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
"""
Return schema defaults for init step based on user input/config dict.
- Retain info already provided for future form views by setting them as defaults in schema.
+ Retain info already provided for future form views by setting them
+ as defaults in schema.
"""
if input_dict is None:
input_dict = {}
@@ -70,7 +72,8 @@ def _get_pairing_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
"""
Return schema defaults for pairing data based on user input.
- Retain info already provided for future form views by setting them as defaults in schema.
+ Retain info already provided for future form views by setting
+ them as defaults in schema.
"""
if input_dict is None:
input_dict = {}
@@ -97,6 +100,16 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow):
) -> Dict[str, Any]:
"""Manage the vizio options."""
if user_input is not None:
+ if user_input.get(CONF_APPS_TO_INCLUDE_OR_EXCLUDE):
+ user_input[CONF_APPS] = {
+ user_input[CONF_INCLUDE_OR_EXCLUDE]: user_input[
+ CONF_APPS_TO_INCLUDE_OR_EXCLUDE
+ ].copy()
+ }
+
+ user_input.pop(CONF_INCLUDE_OR_EXCLUDE)
+ user_input.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE)
+
return self.async_create_entry(title="", data=user_input)
options = {
@@ -108,6 +121,30 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow):
): vol.All(vol.Coerce(int), vol.Range(min=1, max=10))
}
+ if self.config_entry.data[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV:
+ default_include_or_exclude = (
+ CONF_EXCLUDE
+ if self.config_entry.options
+ and CONF_EXCLUDE in self.config_entry.options.get(CONF_APPS)
+ else CONF_EXCLUDE
+ )
+ options.update(
+ {
+ vol.Optional(
+ CONF_INCLUDE_OR_EXCLUDE,
+ default=default_include_or_exclude.title(),
+ ): vol.All(
+ vol.In([CONF_INCLUDE.title(), CONF_EXCLUDE.title()]), vol.Lower
+ ),
+ vol.Optional(
+ CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
+ default=self.config_entry.options.get(CONF_APPS, {}).get(
+ default_include_or_exclude, []
+ ),
+ ): cv.multi_select(VizioAsync.get_apps_list()),
+ }
+ )
+
return self.async_show_form(step_id="init", data_schema=vol.Schema(options))
@@ -135,7 +172,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _create_entry_if_unique(
self, input_dict: Dict[str, Any]
) -> Dict[str, Any]:
- """Check if unique_id doesn't already exist. If it does, abort. If it doesn't, create entry."""
+ """
+ Create entry if ID is unique.
+
+ If it is, create entry. If it isn't, abort config flow.
+ """
# Remove extra keys that will not be used by entry setup
input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None)
input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None)
@@ -195,13 +236,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured()
- # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
- if (
- user_input[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
- and self.context["source"] != SOURCE_IMPORT
- ):
- self._data = copy.deepcopy(user_input)
- return await self.async_step_tv_apps()
return await self._create_entry_if_unique(user_input)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
elif self._must_show_form and self.context["source"] == SOURCE_IMPORT:
@@ -250,7 +284,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not import_config.get(CONF_APPS):
remove_apps = True
else:
- updated_data[CONF_APPS] = import_config[CONF_APPS]
+ updated_options[CONF_APPS] = import_config[CONF_APPS]
if entry.data.get(CONF_VOLUME_STEP) != import_config[CONF_VOLUME_STEP]:
updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP]
@@ -261,6 +295,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if remove_apps:
new_data.pop(CONF_APPS)
+ new_options.pop(CONF_APPS)
if updated_data:
new_data.update(updated_data)
@@ -284,7 +319,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_user(user_input=import_config)
async def async_step_zeroconf(
- self, discovery_info: Dict[str, Any] = None
+ self, discovery_info: Optional[DiscoveryInfoType] = None
) -> Dict[str, Any]:
"""Handle zeroconf discovery."""
@@ -319,7 +354,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_pair_tv(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
- """Start pairing process and ask user for PIN to complete pairing process."""
+ """
+ Start pairing process for TV.
+
+ Ask user for PIN to complete pairing process.
+ """
errors = {}
# Start pairing process if it hasn't already started
@@ -382,7 +421,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# If user is pairing via config import, show different message
return await self.async_step_pairing_complete_import()
- return await self.async_step_tv_apps()
+ return await self.async_step_pairing_complete()
# If no data was retrieved, it's assumed that the pairing attempt was not
# successful
@@ -394,43 +433,35 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def async_step_pairing_complete_import(
- self, user_input: Dict[str, Any] = None
- ) -> Dict[str, Any]:
- """Complete import config flow by displaying final message to show user access token and give further instructions."""
+ async def _pairing_complete(self, step_id: str) -> Dict[str, Any]:
+ """Handle config flow completion."""
if not self._must_show_form:
return await self._create_entry_if_unique(self._data)
self._must_show_form = False
return self.async_show_form(
- step_id="pairing_complete_import",
+ step_id=step_id,
data_schema=vol.Schema({}),
description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]},
)
- async def async_step_tv_apps(
+ async def async_step_pairing_complete(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
- """Handle app configuration to complete TV configuration."""
- if user_input is not None:
- if user_input.get(CONF_APPS_TO_INCLUDE_OR_EXCLUDE):
- # Update stored apps with user entry config keys
- self._apps[user_input[CONF_INCLUDE_OR_EXCLUDE].lower()] = user_input[
- CONF_APPS_TO_INCLUDE_OR_EXCLUDE
- ].copy()
+ """
+ Complete non-import sourced config flow.
- return await self._create_entry_if_unique(self._data)
+ Display final message to user confirming pairing.
+ """
+ return await self._pairing_complete("pairing_complete")
- return self.async_show_form(
- step_id="tv_apps",
- data_schema=vol.Schema(
- {
- vol.Optional(
- CONF_INCLUDE_OR_EXCLUDE, default=CONF_INCLUDE.title(),
- ): vol.In([CONF_INCLUDE.title(), CONF_EXCLUDE.title()]),
- vol.Optional(CONF_APPS_TO_INCLUDE_OR_EXCLUDE): cv.multi_select(
- VizioAsync.get_apps_list()
- ),
- }
- ),
- )
+ async def async_step_pairing_complete_import(
+ self, user_input: Dict[str, Any] = None
+ ) -> Dict[str, Any]:
+ """
+ Complete import sourced config flow.
+
+ Display final message to user confirming pairing and displaying
+ access token.
+ """
+ return await self._pairing_complete("pairing_complete_import")
diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json
index f1931f6fdb1..885cfacca41 100644
--- a/homeassistant/components/vizio/manifest.json
+++ b/homeassistant/components/vizio/manifest.json
@@ -1,8 +1,8 @@
{
"domain": "vizio",
- "name": "Vizio SmartCast",
+ "name": "VIZIO SmartCast",
"documentation": "https://www.home-assistant.io/integrations/vizio",
- "requirements": ["pyvizio==0.1.35"],
+ "requirements": ["pyvizio==0.1.44"],
"dependencies": [],
"codeowners": ["@raman325"],
"config_flow": true,
diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py
index d013f41403a..d463ebca36a 100644
--- a/homeassistant/components/vizio/media_player.py
+++ b/homeassistant/components/vizio/media_player.py
@@ -4,8 +4,8 @@ import logging
from typing import Any, Callable, Dict, List, Optional
from pyvizio import VizioAsync
-from pyvizio.const import INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
-from pyvizio.helpers import find_app_name
+from pyvizio.api.apps import find_app_name
+from pyvizio.const import APP_HOME, APPS, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
from homeassistant.components.media_player import (
DEVICE_CLASS_SPEAKER,
@@ -60,7 +60,6 @@ async def async_setup_entry(
token = config_entry.data.get(CONF_ACCESS_TOKEN)
name = config_entry.data[CONF_NAME]
device_class = config_entry.data[CONF_DEVICE_CLASS]
- conf_apps = config_entry.data.get(CONF_APPS, {})
# If config entry options not set up, set them up, otherwise assign values managed in options
volume_step = config_entry.options.get(
@@ -70,6 +69,20 @@ async def async_setup_entry(
params = {}
if not config_entry.options:
params["options"] = {CONF_VOLUME_STEP: volume_step}
+ include_or_exclude_key = next(
+ (
+ key
+ for key in config_entry.data.get(CONF_APPS, {})
+ if key in [CONF_INCLUDE, CONF_EXCLUDE]
+ ),
+ None,
+ )
+ if include_or_exclude_key:
+ params["options"][CONF_APPS] = {
+ include_or_exclude_key: config_entry.data[CONF_APPS][
+ include_or_exclude_key
+ ].copy()
+ }
if not config_entry.data.get(CONF_VOLUME_STEP):
new_data = config_entry.data.copy()
@@ -93,9 +106,7 @@ async def async_setup_entry(
_LOGGER.warning("Failed to connect to %s", host)
raise PlatformNotReady
- entity = VizioDevice(
- config_entry, device, name, volume_step, device_class, conf_apps,
- )
+ entity = VizioDevice(config_entry, device, name, device_class,)
async_add_entities([entity], update_before_add=True)
@@ -108,9 +119,7 @@ class VizioDevice(MediaPlayerDevice):
config_entry: ConfigEntry,
device: VizioAsync,
name: str,
- volume_step: int,
device_class: str,
- conf_apps: Dict[str, List[Any]],
) -> None:
"""Initialize Vizio device."""
self._config_entry = config_entry
@@ -119,14 +128,17 @@ class VizioDevice(MediaPlayerDevice):
self._name = name
self._state = None
self._volume_level = None
- self._volume_step = volume_step
+ self._volume_step = config_entry.options[CONF_VOLUME_STEP]
self._is_muted = None
self._current_input = None
self._current_app = None
+ self._current_app_config = None
self._available_inputs = []
self._available_apps = []
- self._conf_apps = conf_apps
- self._additional_app_configs = self._conf_apps.get(CONF_ADDITIONAL_CONFIGS, [])
+ self._conf_apps = config_entry.options.get(CONF_APPS, {})
+ self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
+ CONF_ADDITIONAL_CONFIGS, []
+ )
self._device_class = device_class
self._supported_commands = SUPPORTED_COMMANDS[device_class]
self._device = device
@@ -146,20 +158,6 @@ class VizioDevice(MediaPlayerDevice):
return apps
- async def _current_app_name(self) -> Optional[str]:
- """Return name of the currently running app by parsing pyvizio output."""
- app = await self._device.get_current_app(log_api_exception=False)
- if app in [None, NO_APP_RUNNING]:
- return None
-
- if app == UNKNOWN_APP and self._additional_app_configs:
- return find_app_name(
- await self._device.get_current_app_config(log_api_exception=False),
- self._additional_app_configs,
- )
-
- return app
-
async def async_update(self) -> None:
"""Retrieve latest state of the device."""
if not self._model:
@@ -191,6 +189,7 @@ class VizioDevice(MediaPlayerDevice):
self._current_input = None
self._available_inputs = None
self._current_app = None
+ self._current_app_config = None
self._available_apps = None
return
@@ -226,9 +225,16 @@ class VizioDevice(MediaPlayerDevice):
if not self._available_apps:
self._available_apps = self._apps_list(self._device.get_apps_list())
- # Attempt to get current app name. If app name is unknown, check list
- # of additional apps specified in configuration
- self._current_app = await self._current_app_name()
+ self._current_app_config = await self._device.get_current_app_config(
+ log_api_exception=False
+ )
+
+ self._current_app = find_app_name(
+ self._current_app_config, [APP_HOME, *APPS, *self._additional_app_configs]
+ )
+
+ if self._current_app == NO_APP_RUNNING:
+ self._current_app = None
def _get_additional_app_names(self) -> List[Dict[str, Any]]:
"""Return list of additional apps that were included in configuration.yaml."""
@@ -248,6 +254,7 @@ class VizioDevice(MediaPlayerDevice):
async def _async_update_options(self, config_entry: ConfigEntry) -> None:
"""Update options if the update signal comes from this entity."""
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
+ self._conf_apps.update(config_entry.options.get(CONF_APPS, {}))
async def async_added_to_hass(self):
"""Register callbacks when entity is added."""
@@ -323,15 +330,26 @@ class VizioDevice(MediaPlayerDevice):
if _input not in INPUT_APPS
],
*self._available_apps,
- *self._get_additional_app_names(),
+ *[
+ app
+ for app in self._get_additional_app_names()
+ if app not in self._available_apps
+ ],
]
return self._available_inputs
@property
def app_id(self) -> Optional[str]:
- """Return the current app."""
- return self._current_app
+ """Return the ID of the current app if it is unknown by pyvizio."""
+ if self._current_app_config and self.app_name == UNKNOWN_APP:
+ return {
+ "APP_ID": self._current_app_config.APP_ID,
+ "NAME_SPACE": self._current_app_config.NAME_SPACE,
+ "MESSAGE": self._current_app_config.MESSAGE,
+ }
+
+ return None
@property
def app_name(self) -> Optional[str]:
diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json
index 61db7b49665..b6f6f53cf79 100644
--- a/homeassistant/components/vizio/strings.json
+++ b/homeassistant/components/vizio/strings.json
@@ -19,17 +19,13 @@
"pin": "PIN"
}
},
+ "pairing_complete": {
+ "title": "Pairing Complete",
+ "description": "Your Vizio SmartCast device is now connected to Home Assistant."
+ },
"pairing_complete_import": {
"title": "Pairing Complete",
"description": "Your Vizio SmartCast TV is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'."
- },
- "tv_apps": {
- "title": "Configure Apps for Smart TV",
- "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list. You can skip this step for TVs that don't support apps.",
- "data": {
- "include_or_exclude": "Include or Exclude Apps?",
- "apps_to_include_or_exclude": "Apps to Include or Exclude"
- }
}
},
"error": {
@@ -48,8 +44,11 @@
"step": {
"init": {
"title": "Update Vizo SmartCast Options",
+ "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.",
"data": {
- "volume_step": "Volume Step Size"
+ "volume_step": "Volume Step Size",
+ "include_or_exclude": "Include or Exclude Apps?",
+ "apps_to_include_or_exclude": "Apps to Include or Exclude"
}
}
}
diff --git a/homeassistant/components/withings/.translations/bg.json b/homeassistant/components/withings/.translations/bg.json
index 4064b21ca6b..30e384e0bc0 100644
--- a/homeassistant/components/withings/.translations/bg.json
+++ b/homeassistant/components/withings/.translations/bg.json
@@ -1,8 +1,5 @@
{
"config": {
- "abort": {
- "no_flows": "\u0422\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Withings, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0441\u0435 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u0430\u0442\u0435. \u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0447\u0435\u0442\u0435\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430."
- },
"create_entry": {
"default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Withings \u0437\u0430 \u0438\u0437\u0431\u0440\u0430\u043d\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b."
},
@@ -13,13 +10,6 @@
},
"description": "\u041a\u043e\u0439 \u043f\u0440\u043e\u0444\u0438\u043b \u0441\u0442\u0435 \u0438\u0437\u0431\u0440\u0430\u043b\u0438 \u043d\u0430 \u0443\u0435\u0431\u0441\u0430\u0439\u0442\u0430 \u043d\u0430 Withings? \u0412\u0430\u0436\u043d\u043e \u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u0438\u0442\u0435 \u0434\u0430 \u0441\u044a\u0432\u043f\u0430\u0434\u0430\u0442, \u0432 \u043f\u0440\u043e\u0442\u0438\u0432\u0435\u043d \u0441\u043b\u0443\u0447\u0430\u0439 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u0449\u0435 \u0431\u044a\u0434\u0430\u0442 \u043d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e \u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438.",
"title": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u0438 \u043f\u0440\u043e\u0444\u0438\u043b."
- },
- "user": {
- "data": {
- "profile": "\u041f\u0440\u043e\u0444\u0438\u043b"
- },
- "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u0438 \u043f\u0440\u043e\u0444\u0438\u043b, \u043a\u044a\u043c \u043a\u043e\u0439\u0442\u043e \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435 Home Assistant \u0441 Withings. \u041d\u0430 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430\u0442\u0430 \u043d\u0430 Withings \u043d\u0435 \u0437\u0430\u0431\u0440\u0430\u0432\u044f\u0439\u0442\u0435 \u0434\u0430 \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0435\u0434\u0438\u043d \u0438 \u0441\u044a\u0449 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b \u0438\u043b\u0438 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u043d\u044f\u043c\u0430 \u0434\u0430 \u0431\u044a\u0434\u0430\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e.",
- "title": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u0438 \u043f\u0440\u043e\u0444\u0438\u043b."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/ca.json b/homeassistant/components/withings/.translations/ca.json
index edb95a946aa..6363ddf1983 100644
--- a/homeassistant/components/withings/.translations/ca.json
+++ b/homeassistant/components/withings/.translations/ca.json
@@ -2,8 +2,7 @@
"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."
+ "missing_configuration": "La integraci\u00f3 Withings no est\u00e0 configurada. Mira'n la documentaci\u00f3."
},
"create_entry": {
"default": "Autenticaci\u00f3 exitosa amb Withings per al perfil seleccionat."
@@ -18,13 +17,6 @@
},
"description": "Quin perfil has seleccionat al lloc web de Withings? \u00c9s important que els perfils coincideixin sin\u00f3, les dades no s\u2019etiquetaran correctament.",
"title": "Perfil d'usuari."
- },
- "user": {
- "data": {
- "profile": "Perfil"
- },
- "description": "Selecciona un perfil d'usuari amb el qual vols que Home Assistant s'uneixi amb un perfil de Withings. A la p\u00e0gina de Withings, assegura't de seleccionar el mateix usuari o, les dades no seran les correctes.",
- "title": "Perfil d'usuari."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/da.json b/homeassistant/components/withings/.translations/da.json
index 72d851ad873..09e73e4ea8e 100644
--- a/homeassistant/components/withings/.translations/da.json
+++ b/homeassistant/components/withings/.translations/da.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "Timeout ved generering af godkendelses-url.",
- "missing_configuration": "Withings-integrationen er ikke konfigureret. F\u00f8lg venligst dokumentationen.",
- "no_flows": "Du skal konfigurere Withings, f\u00f8r du kan godkende med den. L\u00e6s venligst dokumentationen."
+ "missing_configuration": "Withings-integrationen er ikke konfigureret. F\u00f8lg venligst dokumentationen."
},
"create_entry": {
"default": "Godkendt med Withings."
@@ -18,13 +17,6 @@
},
"description": "Hvilken profil har du valgt p\u00e5 Withings hjemmeside? Det er vigtigt, at profilerne matcher, ellers vil data blive m\u00e6rket forkert.",
"title": "Brugerprofil."
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "V\u00e6lg en brugerprofil, som du vil have Home Assistant til at tilknytte med en Withings-profil. P\u00e5 siden Withings skal du s\u00f8rge for at v\u00e6lge den samme bruger eller data vil ikke blive m\u00e6rket korrekt.",
- "title": "Brugerprofil."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/de.json b/homeassistant/components/withings/.translations/de.json
index ae8ab679593..6295d918848 100644
--- a/homeassistant/components/withings/.translations/de.json
+++ b/homeassistant/components/withings/.translations/de.json
@@ -2,8 +2,7 @@
"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."
+ "missing_configuration": "Die Withings-Integration ist nicht konfiguriert. Bitte folgen Sie der Dokumentation."
},
"create_entry": {
"default": "Erfolgreiche Authentifizierung mit Withings."
@@ -18,13 +17,6 @@
},
"description": "Welches Profil hast du auf der Withings-Website ausgew\u00e4hlt? Es ist wichtig, dass die Profile \u00fcbereinstimmen, da sonst die Daten falsch beschriftet werden.",
"title": "Benutzerprofil"
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "W\u00e4hle ein Benutzerprofil aus, dem Home Assistant ein Withings-Profil zuordnen soll. Stelle sicher, dass du auf der Withings-Seite denselben Benutzer ausw\u00e4hlst, da sonst die Daten nicht korrekt gekennzeichnet werden.",
- "title": "Benutzerprofil."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/en.json b/homeassistant/components/withings/.translations/en.json
index c39ac530ae6..eefa54b9490 100644
--- a/homeassistant/components/withings/.translations/en.json
+++ b/homeassistant/components/withings/.translations/en.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "Timeout generating authorize url.",
- "missing_configuration": "The Withings integration is not configured. Please follow the documentation.",
- "no_flows": "You need to configure Withings before being able to authenticate with it. Please read the documentation."
+ "missing_configuration": "The Withings integration is not configured. Please follow the documentation."
},
"create_entry": {
"default": "Successfully authenticated with Withings."
@@ -18,13 +17,6 @@
},
"description": "Which profile did you select on the Withings website? It's important the profiles match, otherwise data will be mis-labeled.",
"title": "User Profile."
- },
- "user": {
- "data": {
- "profile": "Profile"
- },
- "description": "Select a user profile to which you want Home Assistant to map with a Withings profile. On the withings page, be sure to select the same user or data will not be labeled correctly.",
- "title": "User Profile."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/es-419.json b/homeassistant/components/withings/.translations/es-419.json
index 485150d2928..f0490e5724b 100644
--- a/homeassistant/components/withings/.translations/es-419.json
+++ b/homeassistant/components/withings/.translations/es-419.json
@@ -1,19 +1,8 @@
{
"config": {
- "abort": {
- "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": {
- "user": {
- "data": {
- "profile": "Perfil"
- },
- "title": "Perfil del usuario."
- }
- },
"title": "Withings"
}
}
\ No newline at end of file
diff --git a/homeassistant/components/withings/.translations/es.json b/homeassistant/components/withings/.translations/es.json
index c239d7d8db9..f3e2c36ae72 100644
--- a/homeassistant/components/withings/.translations/es.json
+++ b/homeassistant/components/withings/.translations/es.json
@@ -2,8 +2,7 @@
"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."
+ "missing_configuration": "La integraci\u00f3n de Withings no est\u00e1 configurada. Por favor, siga la documentaci\u00f3n."
},
"create_entry": {
"default": "Autenticado correctamente con Withings para el perfil seleccionado."
@@ -18,13 +17,6 @@
},
"description": "\u00bfQu\u00e9 perfil seleccion\u00f3 en el sitio web de Withings? Es importante que los perfiles coincidan, de lo contrario los datos se etiquetar\u00e1n incorrectamente.",
"title": "Perfil de usuario."
- },
- "user": {
- "data": {
- "profile": "Perfil"
- },
- "description": "Seleccione un perfil de usuario para el cual desea que Home Assistant se conecte con el perfil de Withings. En la p\u00e1gina de Withings, aseg\u00farese de seleccionar el mismo usuario o los datos no se identificar\u00e1n correctamente.",
- "title": "Perfil de usuario."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/fr.json b/homeassistant/components/withings/.translations/fr.json
index 0ad55e7eaa7..d178ef6c889 100644
--- a/homeassistant/components/withings/.translations/fr.json
+++ b/homeassistant/components/withings/.translations/fr.json
@@ -1,8 +1,8 @@
{
"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."
+ "authorize_url_timeout": "D\u00e9lai d'expiration g\u00e9n\u00e9rant une URL d'autorisation.",
+ "missing_configuration": "L'int\u00e9gration Withings n'est pas configur\u00e9e. Veuillez suivre la documentation."
},
"create_entry": {
"default": "Authentifi\u00e9 avec succ\u00e8s \u00e0 Withings pour le profil s\u00e9lectionn\u00e9."
@@ -17,13 +17,6 @@
},
"description": "Quel profil avez-vous s\u00e9lectionn\u00e9 sur le site Withings? Il est important que les profils correspondent, sinon les donn\u00e9es seront mal \u00e9tiquet\u00e9es.",
"title": "Profil utilisateur"
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "S\u00e9lectionnez l'utilisateur que vous souhaitez associer \u00e0 Withings. Sur la page withings, veillez \u00e0 s\u00e9lectionner le m\u00eame utilisateur, sinon les donn\u00e9es ne seront pas \u00e9tiquet\u00e9es correctement.",
- "title": "Profil utilisateur"
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/hu.json b/homeassistant/components/withings/.translations/hu.json
index 503013e402f..b13cf9ec524 100644
--- a/homeassistant/components/withings/.translations/hu.json
+++ b/homeassistant/components/withings/.translations/hu.json
@@ -2,8 +2,7 @@
"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."
+ "missing_configuration": "A Withings integr\u00e1ci\u00f3 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t."
},
"create_entry": {
"default": "A Withings sikeresen hiteles\u00edtett."
@@ -18,13 +17,6 @@
},
"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"
diff --git a/homeassistant/components/withings/.translations/it.json b/homeassistant/components/withings/.translations/it.json
index 4a6f5e67965..6deeff07489 100644
--- a/homeassistant/components/withings/.translations/it.json
+++ b/homeassistant/components/withings/.translations/it.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "Timeout durante la generazione dell'URL di autorizzazione.",
- "missing_configuration": "Il componente Withings non \u00e8 configurato. Si prega di seguire la documentazione.",
- "no_flows": "\u00c8 necessario configurare Withings prima di potersi autenticare con esso. Si prega di leggere la documentazione."
+ "missing_configuration": "Il componente Withings non \u00e8 configurato. Si prega di seguire la documentazione."
},
"create_entry": {
"default": "Autenticazione riuscita con Withings."
@@ -18,13 +17,6 @@
},
"description": "Quale profilo hai selezionato sul sito web di Withings? \u00c8 importante che i profili corrispondano, altrimenti i dati avranno con un'errata etichettatura.",
"title": "Profilo utente."
- },
- "user": {
- "data": {
- "profile": "Profilo"
- },
- "description": "Seleziona un profilo utente a cui desideri associare Home Assistant con un profilo Withings. Nella pagina Withings, assicurati di selezionare lo stesso utente o i dati non saranno etichettati correttamente.",
- "title": "Profilo utente."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/ko.json b/homeassistant/components/withings/.translations/ko.json
index 4ff2a80434a..8cdd8511919 100644
--- a/homeassistant/components/withings/.translations/ko.json
+++ b/homeassistant/components/withings/.translations/ko.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "missing_configuration": "Withings \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.",
- "no_flows": "Withings \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Withings \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/withings/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694."
+ "missing_configuration": "Withings \uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694."
},
"create_entry": {
"default": "Withings \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
@@ -18,13 +17,6 @@
},
"description": "Withings \uc6f9 \uc0ac\uc774\ud2b8\uc5d0\uc11c \uc5b4\ub5a4 \ud504\ub85c\ud544\uc744 \uc120\ud0dd\ud558\uc168\ub098\uc694? \ud504\ub85c\ud544\uc774 \uc77c\uce58\ud574\uc57c \ud569\ub2c8\ub2e4. \uadf8\ub807\uc9c0 \uc54a\uc73c\uba74, \ub370\uc774\ud130\uc5d0 \ub808\uc774\ube14\uc774 \uc798\ubabb \uc9c0\uc815\ub429\ub2c8\ub2e4.",
"title": "\uc0ac\uc6a9\uc790 \ud504\ub85c\ud544."
- },
- "user": {
- "data": {
- "profile": "\ud504\ub85c\ud544"
- },
- "description": "Home Assistant \uac00 Withings \ud504\ub85c\ud544\uacfc \ub9f5\ud551\ud560 \uc0ac\uc6a9\uc790 \ud504\ub85c\ud544\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694. Withings \ud398\uc774\uc9c0\uc5d0\uc11c \ub3d9\uc77c\ud55c \uc0ac\uc6a9\uc790\ub97c \uc120\ud0dd\ud574\uc57c\ud569\ub2c8\ub2e4. \uadf8\ub807\uc9c0 \uc54a\uc73c\uba74 \ub370\uc774\ud130\uc5d0 \uc62c\ubc14\ub978 \ub808\uc774\ube14\uc774 \uc9c0\uc815\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
- "title": "\uc0ac\uc6a9\uc790 \ud504\ub85c\ud544."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/lb.json b/homeassistant/components/withings/.translations/lb.json
index 4f3fb27e7b2..1984ef6f586 100644
--- a/homeassistant/components/withings/.translations/lb.json
+++ b/homeassistant/components/withings/.translations/lb.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.",
- "missing_configuration": "Withings Integratioun ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun.",
- "no_flows": "Dir musst Withingss konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen. Liest w.e.g. d'Instruktioune."
+ "missing_configuration": "Withings Integratioun ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun."
},
"create_entry": {
"default": "Erfollegr\u00e4ich mam ausgewielte Profile mat Withings authentifiz\u00e9iert."
@@ -18,13 +17,6 @@
},
"description": "W\u00e9ie Profil hutt dir op der Withings Webs\u00e4it ausgewielt? Et ass wichteg dass Profiller passen, soss ginn Donn\u00e9e\u00eb falsch gekennzeechent.",
"title": "Benotzer Profil."
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "Wielt ee Benotzer Profile aus dee mam Withings Profile soll verbonne ginn. Stellt s\u00e9cher dass dir op der Withings S\u00e4it deeselwechte Benotzer auswielt, soss ginn d'Donn\u00e9e net richteg ugewisen.",
- "title": "Benotzer Profil."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/lv.json b/homeassistant/components/withings/.translations/lv.json
index 3f7cf20fdb4..7d8b268367c 100644
--- a/homeassistant/components/withings/.translations/lv.json
+++ b/homeassistant/components/withings/.translations/lv.json
@@ -1,13 +1,5 @@
{
"config": {
- "step": {
- "user": {
- "data": {
- "profile": "Profils"
- },
- "title": "Lietot\u0101ja profils."
- }
- },
"title": "Withings"
}
}
\ No newline at end of file
diff --git a/homeassistant/components/withings/.translations/nl.json b/homeassistant/components/withings/.translations/nl.json
index 0b01fc8c16a..d534acc5c09 100644
--- a/homeassistant/components/withings/.translations/nl.json
+++ b/homeassistant/components/withings/.translations/nl.json
@@ -2,8 +2,7 @@
"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]"
+ "missing_configuration": "De Withings integratie is niet geconfigureerd. Gelieve de documentatie te volgen."
},
"create_entry": {
"default": "Succesvol geverifieerd met Withings voor het geselecteerde profiel."
@@ -18,13 +17,6 @@
},
"description": "Welk profiel hebt u op de website van Withings selecteren? Het is belangrijk dat de profielen overeenkomen, anders worden gegevens verkeerd gelabeld.",
"title": "Gebruikersprofiel."
- },
- "user": {
- "data": {
- "profile": "Profiel"
- },
- "description": "Selecteer een gebruikersprofiel waaraan u Home Assistant wilt toewijzen met een Withings-profiel. Zorg ervoor dat u op de pagina Withings dezelfde gebruiker selecteert, anders worden de gegevens niet correct gelabeld.",
- "title": "Gebruikersprofiel."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/no.json b/homeassistant/components/withings/.translations/no.json
index 1c4a8c0fb71..fac2fa3a8fc 100644
--- a/homeassistant/components/withings/.translations/no.json
+++ b/homeassistant/components/withings/.translations/no.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.",
- "missing_configuration": "Withings-integreringen er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.",
- "no_flows": "Du m\u00e5 konfigurere Withings f\u00f8r du kan godkjenne med den. Vennligst les dokumentasjonen."
+ "missing_configuration": "Withings-integreringen er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen."
},
"create_entry": {
"default": "Vellykket godkjent med Withings."
@@ -18,13 +17,6 @@
},
"description": "Hvilken profil valgte du p\u00e5 Withings nettsted? Det er viktig at profilene samsvarer, ellers blir data feilmerket.",
"title": "Brukerprofil."
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "Velg en brukerprofil som du vil at Home Assistant skal kartlegge med en Withings-profil. P\u00e5 Withings-siden m\u00e5 du passe p\u00e5 at du velger samme bruker ellers vil ikke dataen bli merket riktig.",
- "title": "Brukerprofil."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/pl.json b/homeassistant/components/withings/.translations/pl.json
index afe35bd06cf..c20f7a9ba53 100644
--- a/homeassistant/components/withings/.translations/pl.json
+++ b/homeassistant/components/withings/.translations/pl.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"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."
+ "missing_configuration": "Integracja z Withings nie jest skonfigurowana. Post\u0119puj zgodnie z dokumentacj\u0105."
},
"create_entry": {
"default": "Pomy\u015blnie uwierzytelniono z Withings dla wybranego profilu"
@@ -18,13 +17,6 @@
},
"description": "Kt\u00f3ry profil wybra\u0142e\u015b na stronie Withings? Wa\u017cne jest, aby profile si\u0119 zgadza\u0142y, w przeciwnym razie dane zostan\u0105 b\u0142\u0119dnie oznaczone.",
"title": "Profil u\u017cytkownika"
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "Wybierz profil u\u017cytkownika Withings, na kt\u00f3ry chcesz po\u0142\u0105czy\u0107 z Home Assistant'em. Na stronie Withings wybierz ten sam profil u\u017cytkownika, by dane by\u0142y poprawnie oznaczone.",
- "title": "Profil u\u017cytkownika"
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/ru.json b/homeassistant/components/withings/.translations/ru.json
index 407bcf48c1a..eba16290453 100644
--- a/homeassistant/components/withings/.translations/ru.json
+++ b/homeassistant/components/withings/.translations/ru.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
- "missing_configuration": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Withings \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.",
- "no_flows": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Withings \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438."
+ "missing_configuration": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Withings \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."
@@ -18,13 +17,6 @@
},
"description": "\u041a\u0430\u043a\u043e\u0439 \u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0412\u044b \u0432\u044b\u0431\u0440\u0430\u043b\u0438 \u043d\u0430 \u0441\u0430\u0439\u0442\u0435 Withings? \u0412\u0430\u0436\u043d\u043e, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0444\u0438\u043b\u0438 \u0441\u043e\u0432\u043f\u0430\u0434\u0430\u043b\u0438, \u0438\u043d\u0430\u0447\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0431\u0443\u0434\u0443\u0442 \u043d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u043f\u043e\u043c\u0435\u0447\u0435\u043d\u044b.",
"title": "Withings"
- },
- "user": {
- "data": {
- "profile": "\u041f\u0440\u043e\u0444\u0438\u043b\u044c"
- },
- "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f. \u041d\u0430 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 Withings \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u043e\u0433\u043e \u0436\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u0438\u043d\u0430\u0447\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0431\u0443\u0434\u0443\u0442 \u043f\u043e\u043c\u0435\u0447\u0435\u043d\u044b \u043d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e.",
- "title": "Withings"
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/sl.json b/homeassistant/components/withings/.translations/sl.json
index 600b2dbf450..1de0a0d6ce7 100644
--- a/homeassistant/components/withings/.translations/sl.json
+++ b/homeassistant/components/withings/.translations/sl.json
@@ -2,11 +2,10 @@
"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."
+ "missing_configuration": "Integracija Withings ni konfigurirana. Prosimo, upo\u0161tevajte dokumentacijo."
},
"create_entry": {
- "default": "Uspe\u0161no overjen z Withings za izbrani profil."
+ "default": "Uspe\u0161no overjen z Withings."
},
"step": {
"pick_implementation": {
@@ -18,13 +17,6 @@
},
"description": "Kateri profil ste izbrali na spletni strani Withings? Pomembno je, da se profili ujemajo, sicer bodo podatki napa\u010dno ozna\u010deni.",
"title": "Uporabni\u0161ki profil."
- },
- "user": {
- "data": {
- "profile": "Profil"
- },
- "description": "Izberite uporabni\u0161ki profil, za katerega \u017eelite, da se Home Assistant prika\u017ee s profilom Withings. Na Withings strani ne pozabite izbrati istega uporabnika sicer podatki ne bodo pravilno ozna\u010deni.",
- "title": "Uporabni\u0161ki profil."
}
},
"title": "Withings"
diff --git a/homeassistant/components/withings/.translations/sv.json b/homeassistant/components/withings/.translations/sv.json
index dc8954af2c7..dfaa09d52f0 100644
--- a/homeassistant/components/withings/.translations/sv.json
+++ b/homeassistant/components/withings/.translations/sv.json
@@ -2,8 +2,7 @@
"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."
+ "missing_configuration": "Withings-integrationen \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen."
},
"create_entry": {
"default": "Lyckad autentisering med Withings."
@@ -18,13 +17,6 @@
},
"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"
diff --git a/homeassistant/components/withings/.translations/zh-Hant.json b/homeassistant/components/withings/.translations/zh-Hant.json
index 06870c4020a..61ae1fd8e06 100644
--- a/homeassistant/components/withings/.translations/zh-Hant.json
+++ b/homeassistant/components/withings/.translations/zh-Hant.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002",
- "missing_configuration": "Withings \u6574\u5408\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002",
- "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Withings \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002\u8acb\u53c3\u95b1\u6587\u4ef6\u3002"
+ "missing_configuration": "Withings \u6574\u5408\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002"
},
"create_entry": {
"default": "\u5df2\u6210\u529f\u8a8d\u8b49 Withings \u8a2d\u5099\u3002"
@@ -18,13 +17,6 @@
},
"description": "\u65bc Withings \u7db2\u7ad9\u6240\u9078\u64c7\u7684\u500b\u4eba\u8a2d\u5b9a\u70ba\u4f55\uff1f\u5047\u5982\u500b\u4eba\u8a2d\u5b9a\u4e0d\u7b26\u5408\u7684\u8a71\uff0c\u8cc7\u6599\u5c07\u6703\u6a19\u793a\u932f\u8aa4\u3002",
"title": "\u500b\u4eba\u8a2d\u5b9a\u3002"
- },
- "user": {
- "data": {
- "profile": "\u500b\u4eba\u8a2d\u5b9a"
- },
- "description": "\u9078\u64c7 Home Assistant \u6240\u8981\u5c0d\u61c9\u4f7f\u7528\u7684 Withings \u500b\u4eba\u8a2d\u5b9a\u3002\u65bc Withings \u9801\u9762\u3001\u78ba\u5b9a\u9078\u53d6\u76f8\u540c\u7684\u4f7f\u7528\u8005\uff0c\u5426\u5247\u8cc7\u6599\u5c07\u7121\u6cd5\u6b63\u78ba\u6a19\u793a\u3002",
- "title": "\u500b\u4eba\u8a2d\u5b9a\u3002"
}
},
"title": "Withings"
diff --git a/homeassistant/components/wled/.translations/zh-Hant.json b/homeassistant/components/wled/.translations/zh-Hant.json
index b72ef3d078c..14139a20401 100644
--- a/homeassistant/components/wled/.translations/zh-Hant.json
+++ b/homeassistant/components/wled/.translations/zh-Hant.json
@@ -18,7 +18,7 @@
},
"zeroconf_confirm": {
"description": "\u662f\u5426\u8981\u65b0\u589e WLED \u540d\u7a31\u300c{name}\u300d\u8a2d\u5099\u81f3 Home Assistant\uff1f",
- "title": "\u767c\u73fe\u5230 WLED \u8a2d\u5099"
+ "title": "\u81ea\u52d5\u63a2\u7d22\u5230 WLED \u8a2d\u5099"
}
},
"title": "WLED"
diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py
index 1684da28c3f..91c130a7a81 100644
--- a/homeassistant/components/wled/__init__.py
+++ b/homeassistant/components/wled/__init__.py
@@ -2,39 +2,31 @@
import asyncio
from datetime import timedelta
import logging
-from typing import Any, Dict, Optional, Union
+from typing import Any, Dict
-from wled import WLED, WLEDConnectionError, WLEDError
+from wled import WLED, Device as WLEDDevice, WLEDConnectionError, WLEDError
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_NAME, CONF_HOST
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-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
-from homeassistant.util import dt as dt_util
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SOFTWARE_VERSION,
- DATA_WLED_CLIENT,
- DATA_WLED_TIMER,
- DATA_WLED_UPDATED,
DOMAIN,
)
-SCAN_INTERVAL = timedelta(seconds=10)
+SCAN_INTERVAL = timedelta(seconds=5)
WLED_COMPONENTS = (LIGHT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN)
_LOGGER = logging.getLogger(__name__)
@@ -49,22 +41,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WLED from a config entry."""
# Create WLED instance for this entry
- session = async_get_clientsession(hass)
- wled = WLED(entry.data[CONF_HOST], session=session)
+ coordinator = WLEDDataUpdateCoordinator(hass, host=entry.data[CONF_HOST])
+ await coordinator.async_refresh()
- # Ensure we can connect and talk to it
- try:
- await wled.update()
- except WLEDConnectionError as exception:
- raise ConfigEntryNotReady from exception
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = {DATA_WLED_CLIENT: wled}
+ hass.data[DOMAIN][entry.entry_id] = coordinator
# For backwards compat, set unique ID
if entry.unique_id is None:
hass.config_entries.async_update_entry(
- entry, unique_id=wled.device.info.mac_address
+ entry, unique_id=coordinator.data.info.mac_address
)
# Set up all platforms for this device/entry.
@@ -73,32 +62,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_forward_entry_setup(entry, component)
)
- async def interval_update(now: dt_util.dt.datetime = None) -> None:
- """Poll WLED device function, dispatches event after update."""
- try:
- await wled.update()
- except WLEDError:
- _LOGGER.debug("An error occurred while updating WLED", exc_info=True)
-
- # Even if the update failed, we still send out the event.
- # To allow entities to make themselves unavailable.
- async_dispatcher_send(hass, DATA_WLED_UPDATED, entry.entry_id)
-
- # Schedule update interval
- hass.data[DOMAIN][entry.entry_id][DATA_WLED_TIMER] = async_track_time_interval(
- hass, interval_update, SCAN_INTERVAL
- )
-
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload WLED config entry."""
- # Cancel update timer for this entry/device.
- cancel_timer = hass.data[DOMAIN][entry.entry_id][DATA_WLED_TIMER]
- cancel_timer()
-
# Unload entities for this entry/device.
await asyncio.gather(
*(
@@ -115,26 +84,74 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
+def wled_exception_handler(func):
+ """Decorate WLED calls to handle WLED exceptions.
+
+ A decorator that wraps the passed in function, catches WLED errors,
+ and handles the availability of the device in the data coordinator.
+ """
+
+ async def handler(self, *args, **kwargs):
+ try:
+ await func(self, *args, **kwargs)
+ self.coordinator.update_listeners()
+
+ except WLEDConnectionError as error:
+ _LOGGER.error("Error communicating with API: %s", error)
+ self.coordinator.last_update_success = False
+ self.coordinator.update_listeners()
+
+ except WLEDError as error:
+ _LOGGER.error("Invalid response from API: %s", error)
+
+ return handler
+
+
+class WLEDDataUpdateCoordinator(DataUpdateCoordinator):
+ """Class to manage fetching WLED data from single endpoint."""
+
+ def __init__(
+ self, hass: HomeAssistant, *, host: str,
+ ):
+ """Initialize global WLED data updater."""
+ self.wled = WLED(host, session=async_get_clientsession(hass))
+
+ super().__init__(
+ hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL,
+ )
+
+ def update_listeners(self) -> None:
+ """Call update on all listeners."""
+ for update_callback in self._listeners:
+ update_callback()
+
+ async def _async_update_data(self) -> WLEDDevice:
+ """Fetch data from WLED."""
+ try:
+ return await self.wled.update(full_update=not self.last_update_success)
+ except WLEDError as error:
+ raise UpdateFailed(f"Invalid response from API: {error}")
+
+
class WLEDEntity(Entity):
"""Defines a base WLED entity."""
def __init__(
self,
+ *,
entry_id: str,
- wled: WLED,
+ coordinator: WLEDDataUpdateCoordinator,
name: str,
icon: str,
enabled_default: bool = True,
) -> None:
"""Initialize the WLED entity."""
- self._attributes: Dict[str, Union[str, int, float]] = {}
- self._available = True
self._enabled_default = enabled_default
self._entry_id = entry_id
self._icon = icon
self._name = name
self._unsub_dispatcher = None
- self.wled = wled
+ self.coordinator = coordinator
@property
def name(self) -> str:
@@ -149,7 +166,7 @@ class WLEDEntity(Entity):
@property
def available(self) -> bool:
"""Return True if entity is available."""
- return self._available
+ return self.coordinator.last_update_success
@property
def entity_registry_enabled_default(self) -> bool:
@@ -161,42 +178,17 @@ class WLEDEntity(Entity):
"""Return the polling requirement of the entity."""
return False
- @property
- def device_state_attributes(self) -> Optional[Dict[str, Any]]:
- """Return the state attributes of the entity."""
- return self._attributes
-
async def async_added_to_hass(self) -> None:
"""Connect to dispatcher listening for entity data notifications."""
- self._unsub_dispatcher = async_dispatcher_connect(
- self.hass, DATA_WLED_UPDATED, self._schedule_immediate_update
- )
+ self.coordinator.async_add_listener(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect from update signal."""
- self._unsub_dispatcher()
-
- @callback
- def _schedule_immediate_update(self, entry_id: str) -> None:
- """Schedule an immediate update of the entity."""
- if entry_id == self._entry_id:
- self.async_schedule_update_ha_state(True)
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
async def async_update(self) -> None:
"""Update WLED entity."""
- if not self.enabled:
- return
-
- if self.wled.device is None:
- self._available = False
- return
-
- self._available = True
- await self._wled_update()
-
- async def _wled_update(self) -> None:
- """Update WLED entity."""
- raise NotImplementedError()
+ await self.coordinator.async_request_refresh()
class WLEDDeviceEntity(WLEDEntity):
@@ -206,9 +198,9 @@ class WLEDDeviceEntity(WLEDEntity):
def device_info(self) -> Dict[str, Any]:
"""Return device information about this WLED device."""
return {
- ATTR_IDENTIFIERS: {(DOMAIN, self.wled.device.info.mac_address)},
- ATTR_NAME: self.wled.device.info.name,
- ATTR_MANUFACTURER: self.wled.device.info.brand,
- ATTR_MODEL: self.wled.device.info.product,
- ATTR_SOFTWARE_VERSION: self.wled.device.info.version,
+ ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.mac_address)},
+ ATTR_NAME: self.coordinator.data.info.name,
+ ATTR_MANUFACTURER: self.coordinator.data.info.brand,
+ ATTR_MODEL: self.coordinator.data.info.product,
+ ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version,
}
diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py
index dbcd55a7b17..ecf8ca6e1e0 100644
--- a/homeassistant/components/wled/config_flow.py
+++ b/homeassistant/components/wled/config_flow.py
@@ -44,7 +44,12 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context.update(
- {CONF_HOST: host, CONF_NAME: name, "title_placeholders": {"name": name}}
+ {
+ CONF_HOST: user_input["host"],
+ CONF_NAME: name,
+ CONF_MAC: user_input["properties"].get(CONF_MAC),
+ "title_placeholders": {"name": name},
+ }
)
# Prepare configuration flow
@@ -72,23 +77,22 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
if source == SOURCE_ZEROCONF:
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
user_input[CONF_HOST] = self.context.get(CONF_HOST)
+ user_input[CONF_MAC] = self.context.get(CONF_MAC)
- errors = {}
- session = async_get_clientsession(self.hass)
- wled = WLED(user_input[CONF_HOST], session=session)
-
- try:
- device = await wled.update()
- except WLEDConnectionError:
- if source == SOURCE_ZEROCONF:
- return self.async_abort(reason="connection_error")
- errors["base"] = "connection_error"
- return self._show_setup_form(errors)
+ if user_input.get(CONF_MAC) is None or not prepare:
+ session = async_get_clientsession(self.hass)
+ wled = WLED(user_input[CONF_HOST], session=session)
+ try:
+ device = await wled.update()
+ except WLEDConnectionError:
+ if source == SOURCE_ZEROCONF:
+ return self.async_abort(reason="connection_error")
+ return self._show_setup_form({"base": "connection_error"})
+ user_input[CONF_MAC] = device.info.mac_address
# Check if already configured
- mac_address = device.info.mac_address
- await self.async_set_unique_id(device.info.mac_address)
- self._abort_if_unique_id_configured()
+ await self.async_set_unique_id(user_input[CONF_MAC])
+ self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
title = user_input[CONF_HOST]
if source == SOURCE_ZEROCONF:
@@ -99,7 +103,8 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_zeroconf_confirm()
return self.async_create_entry(
- title=title, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: mac_address}
+ title=title,
+ data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]},
)
def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py
index 94ee513f134..6006952a580 100644
--- a/homeassistant/components/wled/const.py
+++ b/homeassistant/components/wled/const.py
@@ -3,11 +3,6 @@
# Integration domain
DOMAIN = "wled"
-# Home Assistant data keys
-DATA_WLED_CLIENT = "wled_client"
-DATA_WLED_TIMER = "wled_timer"
-DATA_WLED_UPDATED = "wled_updated"
-
# Attributes
ATTR_COLOR_PRIMARY = "color_primary"
ATTR_DURATION = "duration"
@@ -22,6 +17,7 @@ ATTR_ON = "on"
ATTR_PALETTE = "palette"
ATTR_PLAYLIST = "playlist"
ATTR_PRESET = "preset"
+ATTR_REVERSE = "reverse"
ATTR_SEGMENT_ID = "segment_id"
ATTR_SOFTWARE_VERSION = "sw_version"
ATTR_SPEED = "speed"
@@ -30,3 +26,7 @@ ATTR_UDP_PORT = "udp_port"
# Units of measurement
CURRENT_MA = "mA"
+SIGNAL_DBM = "dBm"
+
+# Services
+SERVICE_EFFECT = "effect"
diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py
index f22282e5539..beda19b8101 100644
--- a/homeassistant/components/wled/light.py
+++ b/homeassistant/components/wled/light.py
@@ -1,8 +1,8 @@
"""Support for LED lights."""
import logging
-from typing import Any, Callable, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
-from wled import WLED, Effect, WLEDError
+import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -20,11 +20,12 @@ from homeassistant.components.light import (
Light,
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
import homeassistant.util.color as color_util
-from . import WLEDDeviceEntity
+from . import WLEDDataUpdateCoordinator, WLEDDeviceEntity, wled_exception_handler
from .const import (
ATTR_COLOR_PRIMARY,
ATTR_INTENSITY,
@@ -32,10 +33,11 @@ from .const import (
ATTR_PALETTE,
ATTR_PLAYLIST,
ATTR_PRESET,
+ ATTR_REVERSE,
ATTR_SEGMENT_ID,
ATTR_SPEED,
- DATA_WLED_CLIENT,
DOMAIN,
+ SERVICE_EFFECT,
)
_LOGGER = logging.getLogger(__name__)
@@ -49,19 +51,29 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up WLED light based on a config entry."""
- wled: WLED = hass.data[DOMAIN][entry.entry_id][DATA_WLED_CLIENT]
+ coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
- # Does the WLED device support RGBW
- rgbw = wled.device.info.leds.rgbw
+ platform = entity_platform.current_platform.get()
- # List of supported effects
- effects = wled.device.effects
+ platform.async_register_entity_service(
+ SERVICE_EFFECT,
+ {
+ vol.Optional(ATTR_EFFECT): vol.Any(cv.positive_int, cv.string),
+ vol.Optional(ATTR_INTENSITY): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ vol.Optional(ATTR_REVERSE): cv.boolean,
+ vol.Optional(ATTR_SPEED): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=255)
+ ),
+ },
+ "async_effect",
+ )
- # WLED supports splitting a strip in multiple segments
- # Each segment will be a separate light in Home Assistant
- lights = []
- for light in wled.device.state.segments:
- lights.append(WLEDLight(entry.entry_id, wled, light.segment_id, rgbw, effects))
+ lights = [
+ WLEDLight(entry.entry_id, coordinator, light.segment_id)
+ for light in coordinator.data.state.segments
+ ]
async_add_entities(lights, True)
@@ -70,50 +82,71 @@ class WLEDLight(Light, WLEDDeviceEntity):
"""Defines a WLED light."""
def __init__(
- self, entry_id: str, wled: WLED, segment: int, rgbw: bool, effects: List[Effect]
+ self, entry_id: str, coordinator: WLEDDataUpdateCoordinator, segment: int
):
"""Initialize WLED light."""
- self._effects = effects
- self._rgbw = rgbw
+ self._rgbw = coordinator.data.info.leds.rgbw
self._segment = segment
- self._brightness: Optional[int] = None
- self._color: Optional[Tuple[float, float]] = None
- self._effect: Optional[str] = None
- self._state: Optional[bool] = None
- self._white_value: Optional[int] = None
-
# Only apply the segment ID if it is not the first segment
- name = wled.device.info.name
+ name = coordinator.data.info.name
if segment != 0:
name += f" {segment}"
- super().__init__(entry_id, wled, name, "mdi:led-strip-variant")
+ super().__init__(
+ entry_id=entry_id,
+ coordinator=coordinator,
+ name=name,
+ icon="mdi:led-strip-variant",
+ )
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
- return f"{self.wled.device.info.mac_address}_{self._segment}"
+ return f"{self.coordinator.data.info.mac_address}_{self._segment}"
+
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ playlist = self.coordinator.data.state.playlist
+ if playlist == -1:
+ playlist = None
+
+ preset = self.coordinator.data.state.preset
+ if preset == -1:
+ preset = None
+
+ segment = self.coordinator.data.state.segments[self._segment]
+ return {
+ ATTR_INTENSITY: segment.intensity,
+ ATTR_PALETTE: segment.palette.name,
+ ATTR_PLAYLIST: playlist,
+ ATTR_PRESET: preset,
+ ATTR_REVERSE: segment.reverse,
+ ATTR_SPEED: segment.speed,
+ }
@property
def hs_color(self) -> Optional[Tuple[float, float]]:
"""Return the hue and saturation color value [float, float]."""
- return self._color
+ color = self.coordinator.data.state.segments[self._segment].color_primary
+ return color_util.color_RGB_to_hs(*color[:3])
@property
def effect(self) -> Optional[str]:
"""Return the current effect of the light."""
- return self._effect
+ return self.coordinator.data.state.segments[self._segment].effect.name
@property
def brightness(self) -> Optional[int]:
"""Return the brightness of this light between 1..255."""
- return self._brightness
+ return self.coordinator.data.state.brightness
@property
def white_value(self) -> Optional[int]:
"""Return the white value of this light between 0..255."""
- return self._white_value
+ color = self.coordinator.data.state.segments[self._segment].color_primary
+ return color[-1] if self._rgbw else None
@property
def supported_features(self) -> int:
@@ -134,13 +167,14 @@ class WLEDLight(Light, WLEDDeviceEntity):
@property
def effect_list(self) -> List[str]:
"""Return the list of supported effects."""
- return [effect.name for effect in self._effects]
+ return [effect.name for effect in self.coordinator.data.effects]
@property
def is_on(self) -> bool:
"""Return the state of the light."""
- return bool(self._state)
+ return bool(self.coordinator.data.state.on)
+ @wled_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
data = {ATTR_ON: False, ATTR_SEGMENT_ID: self._segment}
@@ -149,14 +183,9 @@ class WLEDLight(Light, WLEDDeviceEntity):
# WLED uses 100ms per unit, so 10 = 1 second.
data[ATTR_TRANSITION] = round(kwargs[ATTR_TRANSITION] * 10)
- try:
- await self.wled.light(**data)
- self._state = False
- except WLEDError:
- _LOGGER.error("An error occurred while turning off WLED light.")
- self._available = False
- self.async_schedule_update_ha_state()
+ await self.coordinator.wled.light(**data)
+ @wled_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
data = {ATTR_ON: True, ATTR_SEGMENT_ID: self._segment}
@@ -189,8 +218,8 @@ class WLEDLight(Light, WLEDDeviceEntity):
):
# WLED cannot just accept a white value, it needs the color.
# We use the last know color in case just the white value changes.
- if not any(x in (ATTR_COLOR_TEMP, ATTR_HS_COLOR) for x in kwargs):
- hue, sat = self._color
+ if all(x not in (ATTR_COLOR_TEMP, ATTR_HS_COLOR) for x in kwargs):
+ hue, sat = self.hs_color
data[ATTR_COLOR_PRIMARY] = color_util.color_hsv_to_RGB(hue, sat, 100)
# On a RGBW strip, when the color is pure white, disable the RGB LEDs in
@@ -202,56 +231,31 @@ class WLEDLight(Light, WLEDDeviceEntity):
if ATTR_WHITE_VALUE in kwargs:
data[ATTR_COLOR_PRIMARY] += (kwargs[ATTR_WHITE_VALUE],)
else:
- data[ATTR_COLOR_PRIMARY] += (self._white_value,)
+ data[ATTR_COLOR_PRIMARY] += (self.white_value,)
- try:
- await self.wled.light(**data)
+ await self.coordinator.wled.light(**data)
- self._state = True
+ @wled_exception_handler
+ async def async_effect(
+ self,
+ effect: Optional[Union[int, str]] = None,
+ intensity: Optional[int] = None,
+ reverse: Optional[bool] = None,
+ speed: Optional[int] = None,
+ ) -> None:
+ """Set the effect of a WLED light."""
+ data = {ATTR_SEGMENT_ID: self._segment}
- if ATTR_BRIGHTNESS in kwargs:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
+ if effect is not None:
+ data[ATTR_EFFECT] = effect
- if ATTR_EFFECT in kwargs:
- self._effect = kwargs[ATTR_EFFECT]
+ if intensity is not None:
+ data[ATTR_INTENSITY] = intensity
- if ATTR_HS_COLOR in kwargs:
- self._color = kwargs[ATTR_HS_COLOR]
+ if reverse is not None:
+ data[ATTR_REVERSE] = reverse
- if ATTR_COLOR_TEMP in kwargs:
- self._color = color_util.color_temperature_to_hs(mireds)
+ if speed is not None:
+ data[ATTR_SPEED] = speed
- if ATTR_WHITE_VALUE in kwargs:
- self._white_value = kwargs[ATTR_WHITE_VALUE]
-
- except WLEDError:
- _LOGGER.error("An error occurred while turning on WLED light.")
- self._available = False
- self.async_schedule_update_ha_state()
-
- async def _wled_update(self) -> None:
- """Update WLED entity."""
- self._brightness = self.wled.device.state.brightness
- self._effect = self.wled.device.state.segments[self._segment].effect.name
- self._state = self.wled.device.state.on
-
- color = self.wled.device.state.segments[self._segment].color_primary
- self._color = color_util.color_RGB_to_hs(*color[:3])
- if self._rgbw:
- self._white_value = color[-1]
-
- playlist = self.wled.device.state.playlist
- if playlist == -1:
- playlist = None
-
- preset = self.wled.device.state.preset
- if preset == -1:
- preset = None
-
- self._attributes = {
- ATTR_INTENSITY: self.wled.device.state.segments[self._segment].intensity,
- ATTR_PALETTE: self.wled.device.state.segments[self._segment].palette.name,
- ATTR_PLAYLIST: playlist,
- ATTR_PRESET: preset,
- ATTR_SPEED: self.wled.device.state.segments[self._segment].speed,
- }
+ await self.coordinator.wled.light(**data)
diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json
index 4b501d0c67c..d501edbd631 100644
--- a/homeassistant/components/wled/manifest.json
+++ b/homeassistant/components/wled/manifest.json
@@ -3,7 +3,7 @@
"name": "WLED",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/wled",
- "requirements": ["wled==0.2.1"],
+ "requirements": ["wled==0.3.0"],
"dependencies": [],
"zeroconf": ["_wled._tcp.local."],
"codeowners": ["@frenck"],
diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py
index 41e03d8c728..b684bdc0977 100644
--- a/homeassistant/components/wled/sensor.py
+++ b/homeassistant/components/wled/sensor.py
@@ -1,16 +1,21 @@
"""Support for WLED sensors."""
from datetime import timedelta
import logging
-from typing import Callable, List, Optional, Union
+from typing import Any, Callable, Dict, List, Optional, Union
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import DATA_BYTES, DEVICE_CLASS_TIMESTAMP
+from homeassistant.const import (
+ DATA_BYTES,
+ DEVICE_CLASS_SIGNAL_STRENGTH,
+ DEVICE_CLASS_TIMESTAMP,
+ UNIT_PERCENTAGE,
+)
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_WLED_CLIENT, DOMAIN
+from . import WLEDDataUpdateCoordinator, WLEDDeviceEntity
+from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, CURRENT_MA, DOMAIN, SIGNAL_DBM
_LOGGER = logging.getLogger(__name__)
@@ -21,12 +26,16 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up WLED sensor based on a config entry."""
- wled: WLED = hass.data[DOMAIN][entry.entry_id][DATA_WLED_CLIENT]
+ coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
sensors = [
- WLEDEstimatedCurrentSensor(entry.entry_id, wled),
- WLEDUptimeSensor(entry.entry_id, wled),
- WLEDFreeHeapSensor(entry.entry_id, wled),
+ WLEDEstimatedCurrentSensor(entry.entry_id, coordinator),
+ WLEDUptimeSensor(entry.entry_id, coordinator),
+ WLEDFreeHeapSensor(entry.entry_id, coordinator),
+ WLEDWifiBSSIDSensor(entry.entry_id, coordinator),
+ WLEDWifiChannelSensor(entry.entry_id, coordinator),
+ WLEDWifiRSSISensor(entry.entry_id, coordinator),
+ WLEDWifiSignalSensor(entry.entry_id, coordinator),
]
async_add_entities(sensors, True)
@@ -37,30 +46,31 @@ class WLEDSensor(WLEDDeviceEntity):
def __init__(
self,
- entry_id: str,
- wled: WLED,
- name: str,
- icon: str,
- unit_of_measurement: str,
- key: str,
+ *,
+ coordinator: WLEDDataUpdateCoordinator,
enabled_default: bool = True,
+ entry_id: str,
+ icon: str,
+ key: str,
+ name: str,
+ unit_of_measurement: Optional[str] = None,
) -> None:
"""Initialize WLED sensor."""
- self._state = None
self._unit_of_measurement = unit_of_measurement
self._key = key
- super().__init__(entry_id, wled, name, icon, enabled_default)
+ super().__init__(
+ entry_id=entry_id,
+ coordinator=coordinator,
+ name=name,
+ icon=icon,
+ enabled_default=enabled_default,
+ )
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
- return f"{self.wled.device.info.mac_address}_{self._key}"
-
- @property
- def state(self) -> Union[None, str, int, float]:
- """Return the state of the sensor."""
- return self._state
+ return f"{self.coordinator.data.info.mac_address}_{self._key}"
@property
def unit_of_measurement(self) -> str:
@@ -71,67 +81,160 @@ class WLEDSensor(WLEDDeviceEntity):
class WLEDEstimatedCurrentSensor(WLEDSensor):
"""Defines a WLED estimated current sensor."""
- def __init__(self, entry_id: str, wled: WLED) -> None:
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
"""Initialize WLED estimated current sensor."""
super().__init__(
- entry_id,
- wled,
- f"{wled.device.info.name} Estimated Current",
- "mdi:power",
- CURRENT_MA,
- "estimated_current",
+ coordinator=coordinator,
+ entry_id=entry_id,
+ icon="mdi:power",
+ key="estimated_current",
+ name=f"{coordinator.data.info.name} Estimated Current",
+ unit_of_measurement=CURRENT_MA,
)
- async def _wled_update(self) -> None:
- """Update WLED entity."""
- self._state = self.wled.device.info.leds.power
- self._attributes = {
- ATTR_LED_COUNT: self.wled.device.info.leds.count,
- ATTR_MAX_POWER: self.wled.device.info.leds.max_power,
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ return {
+ ATTR_LED_COUNT: self.coordinator.data.info.leds.count,
+ ATTR_MAX_POWER: self.coordinator.data.info.leds.max_power,
}
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.info.leds.power
+
class WLEDUptimeSensor(WLEDSensor):
"""Defines a WLED uptime sensor."""
- def __init__(self, entry_id: str, wled: WLED) -> None:
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
"""Initialize WLED uptime sensor."""
super().__init__(
- entry_id,
- wled,
- f"{wled.device.info.name} Uptime",
- "mdi:clock-outline",
- None,
- "uptime",
+ coordinator=coordinator,
enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:clock-outline",
+ key="uptime",
+ name=f"{coordinator.data.info.name} Uptime",
)
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime)
+ return uptime.replace(microsecond=0).isoformat()
+
@property
def device_class(self) -> Optional[str]:
"""Return the class of this sensor."""
return DEVICE_CLASS_TIMESTAMP
- async def _wled_update(self) -> None:
- """Update WLED uptime sensor."""
- uptime = utcnow() - timedelta(seconds=self.wled.device.info.uptime)
- self._state = uptime.replace(microsecond=0).isoformat()
-
class WLEDFreeHeapSensor(WLEDSensor):
"""Defines a WLED free heap sensor."""
- def __init__(self, entry_id: str, wled: WLED) -> None:
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
"""Initialize WLED free heap sensor."""
super().__init__(
- entry_id,
- wled,
- f"{wled.device.info.name} Free Memory",
- "mdi:memory",
- DATA_BYTES,
- "free_heap",
+ coordinator=coordinator,
enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:memory",
+ key="free_heap",
+ name=f"{coordinator.data.info.name} Free Memory",
+ unit_of_measurement=DATA_BYTES,
)
- async def _wled_update(self) -> None:
- """Update WLED uptime sensor."""
- self._state = self.wled.device.info.free_heap
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.info.free_heap
+
+
+class WLEDWifiSignalSensor(WLEDSensor):
+ """Defines a WLED Wi-Fi signal sensor."""
+
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
+ """Initialize WLED Wi-Fi signal sensor."""
+ super().__init__(
+ coordinator=coordinator,
+ enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:wifi",
+ key="wifi_signal",
+ name=f"{coordinator.data.info.name} Wi-Fi Signal",
+ unit_of_measurement=UNIT_PERCENTAGE,
+ )
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.info.wifi.signal
+
+
+class WLEDWifiRSSISensor(WLEDSensor):
+ """Defines a WLED Wi-Fi RSSI sensor."""
+
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
+ """Initialize WLED Wi-Fi RSSI sensor."""
+ super().__init__(
+ coordinator=coordinator,
+ enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:wifi",
+ key="wifi_rssi",
+ name=f"{coordinator.data.info.name} Wi-Fi RSSI",
+ unit_of_measurement=SIGNAL_DBM,
+ )
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.info.wifi.rssi
+
+ @property
+ def device_class(self) -> Optional[str]:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_SIGNAL_STRENGTH
+
+
+class WLEDWifiChannelSensor(WLEDSensor):
+ """Defines a WLED Wi-Fi Channel sensor."""
+
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
+ """Initialize WLED Wi-Fi Channel sensor."""
+ super().__init__(
+ coordinator=coordinator,
+ enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:wifi",
+ key="wifi_channel",
+ name=f"{coordinator.data.info.name} Wi-Fi Channel",
+ )
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.info.wifi.channel
+
+
+class WLEDWifiBSSIDSensor(WLEDSensor):
+ """Defines a WLED Wi-Fi BSSID sensor."""
+
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
+ """Initialize WLED Wi-Fi BSSID sensor."""
+ super().__init__(
+ coordinator=coordinator,
+ enabled_default=False,
+ entry_id=entry_id,
+ icon="mdi:wifi",
+ key="wifi_bssid",
+ name=f"{coordinator.data.info.name} Wi-Fi BSSID",
+ )
+
+ @property
+ def state(self) -> Union[None, str, int, float]:
+ """Return the state of the sensor."""
+ return self.coordinator.data.info.wifi.bssid
diff --git a/homeassistant/components/wled/services.yaml b/homeassistant/components/wled/services.yaml
new file mode 100644
index 00000000000..90b14125ad8
--- /dev/null
+++ b/homeassistant/components/wled/services.yaml
@@ -0,0 +1,18 @@
+effect:
+ description: Controls the effect settings of WLED
+ fields:
+ entity_id:
+ description: Name of the WLED light entity.
+ example: "light.wled"
+ effect:
+ description: Name or ID of the WLED light effect.
+ example: "Rainbow"
+ intensity:
+ description: Intensity of the effect
+ example: 100
+ speed:
+ description: Speed of the effect. Number between 0 (slow) and 255 (fast).
+ example: 150
+ reverse:
+ description: Reverse the effect. Either true to reverse or false otherwise.
+ example: false
diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py
index dcb41a1e49b..85a1f261d94 100644
--- a/homeassistant/components/wled/switch.py
+++ b/homeassistant/components/wled/switch.py
@@ -1,21 +1,18 @@
"""Support for WLED switches."""
import logging
-from typing import Any, Callable, List
-
-from wled import WLED, WLEDError
+from typing import Any, Callable, Dict, List, Optional
from homeassistant.components.switch import SwitchDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
-from . import WLEDDeviceEntity
+from . import WLEDDataUpdateCoordinator, WLEDDeviceEntity, wled_exception_handler
from .const import (
ATTR_DURATION,
ATTR_FADE,
ATTR_TARGET_BRIGHTNESS,
ATTR_UDP_PORT,
- DATA_WLED_CLIENT,
DOMAIN,
)
@@ -30,12 +27,12 @@ async def async_setup_entry(
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up WLED switch based on a config entry."""
- wled: WLED = hass.data[DOMAIN][entry.entry_id][DATA_WLED_CLIENT]
+ coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
switches = [
- WLEDNightlightSwitch(entry.entry_id, wled),
- WLEDSyncSendSwitch(entry.entry_id, wled),
- WLEDSyncReceiveSwitch(entry.entry_id, wled),
+ WLEDNightlightSwitch(entry.entry_id, coordinator),
+ WLEDSyncSendSwitch(entry.entry_id, coordinator),
+ WLEDSyncReceiveSwitch(entry.entry_id, coordinator),
]
async_add_entities(switches, True)
@@ -44,132 +41,127 @@ class WLEDSwitch(WLEDDeviceEntity, SwitchDevice):
"""Defines a WLED switch."""
def __init__(
- self, entry_id: str, wled: WLED, name: str, icon: str, key: str
+ self,
+ *,
+ entry_id: str,
+ coordinator: WLEDDataUpdateCoordinator,
+ name: str,
+ icon: str,
+ key: str,
) -> None:
"""Initialize WLED switch."""
self._key = key
- self._state = False
- super().__init__(entry_id, wled, name, icon)
+ super().__init__(
+ entry_id=entry_id, coordinator=coordinator, name=name, icon=icon
+ )
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
- return f"{self.wled.device.info.mac_address}_{self._key}"
-
- @property
- def is_on(self) -> bool:
- """Return the state of the switch."""
- return self._state
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn off the switch."""
- try:
- await self._wled_turn_off()
- self._state = False
- except WLEDError:
- _LOGGER.error("An error occurred while turning off WLED switch.")
- self._available = False
- self.async_schedule_update_ha_state()
-
- async def _wled_turn_off(self) -> None:
- """Turn off the switch."""
- raise NotImplementedError()
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn on the switch."""
- try:
- await self._wled_turn_on()
- self._state = True
- except WLEDError:
- _LOGGER.error("An error occurred while turning on WLED switch")
- self._available = False
- self.async_schedule_update_ha_state()
-
- async def _wled_turn_on(self) -> None:
- """Turn on the switch."""
- raise NotImplementedError()
+ return f"{self.coordinator.data.info.mac_address}_{self._key}"
class WLEDNightlightSwitch(WLEDSwitch):
"""Defines a WLED nightlight switch."""
- def __init__(self, entry_id: str, wled: WLED) -> None:
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
"""Initialize WLED nightlight switch."""
super().__init__(
- entry_id,
- wled,
- f"{wled.device.info.name} Nightlight",
- "mdi:weather-night",
- "nightlight",
+ coordinator=coordinator,
+ entry_id=entry_id,
+ icon="mdi:weather-night",
+ key="nightlight",
+ name=f"{coordinator.data.info.name} Nightlight",
)
- async def _wled_turn_off(self) -> None:
- """Turn off the WLED nightlight switch."""
- await self.wled.nightlight(on=False)
-
- async def _wled_turn_on(self) -> None:
- """Turn on the WLED nightlight switch."""
- await self.wled.nightlight(on=True)
-
- async def _wled_update(self) -> None:
- """Update WLED entity."""
- self._state = self.wled.device.state.nightlight.on
- self._attributes = {
- ATTR_DURATION: self.wled.device.state.nightlight.duration,
- ATTR_FADE: self.wled.device.state.nightlight.fade,
- ATTR_TARGET_BRIGHTNESS: self.wled.device.state.nightlight.target_brightness,
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ return {
+ ATTR_DURATION: self.coordinator.data.state.nightlight.duration,
+ ATTR_FADE: self.coordinator.data.state.nightlight.fade,
+ ATTR_TARGET_BRIGHTNESS: self.coordinator.data.state.nightlight.target_brightness,
}
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the switch."""
+ return bool(self.coordinator.data.state.nightlight.on)
+
+ @wled_exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the WLED nightlight switch."""
+ await self.coordinator.wled.nightlight(on=False)
+
+ @wled_exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the WLED nightlight switch."""
+ await self.coordinator.wled.nightlight(on=True)
+
class WLEDSyncSendSwitch(WLEDSwitch):
"""Defines a WLED sync send switch."""
- def __init__(self, entry_id: str, wled: WLED) -> None:
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator) -> None:
"""Initialize WLED sync send switch."""
super().__init__(
- entry_id,
- wled,
- f"{wled.device.info.name} Sync Send",
- "mdi:upload-network-outline",
- "sync_send",
+ coordinator=coordinator,
+ entry_id=entry_id,
+ icon="mdi:upload-network-outline",
+ key="sync_send",
+ name=f"{coordinator.data.info.name} Sync Send",
)
- async def _wled_turn_off(self) -> None:
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ return {ATTR_UDP_PORT: self.coordinator.data.info.udp_port}
+
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the switch."""
+ return bool(self.coordinator.data.state.sync.send)
+
+ @wled_exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the WLED sync send switch."""
- await self.wled.sync(send=False)
+ await self.coordinator.wled.sync(send=False)
- async def _wled_turn_on(self) -> None:
+ @wled_exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the WLED sync send switch."""
- await self.wled.sync(send=True)
-
- async def _wled_update(self) -> None:
- """Update WLED entity."""
- self._state = self.wled.device.state.sync.send
- self._attributes = {ATTR_UDP_PORT: self.wled.device.info.udp_port}
+ await self.coordinator.wled.sync(send=True)
class WLEDSyncReceiveSwitch(WLEDSwitch):
"""Defines a WLED sync receive switch."""
- def __init__(self, entry_id: str, wled: WLED):
+ def __init__(self, entry_id: str, coordinator: WLEDDataUpdateCoordinator):
"""Initialize WLED sync receive switch."""
super().__init__(
- entry_id,
- wled,
- f"{wled.device.info.name} Sync Receive",
- "mdi:download-network-outline",
- "sync_receive",
+ coordinator=coordinator,
+ entry_id=entry_id,
+ icon="mdi:download-network-outline",
+ key="sync_receive",
+ name=f"{coordinator.data.info.name} Sync Receive",
)
- async def _wled_turn_off(self) -> None:
+ @property
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
+ """Return the state attributes of the entity."""
+ return {ATTR_UDP_PORT: self.coordinator.data.info.udp_port}
+
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the switch."""
+ return bool(self.coordinator.data.state.sync.receive)
+
+ @wled_exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the WLED sync receive switch."""
- await self.wled.sync(receive=False)
+ await self.coordinator.wled.sync(receive=False)
- async def _wled_turn_on(self) -> None:
+ @wled_exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the WLED sync receive switch."""
- await self.wled.sync(receive=True)
-
- async def _wled_update(self) -> None:
- """Update WLED entity."""
- self._state = self.wled.device.state.sync.receive
- self._attributes = {ATTR_UDP_PORT: self.wled.device.info.udp_port}
+ await self.coordinator.wled.sync(receive=True)
diff --git a/homeassistant/components/wwlln/.translations/bg.json b/homeassistant/components/wwlln/.translations/bg.json
index c083218c443..f252518fcab 100644
--- a/homeassistant/components/wwlln/.translations/bg.json
+++ b/homeassistant/components/wwlln/.translations/bg.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u043e"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/ca.json b/homeassistant/components/wwlln/.translations/ca.json
index acf8ec7c518..f7fe15f27ec 100644
--- a/homeassistant/components/wwlln/.translations/ca.json
+++ b/homeassistant/components/wwlln/.translations/ca.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Ubicaci\u00f3 ja registrada"
+ "abort": {
+ "already_configured": "Aquesta ubicaci\u00f3 ja est\u00e0 registrada."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/cy.json b/homeassistant/components/wwlln/.translations/cy.json
index e9de2acbdc6..6050207304f 100644
--- a/homeassistant/components/wwlln/.translations/cy.json
+++ b/homeassistant/components/wwlln/.translations/cy.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Enw eisoes wedi gofrestru"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/da.json b/homeassistant/components/wwlln/.translations/da.json
index 5d4f4c40b5d..df10f39657a 100644
--- a/homeassistant/components/wwlln/.translations/da.json
+++ b/homeassistant/components/wwlln/.translations/da.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Lokalitet er allerede registreret"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/de.json b/homeassistant/components/wwlln/.translations/de.json
index 651e2e6fa0f..487f2294dc6 100644
--- a/homeassistant/components/wwlln/.translations/de.json
+++ b/homeassistant/components/wwlln/.translations/de.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Standort bereits registriert"
+ "abort": {
+ "already_configured": "Dieser Standort ist bereits registriert."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/es-419.json b/homeassistant/components/wwlln/.translations/es-419.json
index d185410a4ef..6b2e5d23ffb 100644
--- a/homeassistant/components/wwlln/.translations/es-419.json
+++ b/homeassistant/components/wwlln/.translations/es-419.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Lugar ya registrado"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/es.json b/homeassistant/components/wwlln/.translations/es.json
index 869e8d07994..22eb2c1e704 100644
--- a/homeassistant/components/wwlln/.translations/es.json
+++ b/homeassistant/components/wwlln/.translations/es.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Ubicaci\u00f3n ya registrada"
+ "abort": {
+ "already_configured": "Esta ubicaci\u00f3n ya est\u00e1 registrada."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/fr.json b/homeassistant/components/wwlln/.translations/fr.json
index d76582e4127..d19114286ad 100644
--- a/homeassistant/components/wwlln/.translations/fr.json
+++ b/homeassistant/components/wwlln/.translations/fr.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Emplacement d\u00e9j\u00e0 enregistr\u00e9"
+ "abort": {
+ "already_configured": "Cet emplacement est d\u00e9j\u00e0 enregistr\u00e9."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/hr.json b/homeassistant/components/wwlln/.translations/hr.json
index 09ca1a0273f..3dec14ffa17 100644
--- a/homeassistant/components/wwlln/.translations/hr.json
+++ b/homeassistant/components/wwlln/.translations/hr.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Lokacija je ve\u0107 registrirana"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/it.json b/homeassistant/components/wwlln/.translations/it.json
index f0fc3263607..1733cfdf172 100644
--- a/homeassistant/components/wwlln/.translations/it.json
+++ b/homeassistant/components/wwlln/.translations/it.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Localit\u00e0 gi\u00e0 registrata"
+ "abort": {
+ "already_configured": "Questa posizione \u00e8 gi\u00e0 registrata."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/ko.json b/homeassistant/components/wwlln/.translations/ko.json
index e5831f5af29..a71ebe3ea0c 100644
--- a/homeassistant/components/wwlln/.translations/ko.json
+++ b/homeassistant/components/wwlln/.translations/ko.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ "abort": {
+ "already_configured": "\uc774 \uc704\uce58\ub294 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/lb.json b/homeassistant/components/wwlln/.translations/lb.json
index c6d969894e7..9632cb372b2 100644
--- a/homeassistant/components/wwlln/.translations/lb.json
+++ b/homeassistant/components/wwlln/.translations/lb.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Standuert ass scho registr\u00e9iert"
+ "abort": {
+ "already_configured": "D\u00ebse Standuert ass scho registr\u00e9iert"
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/nl.json b/homeassistant/components/wwlln/.translations/nl.json
index 8cf0e80806d..542c53f0c03 100644
--- a/homeassistant/components/wwlln/.translations/nl.json
+++ b/homeassistant/components/wwlln/.translations/nl.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Locatie al geregistreerd"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/no.json b/homeassistant/components/wwlln/.translations/no.json
index ea3b5cd1056..fab8810ba5e 100644
--- a/homeassistant/components/wwlln/.translations/no.json
+++ b/homeassistant/components/wwlln/.translations/no.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Lokasjon allerede registrert"
+ "abort": {
+ "already_configured": "Denne plasseringen er allerede registrert."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/pl.json b/homeassistant/components/wwlln/.translations/pl.json
index 658dbebbe45..22d84209b7f 100644
--- a/homeassistant/components/wwlln/.translations/pl.json
+++ b/homeassistant/components/wwlln/.translations/pl.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Lokalizacja jest ju\u017c zarejestrowana."
+ "abort": {
+ "already_configured": "Ta lokalizacja jest ju\u017c zarejestrowana."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/pt-BR.json b/homeassistant/components/wwlln/.translations/pt-BR.json
index 30b39a4431c..296588f66a8 100644
--- a/homeassistant/components/wwlln/.translations/pt-BR.json
+++ b/homeassistant/components/wwlln/.translations/pt-BR.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Localiza\u00e7\u00e3o j\u00e1 registrada"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/ru.json b/homeassistant/components/wwlln/.translations/ru.json
index 3bdaf85498b..b67d70e057b 100644
--- a/homeassistant/components/wwlln/.translations/ru.json
+++ b/homeassistant/components/wwlln/.translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "\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."
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/sl.json b/homeassistant/components/wwlln/.translations/sl.json
index d6562a2a247..11fc4f00db8 100644
--- a/homeassistant/components/wwlln/.translations/sl.json
+++ b/homeassistant/components/wwlln/.translations/sl.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Lokacija je \u017ee registrirana"
+ "abort": {
+ "already_configured": "Ta lokacija je \u017ee registrirana."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/.translations/sv.json b/homeassistant/components/wwlln/.translations/sv.json
index 4aa525f7a2a..3180c543452 100644
--- a/homeassistant/components/wwlln/.translations/sv.json
+++ b/homeassistant/components/wwlln/.translations/sv.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "Platsen \u00e4r redan registrerad"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/zh-Hans.json b/homeassistant/components/wwlln/.translations/zh-Hans.json
index d719802ad7a..e53d33512e1 100644
--- a/homeassistant/components/wwlln/.translations/zh-Hans.json
+++ b/homeassistant/components/wwlln/.translations/zh-Hans.json
@@ -1,8 +1,5 @@
{
"config": {
- "error": {
- "identifier_exists": "\u4f4d\u7f6e\u5df2\u7ecf\u6ce8\u518c"
- },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/wwlln/.translations/zh-Hant.json b/homeassistant/components/wwlln/.translations/zh-Hant.json
index 710ee882a9c..fac13ffe77f 100644
--- a/homeassistant/components/wwlln/.translations/zh-Hant.json
+++ b/homeassistant/components/wwlln/.translations/zh-Hant.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "\u5ea7\u6a19\u5df2\u8a3b\u518a"
+ "abort": {
+ "already_configured": "\u6b64\u4f4d\u7f6e\u5df2\u8a3b\u518a\u3002"
},
"step": {
"user": {
diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py
index 2605823a99d..59863464d21 100644
--- a/homeassistant/components/yeelight/light.py
+++ b/homeassistant/components/yeelight/light.py
@@ -141,6 +141,7 @@ MODEL_TO_DEVICE_TYPE = {
"ceiling2": BulbType.WhiteTemp,
"ceiling3": BulbType.WhiteTemp,
"ceiling4": BulbType.WhiteTempMood,
+ "ceiling13": BulbType.WhiteTemp,
}
EFFECTS_MAP = {
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index 1a181536d0b..c5396030813 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -2,7 +2,7 @@
"domain": "yeelight",
"name": "Yeelight",
"documentation": "https://www.home-assistant.io/integrations/yeelight",
- "requirements": ["yeelight==0.5.0"],
+ "requirements": ["yeelight==0.5.1"],
"dependencies": [],
"after_dependencies": ["discovery"],
"codeowners": ["@rytilahti", "@zewelor"]
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index 206f529344f..16a7d2f000c 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -132,7 +132,11 @@ def handle_homekit(hass, info) -> bool:
return False
for test_model in HOMEKIT:
- if model != test_model and not model.startswith(test_model + " "):
+ if (
+ model != test_model
+ and not model.startswith(test_model + " ")
+ and not model.startswith(test_model + "-")
+ ):
continue
hass.add_job(
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index b0808d83d68..3171b8e953b 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -2,7 +2,7 @@
"domain": "zeroconf",
"name": "Zero-configuration networking (zeroconf)",
"documentation": "https://www.home-assistant.io/integrations/zeroconf",
- "requirements": ["zeroconf==0.24.5"],
+ "requirements": ["zeroconf==0.25.0"],
"dependencies": ["api"],
"codeowners": ["@robbiet480", "@Kane610"],
"quality_scale": "internal"
diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json
index e5181fb5106..9ad486f5041 100644
--- a/homeassistant/components/zha/.translations/ca.json
+++ b/homeassistant/components/zha/.translations/ca.json
@@ -54,6 +54,14 @@
"device_shaken": "Dispositiu sacsejat",
"device_slid": "Dispositiu lliscat a \"{subtype}\"",
"device_tilted": "Dispositiu inclinat",
+ "remote_button_alt_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades (mode alternatiu)",
+ "remote_button_alt_long_press": "Bot\u00f3 \"{subtype}\" premut cont\u00ednuament (mode alternatiu)",
+ "remote_button_alt_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut (mode alternatiu",
+ "remote_button_alt_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades (mode alternatiu)",
+ "remote_button_alt_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades (mode alternatiu)",
+ "remote_button_alt_short_press": "Bot\u00f3 \"{subtype}\" premut (mode alternatiu)",
+ "remote_button_alt_short_release": "Bot\u00f3 \"{subtype}\" alliberat (mode alternatiu)",
+ "remote_button_alt_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades (mode alternatiu)",
"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",
diff --git a/homeassistant/components/zha/.translations/de.json b/homeassistant/components/zha/.translations/de.json
index 3329eafa1c6..f7a00fcfa7f 100644
--- a/homeassistant/components/zha/.translations/de.json
+++ b/homeassistant/components/zha/.translations/de.json
@@ -54,6 +54,14 @@
"device_shaken": "Ger\u00e4t ersch\u00fcttert",
"device_slid": "Ger\u00e4t gerutscht \"{subtype}\"",
"device_tilted": "Ger\u00e4t gekippt",
+ "remote_button_alt_double_press": "\"{subtype}\" Taste doppelt geklickt (Alternativer Modus)",
+ "remote_button_alt_long_press": "\"{subtype}\" Taste kontinuierlich gedr\u00fcckt (Alternativer Modus)",
+ "remote_button_alt_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen (Alternativer Modus)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" Taste vierfach geklickt (Alternativer Modus)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" Taste f\u00fcnffach geklickt (Alternativer Modus)",
+ "remote_button_alt_short_press": "\"{subtype}\" Taste gedr\u00fcckt (Alternativer Modus)",
+ "remote_button_alt_short_release": "\"{subtype}\" Taste losgelassen (Alternativer Modus)",
+ "remote_button_alt_triple_press": "\"{subtype}\" Taste dreimal geklickt (Alternativer Modus)",
"remote_button_double_press": "\"{subtype}\" Taste doppelt angeklickt",
"remote_button_long_press": "\"{subtype}\" Taste kontinuierlich gedr\u00fcckt",
"remote_button_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen",
diff --git a/homeassistant/components/zha/.translations/fr.json b/homeassistant/components/zha/.translations/fr.json
index 5d8bdfa82eb..99905bba836 100644
--- a/homeassistant/components/zha/.translations/fr.json
+++ b/homeassistant/components/zha/.translations/fr.json
@@ -54,7 +54,7 @@
"device_shaken": "Appareil secou\u00e9",
"device_slid": "Appareil gliss\u00e9 \"{subtype}\"",
"device_tilted": "Dispositif inclin\u00e9",
- "remote_button_double_press": "Bouton \"{subtype}\" double cliqu\u00e9",
+ "remote_button_double_press": "Double clic sur le bouton \" {subtype} \"",
"remote_button_long_press": "Bouton \"{subtype}\" appuy\u00e9 continuellement",
"remote_button_long_release": "Bouton \" {subtype} \" rel\u00e2ch\u00e9 apr\u00e8s un appui long",
"remote_button_quadruple_press": "bouton \" {subtype} \" quadruple clics",
diff --git a/homeassistant/components/zha/.translations/it.json b/homeassistant/components/zha/.translations/it.json
index bb05977fd09..5048ce52599 100644
--- a/homeassistant/components/zha/.translations/it.json
+++ b/homeassistant/components/zha/.translations/it.json
@@ -54,6 +54,14 @@
"device_shaken": "Dispositivo in vibrazione",
"device_slid": "Dispositivo scivolato \"{sottotipo}\"",
"device_tilted": "Dispositivo inclinato",
+ "remote_button_alt_double_press": "Pulsante \"{subtype}\" cliccato due volte (modalit\u00e0 Alternata)",
+ "remote_button_alt_long_press": "Pulsante \"{subtype}\" premuto continuamente (modalit\u00e0 Alternata)",
+ "remote_button_alt_long_release": "Pulsante \"{subtype}\" rilasciato dopo una lunga pressione (modalit\u00e0 Alternata)",
+ "remote_button_alt_quadruple_press": "Pulsante \"{subtype}\" cliccato quattro volte (modalit\u00e0 Alternata)",
+ "remote_button_alt_quintuple_press": "Pulsante \"{subtype}\" cliccato cinque volte (modalit\u00e0 Alternata)",
+ "remote_button_alt_short_press": "Pulsante \"{subtype}\" premuto (modalit\u00e0 Alternata)",
+ "remote_button_alt_short_release": "Pulsante \"{subtype}\" rilasciato (modalit\u00e0 Alternata)",
+ "remote_button_alt_triple_press": "Pulsante \"{subtype}\" cliccato tre volte (modalit\u00e0 Alternata)",
"remote_button_double_press": "Pulsante \"{subtype}\" cliccato due volte",
"remote_button_long_press": "Pulsante \"{subtype}\" premuto continuamente",
"remote_button_long_release": "Pulsante \"{subtype}\" rilasciato dopo una lunga pressione",
diff --git a/homeassistant/components/zha/.translations/ko.json b/homeassistant/components/zha/.translations/ko.json
index 69b8f9ad9a4..76a10d2c976 100644
--- a/homeassistant/components/zha/.translations/ko.json
+++ b/homeassistant/components/zha/.translations/ko.json
@@ -54,6 +54,14 @@
"device_shaken": "\uae30\uae30\uac00 \ud754\ub4e4\ub9b4 \ub54c",
"device_slid": "\"{subtype}\" \uae30\uae30\uac00 \ubbf8\ub044\ub7ec\uc9c8 \ub54c",
"device_tilted": "\uae30\uae30\uac00 \uae30\uc6b8\uc5b4\uc9c8 \ub54c",
+ "remote_button_alt_double_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub450 \ubc88 \ub20c\ub9b4 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
+ "remote_button_alt_long_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uacc4\uc18d \ub20c\ub824\uc9c8 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
+ "remote_button_alt_long_release": "\"{subtype}\" \ubc84\ud2bc\uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c (\ub300\uccb4\ubaa8\ub4dc)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub124 \ubc88 \ub20c\ub9b4 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub2e4\uc12f \ubc88 \ub20c\ub9b4 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
+ "remote_button_alt_short_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub20c\ub9b4 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
+ "remote_button_alt_short_release": "\"{subtype}\" \ubc84\ud2bc\uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
+ "remote_button_alt_triple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uc138 \ubc88 \ub20c\ub9b4 \ub54c (\ub300\uccb4 \ubaa8\ub4dc)",
"remote_button_double_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub450 \ubc88 \ub20c\ub9b4 \ub54c",
"remote_button_long_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uacc4\uc18d \ub20c\ub824\uc9c8 \ub54c",
"remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c",
diff --git a/homeassistant/components/zha/.translations/no.json b/homeassistant/components/zha/.translations/no.json
index a08761ac4b6..656926017cf 100644
--- a/homeassistant/components/zha/.translations/no.json
+++ b/homeassistant/components/zha/.translations/no.json
@@ -12,10 +12,10 @@
"radio_type": "Radio type",
"usb_path": "USB enhetsbane"
},
- "title": "ZHA"
+ "title": ""
}
},
- "title": "ZHA"
+ "title": ""
},
"device_automation": {
"action_type": {
diff --git a/homeassistant/components/zha/.translations/pl.json b/homeassistant/components/zha/.translations/pl.json
index 4698a0a37ef..bf651fb16ed 100644
--- a/homeassistant/components/zha/.translations/pl.json
+++ b/homeassistant/components/zha/.translations/pl.json
@@ -54,6 +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_alt_double_press": "\"{subtype}\" dwukrotnie naci\u015bni\u0119ty (tryb alternatywny)",
+ "remote_button_alt_long_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y (tryb alternatywny)",
+ "remote_button_alt_long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu (tryb alternatywny)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" czterokrotnie naci\u015bni\u0119ty (tryb alternatywny)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty (tryb alternatywny)",
+ "remote_button_alt_short_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty (tryb alternatywny)",
+ "remote_button_alt_short_release": "\"{subtype}\" zostanie zwolniony (tryb alternatywny)",
+ "remote_button_alt_triple_press": "\"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty (tryb alternatywny)",
"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",
diff --git a/homeassistant/components/zha/.translations/sl.json b/homeassistant/components/zha/.translations/sl.json
index 226bd37200e..53a45000701 100644
--- a/homeassistant/components/zha/.translations/sl.json
+++ b/homeassistant/components/zha/.translations/sl.json
@@ -54,6 +54,14 @@
"device_shaken": "Naprava se je pretresla",
"device_slid": "Naprava zdrsnila \"{subtype}\"",
"device_tilted": "Naprava je nagnjena",
+ "remote_button_alt_double_press": "Dvojni klik gumba \" {subtype} \" (nadomestni na\u010din)",
+ "remote_button_alt_long_press": "Gumb \" {subtype} \" neprekinjeno pritisnjen (nadomestni na\u010din)",
+ "remote_button_alt_long_release": "\"{Subtype}\" gumb spro\u0161\u010den po dolgem pritisku (nadomestni na\u010din)",
+ "remote_button_alt_quadruple_press": "\u0161tirikrat kliknjen gumb \" {subtype} \" (nadomestni na\u010din)",
+ "remote_button_alt_quintuple_press": "Petkrat kliknjen \"{podtipa}\" gumb (Nadomestni na\u010din)",
+ "remote_button_alt_short_press": "pritisnjen gumb \" {subtype} \" (nadomestni na\u010din)",
+ "remote_button_alt_short_release": "Gumb \" {subtype} \" spro\u0161\u010den (nadomestni na\u010din)",
+ "remote_button_alt_triple_press": "Trikrat kliknjen gumb \" {subtype} \" (nadomestni na\u010din)",
"remote_button_double_press": "Dvakrat kliknete gumb \"{subtype}\"",
"remote_button_long_press": "\"{subtype}\" gumb neprekinjeno pritisnjen",
"remote_button_long_release": "\"{subtype}\" gumb spro\u0161\u010den po dolgem pritisku",
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
index 5fef586d5cf..2af35e8fb92 100644
--- a/homeassistant/components/zha/__init__.py
+++ b/homeassistant/components/zha/__init__.py
@@ -130,7 +130,7 @@ async def async_setup_entry(hass, config_entry):
await zha_data[DATA_ZHA_GATEWAY].async_update_device_storage()
hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown)
- asyncio.create_task(async_load_entities(hass, config_entry))
+ asyncio.create_task(async_load_entities(hass))
return True
@@ -150,11 +150,9 @@ async def async_unload_entry(hass, config_entry):
return True
-async def async_load_entities(
- hass: HomeAssistantType, config_entry: config_entries.ConfigEntry
-) -> None:
+async def async_load_entities(hass: HomeAssistantType) -> None:
"""Load entities after integration was setup."""
- await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_prepare_entities()
+ await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_initialize_devices_and_entities()
to_setup = hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED]
results = await asyncio.gather(*to_setup, return_exceptions=True)
for res in results:
diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py
index ea5586ef96f..f3b6e2eebd9 100644
--- a/homeassistant/components/zha/api.py
+++ b/homeassistant/components/zha/api.py
@@ -950,6 +950,15 @@ def async_load_api(hass):
schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND],
)
+ def _get_ias_wd_channel(zha_device):
+ """Get the IASWD channel for a device."""
+ cluster_channels = {
+ ch.name: ch
+ for pool in zha_device.channels.pools
+ for ch in pool.claimed_channels.values()
+ }
+ return cluster_channels.get(CHANNEL_IAS_WD)
+
async def warning_device_squawk(service):
"""Issue the squawk command for an IAS warning device."""
ieee = service.data[ATTR_IEEE]
@@ -959,9 +968,9 @@ def async_load_api(hass):
zha_device = zha_gateway.get_device(ieee)
if zha_device is not None:
- channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD)
+ channel = _get_ias_wd_channel(zha_device)
if channel:
- await channel.squawk(mode, strobe, level)
+ await channel.issue_squawk(mode, strobe, level)
else:
_LOGGER.error(
"Squawking IASWD: %s: [%s] is missing the required IASWD channel!",
@@ -1003,9 +1012,9 @@ def async_load_api(hass):
zha_device = zha_gateway.get_device(ieee)
if zha_device is not None:
- channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD)
+ channel = _get_ias_wd_channel(zha_device)
if channel:
- await channel.start_warning(
+ await channel.issue_start_warning(
mode, strobe, level, duration, duty_mode, intensity
)
else:
diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py
index a4848fbaa63..91a23e17f12 100644
--- a/homeassistant/components/zha/core/channels/__init__.py
+++ b/homeassistant/components/zha/core/channels/__init__.py
@@ -163,12 +163,12 @@ class ChannelPool:
self._channels: Channels = channels
self._claimed_channels: ChannelsDict = {}
self._id: int = ep_id
- self._relay_channels: Dict[str, zha_typing.EventRelayChannelType] = {}
+ self._client_channels: Dict[str, zha_typing.ClientChannelType] = {}
self._unique_id: str = f"{channels.unique_id}-{ep_id}"
@property
def all_channels(self) -> ChannelsDict:
- """All channels of an endpoint."""
+ """All server channels of an endpoint."""
return self._all_channels
@property
@@ -176,6 +176,11 @@ class ChannelPool:
"""Channels in use."""
return self._claimed_channels
+ @property
+ def client_channels(self) -> Dict[str, zha_typing.ClientChannelType]:
+ """Return a dict of client channels."""
+ return self._client_channels
+
@property
def endpoint(self) -> zha_typing.ZigpyEndpointType:
"""Return endpoint of zigpy device."""
@@ -216,11 +221,6 @@ class ChannelPool:
"""Return device model."""
return self._channels.zha_device.model
- @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."""
@@ -236,7 +236,7 @@ class ChannelPool:
"""Create new channels for an endpoint."""
pool = cls(channels, ep_id)
pool.add_all_channels()
- pool.add_relay_channels()
+ pool.add_client_channels()
zha_disc.PROBE.discover_entities(pool)
return pool
@@ -270,13 +270,13 @@ class ChannelPool:
self.all_channels[channel.id] = channel
@callback
- 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:
+ def add_client_channels(self) -> None:
+ """Create client channels for all output clusters if in the registry."""
+ for cluster_id, channel_class in zha_regs.CLIENT_CHANNELS_REGISTRY.items():
cluster = self.endpoint.out_clusters.get(cluster_id)
if cluster is not None:
- channel = base.EventRelayChannel(cluster, self)
- self.relay_channels[channel.id] = channel
+ channel = channel_class(cluster, self)
+ self.client_channels[channel.id] = channel
async def async_initialize(self, from_cache: bool = False) -> None:
"""Initialize claimed channels."""
@@ -293,7 +293,7 @@ class ChannelPool:
async with self._channels.semaphore:
return await coro
- channels = [*self.claimed_channels.values(), *self.relay_channels.values()]
+ channels = [*self.claimed_channels.values(), *self.client_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):
diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py
index dfe564ec2c1..a5255e7f756 100644
--- a/homeassistant/components/zha/core/channels/base.py
+++ b/homeassistant/components/zha/core/channels/base.py
@@ -19,11 +19,7 @@ from ..const import (
ATTR_COMMAND,
ATTR_UNIQUE_ID,
ATTR_VALUE,
- CHANNEL_EVENT_RELAY,
CHANNEL_ZDO,
- REPORT_CONFIG_MAX_INT,
- REPORT_CONFIG_MIN_INT,
- REPORT_CONFIG_RPT_CHANGE,
SIGNAL_ATTR_UPDATED,
)
from ..helpers import LogMixin, safe_read
@@ -78,7 +74,6 @@ class ChannelStatus(Enum):
class ZigbeeChannel(LogMixin):
"""Base channel for a Zigbee cluster."""
- CHANNEL_NAME = None
REPORT_CONFIG = ()
def __init__(
@@ -87,8 +82,6 @@ class ZigbeeChannel(LogMixin):
"""Initialize ZigbeeChannel."""
self._generic_id = f"channel_0x{cluster.cluster_id:04x}"
self._channel_name = getattr(cluster, "ep_attribute", self._generic_id)
- if self.CHANNEL_NAME:
- self._channel_name = self.CHANNEL_NAME
self._ch_pool = ch_pool
self._cluster = cluster
self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}"
@@ -153,57 +146,47 @@ class ZigbeeChannel(LogMixin):
"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,
- ),
- ):
+ async def configure_reporting(self) -> None:
"""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),
- )
+ for report in self._report_config:
+ attr = report["attr"]
+ attr_name = self.cluster.attributes.get(attr, [attr])[0]
+ 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 self.configure_reporting()
self.debug("finished channel configuration")
else:
self.debug("skipping channel configuration")
@@ -361,10 +344,8 @@ class ZDOChannel(LogMixin):
_LOGGER.log(level, msg, *args)
-class EventRelayChannel(ZigbeeChannel):
- """Event relay that can be attached to zigbee clusters."""
-
- CHANNEL_NAME = CHANNEL_EVENT_RELAY
+class ClientChannel(ZigbeeChannel):
+ """Channel listener for Zigbee client (output) clusters."""
@callback
def attribute_updated(self, attrid, value):
diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py
index 783188248f3..f2afadbd657 100644
--- a/homeassistant/components/zha/core/channels/general.py
+++ b/homeassistant/components/zha/core/channels/general.py
@@ -19,8 +19,9 @@ from ..const import (
SIGNAL_MOVE_LEVEL,
SIGNAL_SET_LEVEL,
SIGNAL_STATE_ATTR,
+ SIGNAL_UPDATE_DEVICE,
)
-from .base import ZigbeeChannel, parse_and_log_command
+from .base import ClientChannel, ZigbeeChannel, parse_and_log_command
_LOGGER = logging.getLogger(__name__)
@@ -166,8 +167,14 @@ class Identify(ZigbeeChannel):
self.async_send_signal(f"{self.unique_id}_{cmd}", args[0])
+@registries.CLIENT_CHANNELS_REGISTRY.register(general.LevelControl.cluster_id)
+class LevelControlClientChannel(ClientChannel):
+ """LevelControl client cluster."""
+
+ pass
+
+
@registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id)
-@registries.EVENT_RELAY_CLUSTERS.register(general.LevelControl.cluster_id)
@registries.LIGHT_CLUSTERS.register(general.LevelControl.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.LevelControl.cluster_id)
class LevelControlChannel(ZigbeeChannel):
@@ -233,9 +240,15 @@ class MultistateValue(ZigbeeChannel):
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
+@registries.CLIENT_CHANNELS_REGISTRY.register(general.OnOff.cluster_id)
+class OnOffClientChannel(ClientChannel):
+ """OnOff client channel."""
+
+ pass
+
+
@registries.BINARY_SENSOR_CLUSTERS.register(general.OnOff.cluster_id)
@registries.BINDABLE_CLUSTERS.register(general.OnOff.cluster_id)
-@registries.EVENT_RELAY_CLUSTERS.register(general.OnOff.cluster_id)
@registries.LIGHT_CLUSTERS.register(general.OnOff.cluster_id)
@registries.SWITCH_CLUSTERS.register(general.OnOff.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.OnOff.cluster_id)
@@ -321,11 +334,20 @@ class OnOffConfiguration(ZigbeeChannel):
pass
+@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Ota.cluster_id)
class Ota(ZigbeeChannel):
"""OTA Channel."""
- pass
+ @callback
+ def cluster_command(
+ self, tsn: int, command_id: int, args: Optional[List[Any]]
+ ) -> None:
+ """Handle OTA commands."""
+ cmd_name = self.cluster.server_commands.get(command_id, [command_id])[0]
+ signal_id = self._ch_pool.unique_id.split("-")[0]
+ if cmd_name == "query_next_image":
+ self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3])
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Partition.cluster_id)
@@ -437,7 +459,13 @@ class RSSILocation(ZigbeeChannel):
pass
-@registries.EVENT_RELAY_CLUSTERS.register(general.Scenes.cluster_id)
+@registries.CLIENT_CHANNELS_REGISTRY.register(general.Scenes.cluster_id)
+class ScenesClientChannel(ClientChannel):
+ """Scenes channel."""
+
+ pass
+
+
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Scenes.cluster_id)
class Scenes(ZigbeeChannel):
"""Scenes channel."""
diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py
index 7dc98d04515..25f6c05d739 100644
--- a/homeassistant/components/zha/core/channels/lighting.py
+++ b/homeassistant/components/zha/core/channels/lighting.py
@@ -5,7 +5,7 @@ import zigpy.zcl.clusters.lighting as lighting
from .. import registries, typing as zha_typing
from ..const import REPORT_CONFIG_DEFAULT
-from .base import ZigbeeChannel
+from .base import ClientChannel, ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -17,8 +17,14 @@ class Ballast(ZigbeeChannel):
pass
+@registries.CLIENT_CHANNELS_REGISTRY.register(lighting.Color.cluster_id)
+class ColorClientChannel(ClientChannel):
+ """Color client channel."""
+
+ pass
+
+
@registries.BINDABLE_CLUSTERS.register(lighting.Color.cluster_id)
-@registries.EVENT_RELAY_CLUSTERS.register(lighting.Color.cluster_id)
@registries.LIGHT_CLUSTERS.register(lighting.Color.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Color.cluster_id)
class ColorChannel(ZigbeeChannel):
diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py
index 2616161de03..822ae8dd911 100644
--- a/homeassistant/components/zha/core/channels/security.py
+++ b/homeassistant/components/zha/core/channels/security.py
@@ -51,7 +51,7 @@ class IasWd(ZigbeeChannel):
"""Get the specified bit from the value."""
return (value & (1 << bit)) != 0
- async def squawk(
+ async def issue_squawk(
self,
mode=WARNING_DEVICE_SQUAWK_MODE_ARMED,
strobe=WARNING_DEVICE_STROBE_YES,
@@ -76,7 +76,7 @@ class IasWd(ZigbeeChannel):
await self.squawk(value)
- async def start_warning(
+ async def issue_start_warning(
self,
mode=WARNING_DEVICE_MODE_EMERGENCY,
strobe=WARNING_DEVICE_STROBE_YES,
diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py
index 4b5a5a0c6a1..da151f67dbb 100644
--- a/homeassistant/components/zha/core/const.py
+++ b/homeassistant/components/zha/core/const.py
@@ -206,6 +206,9 @@ SIGNAL_MOVE_LEVEL = "move_level"
SIGNAL_REMOVE = "remove"
SIGNAL_SET_LEVEL = "set_level"
SIGNAL_STATE_ATTR = "update_state_attribute"
+SIGNAL_UPDATE_DEVICE = "{}_zha_update_device"
+SIGNAL_REMOVE_GROUP = "remove_group"
+SIGNAL_GROUP_MEMBERSHIP_CHANGE = "group_membership_change"
UNKNOWN = "unknown"
UNKNOWN_MANUFACTURER = "unk_manufacturer"
diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py
index f2544b43882..ad3d1ff18ad 100644
--- a/homeassistant/components/zha/core/device.py
+++ b/homeassistant/components/zha/core/device.py
@@ -54,6 +54,7 @@ from .const import (
POWER_BATTERY_OR_UNKNOWN,
POWER_MAINS_POWERED,
SIGNAL_AVAILABLE,
+ SIGNAL_UPDATE_DEVICE,
UNKNOWN,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
@@ -92,8 +93,11 @@ class ZHADevice(LogMixin):
self.name, self.ieee, SIGNAL_AVAILABLE
)
self._checkins_missed_count = 0
- self._unsub = async_dispatcher_connect(
- self.hass, self._available_signal, self.async_initialize
+ self.unsubs = []
+ self.unsubs.append(
+ async_dispatcher_connect(
+ self.hass, self._available_signal, self.async_initialize
+ )
)
self.quirk_applied = isinstance(self._zigpy_device, zigpy.quirks.CustomDevice)
self.quirk_class = "{}.{}".format(
@@ -105,8 +109,10 @@ class ZHADevice(LogMixin):
else:
self._consider_unavailable_time = _CONSIDER_UNAVAILABLE_BATTERY
keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL)
- self._cancel_available_check = async_track_time_interval(
- self.hass, self._check_available, timedelta(seconds=keep_alive_interval)
+ self.unsubs.append(
+ async_track_time_interval(
+ self.hass, self._check_available, timedelta(seconds=keep_alive_interval)
+ )
)
self._ha_device_id = None
self.status = DeviceStatus.CREATED
@@ -276,8 +282,24 @@ class ZHADevice(LogMixin):
"""Create new device."""
zha_dev = cls(hass, zigpy_dev, gateway)
zha_dev.channels = channels.Channels.new(zha_dev)
+ zha_dev.unsubs.append(
+ async_dispatcher_connect(
+ hass,
+ SIGNAL_UPDATE_DEVICE.format(zha_dev.channels.unique_id),
+ zha_dev.async_update_sw_build_id,
+ )
+ )
return zha_dev
+ @callback
+ def async_update_sw_build_id(self, sw_version: int):
+ """Update device sw version."""
+ if self.device_id is None:
+ return
+ self._zha_gateway.ha_device_registry.async_update_device(
+ self.device_id, sw_version=f"0x{sw_version:08x}"
+ )
+
async def _check_available(self, *_):
if self.last_seen is None:
self.update_available(False)
@@ -351,7 +373,7 @@ class ZHADevice(LogMixin):
self.debug("started configuration")
await self._channels.async_configure()
self.debug("completed configuration")
- entry = self.gateway.zha_storage.async_create_or_update(self)
+ entry = self.gateway.zha_storage.async_create_or_update_device(self)
self.debug("stored in registry: %s", entry)
if self._channels.identify_ch is not None:
@@ -370,13 +392,14 @@ class ZHADevice(LogMixin):
@callback
def async_cleanup_handles(self) -> None:
"""Unsubscribe the dispatchers and timers."""
- self._unsub()
- self._cancel_available_check()
+ for unsubscribe in self.unsubs:
+ unsubscribe()
@callback
def async_update_last_seen(self, last_seen):
"""Set last seen on the zigpy device."""
- self._zigpy_device.last_seen = last_seen
+ if self._zigpy_device.last_seen is None and last_seen is not None:
+ self._zigpy_device.last_seen = last_seen
@callback
def async_get_info(self):
diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py
index 5f8f6b593f8..90ec0e6e250 100644
--- a/homeassistant/components/zha/core/discovery.py
+++ b/homeassistant/components/zha/core/discovery.py
@@ -1,10 +1,13 @@
"""Device discovery functions for Zigbee Home Automation."""
+from collections import Counter
import logging
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.entity_registry import async_entries_for_device
from homeassistant.helpers.typing import HomeAssistantType
from . import const as zha_const, registries as zha_regs, typing as zha_typing
@@ -157,4 +160,94 @@ class ProbeEndpoint:
self._device_configs.update(overrides)
+class GroupProbe:
+ """Determine the appropriate component for a group."""
+
+ def __init__(self):
+ """Initialize instance."""
+ self._hass = None
+
+ def initialize(self, hass: HomeAssistantType) -> None:
+ """Initialize the group probe."""
+ self._hass = hass
+
+ @callback
+ def discover_group_entities(self, group: zha_typing.ZhaGroupType) -> None:
+ """Process a group and create any entities that are needed."""
+ # only create a group entity if there are 2 or more members in a group
+ if len(group.members) < 2:
+ _LOGGER.debug(
+ "Group: %s:0x%04x has less than 2 members - skipping entity discovery",
+ group.name,
+ group.group_id,
+ )
+ return
+
+ entity_domains = GroupProbe.determine_entity_domains(self._hass, group)
+
+ if not entity_domains:
+ return
+
+ zha_gateway = self._hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
+ for domain in entity_domains:
+ entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain)
+ if entity_class is None:
+ continue
+ self._hass.data[zha_const.DATA_ZHA][domain].append(
+ (
+ entity_class,
+ (
+ group.get_domain_entity_ids(domain),
+ f"{domain}_zha_group_0x{group.group_id:04x}",
+ group.group_id,
+ zha_gateway.coordinator_zha_device,
+ ),
+ )
+ )
+ async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES)
+
+ @staticmethod
+ def determine_entity_domains(
+ hass: HomeAssistantType, group: zha_typing.ZhaGroupType
+ ) -> List[str]:
+ """Determine the entity domains for this group."""
+ entity_domains: List[str] = []
+ if len(group.members) < 2:
+ _LOGGER.debug(
+ "Group: %s:0x%04x has less than 2 members so cannot default an entity domain",
+ group.name,
+ group.group_id,
+ )
+ return entity_domains
+
+ zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
+ all_domain_occurrences = []
+ for device in group.members:
+ if device.is_coordinator:
+ continue
+ entities = async_entries_for_device(
+ zha_gateway.ha_entity_registry, device.device_id
+ )
+ all_domain_occurrences.extend(
+ [
+ entity.domain
+ for entity in entities
+ if entity.domain in zha_regs.GROUP_ENTITY_DOMAINS
+ ]
+ )
+ if not all_domain_occurrences:
+ return entity_domains
+ # get all domains we care about if there are more than 2 entities of this domain
+ counts = Counter(all_domain_occurrences)
+ entity_domains = [domain[0] for domain in counts.items() if domain[1] >= 2]
+ _LOGGER.debug(
+ "The entity domains are: %s for group: %s:0x%04x",
+ entity_domains,
+ group.name,
+ group.group_id,
+ )
+ return entity_domains
+
+
PROBE = ProbeEndpoint()
+GROUP_PROBE = GroupProbe()
diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py
index 78b5f939cae..e032de4d94c 100644
--- a/homeassistant/components/zha/core/gateway.py
+++ b/homeassistant/components/zha/core/gateway.py
@@ -5,7 +5,9 @@ import collections
import itertools
import logging
import os
+import time
import traceback
+from typing import List, Optional
from serial import SerialException
import zigpy.device as zigpy_dev
@@ -51,7 +53,9 @@ from .const import (
DEFAULT_DATABASE_NAME,
DOMAIN,
SIGNAL_ADD_ENTITIES,
+ SIGNAL_GROUP_MEMBERSHIP_CHANGE,
SIGNAL_REMOVE,
+ SIGNAL_REMOVE_GROUP,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
ZHA_GW_MSG,
@@ -75,6 +79,7 @@ from .group import ZHAGroup
from .patches import apply_application_controller_patch
from .registries import RADIO_TYPES
from .store import async_get_registry
+from .typing import ZhaDeviceType, ZhaGroupType, ZigpyEndpointType, ZigpyGroupType
_LOGGER = logging.getLogger(__name__)
@@ -93,6 +98,7 @@ class ZHAGateway:
self._config = config
self._devices = {}
self._groups = {}
+ self.coordinator_zha_device = None
self._device_registry = collections.defaultdict(list)
self.zha_storage = None
self.ha_device_registry = None
@@ -110,6 +116,7 @@ class ZHAGateway:
async def async_initialize(self):
"""Initialize controller and connect radio."""
discovery.PROBE.initialize(self._hass)
+ discovery.GROUP_PROBE.initialize(self._hass)
self.zha_storage = await async_get_registry(self._hass)
self.ha_device_registry = await get_dev_reg(self._hass)
@@ -156,17 +163,29 @@ class ZHAGateway:
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(
self.application_controller.ieee
)
- await self.async_load_devices()
- self._initialize_groups()
+ self.async_load_devices()
+ self.async_load_groups()
- async def async_load_devices(self) -> None:
+ @callback
+ def async_load_devices(self) -> None:
"""Restore ZHA devices from zigpy application state."""
zigpy_devices = self.application_controller.devices.values()
for zigpy_device in zigpy_devices:
- self._async_get_or_create_device(zigpy_device, restored=True)
+ zha_device = self._async_get_or_create_device(zigpy_device, restored=True)
+ if zha_device.nwk == 0x0000:
+ self.coordinator_zha_device = zha_device
- async def async_prepare_entities(self) -> None:
- """Prepare entities by initializing device channels."""
+ @callback
+ def async_load_groups(self) -> None:
+ """Initialize ZHA groups."""
+ for group_id in self.application_controller.groups:
+ group = self.application_controller.groups[group_id]
+ zha_group = self._async_get_or_create_group(group)
+ # we can do this here because the entities are in the entity registry tied to the devices
+ discovery.GROUP_PROBE.discover_group_entities(zha_group)
+
+ async def async_initialize_devices_and_entities(self) -> None:
+ """Initialize devices and load entities."""
semaphore = asyncio.Semaphore(2)
async def _throttle(zha_device: zha_typing.ZhaDeviceType, cached: bool):
@@ -231,35 +250,50 @@ class ZHAGateway:
"""Handle device leaving the network."""
self.async_update_device(device, False)
- def group_member_removed(self, zigpy_group, endpoint):
+ def group_member_removed(
+ self, zigpy_group: ZigpyGroupType, endpoint: ZigpyEndpointType
+ ) -> None:
"""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)
+ async_dispatcher_send(
+ self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}"
+ )
- def group_member_added(self, zigpy_group, endpoint):
+ def group_member_added(
+ self, zigpy_group: ZigpyGroupType, endpoint: ZigpyEndpointType
+ ) -> None:
"""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)
+ async_dispatcher_send(
+ self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}"
+ )
- def group_added(self, zigpy_group):
+ def group_added(self, zigpy_group: ZigpyGroupType) -> None:
"""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):
+ def group_removed(self, zigpy_group: ZigpyGroupType) -> None:
"""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")
+ async_dispatcher_send(
+ self._hass, f"{SIGNAL_REMOVE_GROUP}_0x{zigpy_group.group_id:04x}"
+ )
- def _send_group_gateway_message(self, zigpy_group, gateway_message_type):
- """Send the gareway event for a zigpy group event."""
+ def _send_group_gateway_message(
+ self, zigpy_group: ZigpyGroupType, gateway_message_type: str
+ ) -> None:
+ """Send the gateway event for a zigpy group event."""
zha_group = self._groups.get(zigpy_group.group_id)
if zha_group is not None:
async_dispatcher_send(
@@ -306,12 +340,12 @@ class ZHAGateway:
"""Return ZHADevice for given ieee."""
return self._devices.get(ieee)
- def get_group(self, group_id):
+ def get_group(self, group_id: str) -> Optional[ZhaGroupType]:
"""Return Group for given group id."""
return self.groups.get(group_id)
@callback
- def async_get_group_by_name(self, group_name):
+ def async_get_group_by_name(self, group_name: str) -> Optional[ZhaGroupType]:
"""Get ZHA group by name."""
for group in self.groups.values():
if group.name == group_name:
@@ -390,12 +424,6 @@ class ZHAGateway:
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: zha_typing.ZigpyDeviceType, restored: bool = False
@@ -414,12 +442,12 @@ class ZHAGateway:
model=zha_device.model,
)
zha_device.set_device_id(device_registry_device.id)
- entry = self.zha_storage.async_get_or_create(zha_device)
+ entry = self.zha_storage.async_get_or_create_device(zha_device)
zha_device.async_update_last_seen(entry.last_seen)
return zha_device
@callback
- def _async_get_or_create_group(self, zigpy_group):
+ def _async_get_or_create_group(self, zigpy_group: ZigpyGroupType) -> ZhaGroupType:
"""Get or create a ZHA group."""
zha_group = self._groups.get(zigpy_group.group_id)
if zha_group is None:
@@ -446,13 +474,14 @@ class ZHAGateway:
async def async_update_device_storage(self):
"""Update the devices in the store."""
for device in self.devices.values():
- self.zha_storage.async_update(device)
- await self.zha_storage.async_save()
+ self.zha_storage.async_update_device(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)
-
+ # This is an active device so set a last seen if it is none
+ if zha_device.last_seen is None:
+ zha_device.async_update_last_seen(time.time())
_LOGGER.debug(
"device - %s:%s entering async_device_initialized - is_new_join: %s",
device.nwk,
@@ -494,25 +523,6 @@ class ZHAGateway:
zha_device.update_available(True)
async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES)
- # only public for testing
- 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, 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 with power source %s",
- zha_device.nwk,
- zha_device.ieee,
- zha_device.name,
- zha_device.power_source,
- )
- await zha_device.async_initialize(from_cache=False)
- else:
- await zha_device.async_initialize(from_cache=True)
-
async def _async_device_rejoined(self, zha_device):
_LOGGER.debug(
"skipping discovery for previously discovered device - %s:%s",
@@ -524,7 +534,9 @@ class ZHAGateway:
# 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):
+ async def async_create_zigpy_group(
+ self, name: str, members: List[ZhaDeviceType]
+ ) -> ZhaGroupType:
"""Create a new Zigpy Zigbee group."""
# we start with one to fill any gaps from a user removing existing groups
group_id = 1
@@ -537,24 +549,37 @@ class ZHAGateway:
if members is not None:
tasks = []
for ieee in members:
+ _LOGGER.debug(
+ "Adding member with IEEE: %s to group: %s:0x%04x",
+ ieee,
+ name,
+ group_id,
+ )
tasks.append(self.devices[ieee].async_add_to_group(group_id))
await asyncio.gather(*tasks)
- return self.groups.get(group_id)
+ zha_group = self.groups.get(group_id)
+ _LOGGER.debug(
+ "Probing group: %s:0x%04x for entity discovery",
+ zha_group.name,
+ zha_group.group_id,
+ )
+ discovery.GROUP_PROBE.discover_group_entities(zha_group)
- async def async_remove_zigpy_group(self, group_id):
+ return zha_group
+
+ async def async_remove_zigpy_group(self, group_id: int) -> None:
"""Remove a Zigbee group from Zigpy."""
group = self.groups.get(group_id)
+ if not group:
+ _LOGGER.debug("Group: %s:0x%04x could not be found", group.name, group_id)
+ return
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)
+ self.application_controller.groups.pop(group_id)
async def shutdown(self):
"""Stop ZHA Controller Application."""
diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py
index ca2cc0ff1d3..4fc86012d1a 100644
--- a/homeassistant/components/zha/core/group.py
+++ b/homeassistant/components/zha/core/group.py
@@ -1,10 +1,16 @@
"""Group for Zigbee Home Automation."""
import asyncio
import logging
+from typing import Any, Dict, List
+
+from zigpy.types.named import EUI64
from homeassistant.core import callback
+from homeassistant.helpers.entity_registry import async_entries_for_device
+from homeassistant.helpers.typing import HomeAssistantType
from .helpers import LogMixin
+from .typing import ZhaDeviceType, ZhaGatewayType, ZigpyEndpointType, ZigpyGroupType
_LOGGER = logging.getLogger(__name__)
@@ -12,29 +18,34 @@ _LOGGER = logging.getLogger(__name__)
class ZHAGroup(LogMixin):
"""ZHA Zigbee group object."""
- def __init__(self, hass, zha_gateway, zigpy_group):
+ def __init__(
+ self,
+ hass: HomeAssistantType,
+ zha_gateway: ZhaGatewayType,
+ zigpy_group: ZigpyGroupType,
+ ):
"""Initialize the group."""
- self.hass = hass
- self._zigpy_group = zigpy_group
- self._zha_gateway = zha_gateway
+ self.hass: HomeAssistantType = hass
+ self._zigpy_group: ZigpyGroupType = zigpy_group
+ self._zha_gateway: ZhaGatewayType = zha_gateway
@property
- def name(self):
+ def name(self) -> str:
"""Return group name."""
return self._zigpy_group.name
@property
- def group_id(self):
+ def group_id(self) -> int:
"""Return group name."""
return self._zigpy_group.group_id
@property
- def endpoint(self):
+ def endpoint(self) -> ZigpyEndpointType:
"""Return the endpoint for this group."""
return self._zigpy_group.endpoint
@property
- def members(self):
+ def members(self) -> List[ZhaDeviceType]:
"""Return the ZHA devices that are members of this group."""
return [
self._zha_gateway.devices.get(member_ieee[0])
@@ -42,7 +53,7 @@ class ZHAGroup(LogMixin):
if member_ieee[0] in self._zha_gateway.devices
]
- async def async_add_members(self, member_ieee_addresses):
+ async def async_add_members(self, member_ieee_addresses: List[EUI64]) -> None:
"""Add members to this group."""
if len(member_ieee_addresses) > 1:
tasks = []
@@ -56,7 +67,7 @@ class ZHAGroup(LogMixin):
member_ieee_addresses[0]
].async_add_to_group(self.group_id)
- async def async_remove_members(self, member_ieee_addresses):
+ async def async_remove_members(self, member_ieee_addresses: List[EUI64]) -> None:
"""Remove members from this group."""
if len(member_ieee_addresses) > 1:
tasks = []
@@ -72,10 +83,34 @@ class ZHAGroup(LogMixin):
member_ieee_addresses[0]
].async_remove_from_group(self.group_id)
+ @property
+ def member_entity_ids(self) -> List[str]:
+ """Return the ZHA entity ids for all entities for the members of this group."""
+ all_entity_ids: List[str] = []
+ for device in self.members:
+ entities = async_entries_for_device(
+ self._zha_gateway.ha_entity_registry, device.device_id
+ )
+ for entity in entities:
+ all_entity_ids.append(entity.entity_id)
+ return all_entity_ids
+
+ def get_domain_entity_ids(self, domain) -> List[str]:
+ """Return entity ids from the entity domain for this group."""
+ domain_entity_ids: List[str] = []
+ for device in self.members:
+ entities = async_entries_for_device(
+ self._zha_gateway.ha_entity_registry, device.device_id
+ )
+ domain_entity_ids.extend(
+ [entity.entity_id for entity in entities if entity.domain == domain]
+ )
+ return domain_entity_ids
+
@callback
- def async_get_info(self):
+ def async_get_info(self) -> Dict[str, Any]:
"""Get ZHA group info."""
- group_info = {}
+ group_info: Dict[str, Any] = {}
group_info["group_id"] = self.group_id
group_info["name"] = self.name
group_info["members"] = [
@@ -83,7 +118,7 @@ class ZHAGroup(LogMixin):
]
return group_info
- def log(self, level, msg, *args):
+ def log(self, level: int, msg: str, *args):
"""Log a message."""
msg = f"[%s](%s): {msg}"
args = (self.name, self.group_id) + args
diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py
index ab4c7ae540c..4441ac90717 100644
--- a/homeassistant/components/zha/core/helpers.py
+++ b/homeassistant/components/zha/core/helpers.py
@@ -1,10 +1,11 @@
"""Helpers for Zigbee Home Automation."""
import collections
import logging
+from typing import Any, Callable, Iterator, List, Optional
import zigpy.types
-from homeassistant.core import callback
+from homeassistant.core import State, callback
from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, DATA_ZHA, DATA_ZHA_GATEWAY
from .registries import BINDABLE_CLUSTERS
@@ -85,6 +86,45 @@ async def async_get_zha_device(hass, device_id):
return zha_gateway.devices[ieee]
+def find_state_attributes(states: List[State], key: str) -> Iterator[Any]:
+ """Find attributes with matching key from states."""
+ for state in states:
+ value = state.attributes.get(key)
+ if value is not None:
+ yield value
+
+
+def mean_int(*args):
+ """Return the mean of the supplied values."""
+ return int(sum(args) / len(args))
+
+
+def mean_tuple(*args):
+ """Return the mean values along the columns of the supplied values."""
+ return tuple(sum(l) / len(l) for l in zip(*args))
+
+
+def reduce_attribute(
+ states: List[State],
+ key: str,
+ default: Optional[Any] = None,
+ reduce: Callable[..., Any] = mean_int,
+) -> Any:
+ """Find the first attribute matching key from states.
+
+ If none are found, return default.
+ """
+ attrs = list(find_state_attributes(states, key))
+
+ if not attrs:
+ return default
+
+ if len(attrs) == 1:
+ return attrs[0]
+
+ return reduce(*attrs)
+
+
class LogMixin:
"""Log helper."""
diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py
index 3b08d1acd37..29b71343245 100644
--- a/homeassistant/components/zha/core/registries.py
+++ b/homeassistant/components/zha/core/registries.py
@@ -32,6 +32,8 @@ from .const import CONTROLLER, ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, RadioType
from .decorators import CALLABLE_T, DictRegistry, SetRegistry
from .typing import ChannelType
+GROUP_ENTITY_DOMAINS = [LIGHT, SWITCH, FAN]
+
SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000
SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45
@@ -123,9 +125,9 @@ DEVICE_CLASS = {
DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS)
DEVICE_TRACKER_CLUSTERS = SetRegistry()
-EVENT_RELAY_CLUSTERS = SetRegistry()
LIGHT_CLUSTERS = SetRegistry()
OUTPUT_CHANNEL_ONLY_CLUSTERS = SetRegistry()
+CLIENT_CHANNELS_REGISTRY = DictRegistry()
RADIO_TYPES = {
RadioType.deconz.name: {
@@ -196,6 +198,30 @@ class MatchRule:
factory=frozenset, converter=set_or_callable
)
+ @property
+ def weight(self) -> int:
+ """Return the weight of the matching rule.
+
+ Most specific matches should be preferred over less specific. Model matching
+ rules have a priority over manufacturer matching rules and rules matching a
+ single model/manufacturer get a better priority over rules matching multiple
+ models/manufacturers. And any model or manufacturers matching rules get better
+ priority over rules matching only channels.
+ But in case of a channel name/channel id matching, we give rules matching
+ multiple channels a better priority over rules matching a single channel.
+ """
+ weight = 0
+ if self.models:
+ weight += 401 - len(self.models)
+
+ if self.manufacturers:
+ weight += 301 - len(self.manufacturers)
+
+ weight += 10 * len(self.channel_names)
+ weight += 5 * len(self.generic_ids)
+ weight += 1 * len(self.aux_channels)
+ return weight
+
def claim_channels(self, channel_pool: List[ChannelType]) -> List[ChannelType]:
"""Return a list of channels this rule matches + aux channels."""
claimed = []
@@ -251,6 +277,9 @@ RegistryDictType = Dict[
] # pylint: disable=invalid-name
+GroupRegistryDictType = Dict[str, CALLABLE_T] # pylint: disable=invalid-name
+
+
class ZHAEntityRegistry:
"""Channel to ZHA Entity mapping."""
@@ -258,6 +287,7 @@ class ZHAEntityRegistry:
"""Initialize Registry instance."""
self._strict_registry: RegistryDictType = collections.defaultdict(dict)
self._loose_registry: RegistryDictType = collections.defaultdict(dict)
+ self._group_registry: GroupRegistryDictType = {}
def get_entity(
self,
@@ -268,13 +298,18 @@ class ZHAEntityRegistry:
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]:
+ matches = self._strict_registry[component]
+ for match in sorted(matches, key=lambda x: x.weight, reverse=True):
if match.strict_matched(manufacturer, model, channels):
claimed = match.claim_channels(channels)
return self._strict_registry[component][match], claimed
return default, []
+ def get_group_entity(self, component: str) -> CALLABLE_T:
+ """Match a ZHA group to a ZHA Entity class."""
+ return self._group_registry.get(component)
+
def strict_match(
self,
component: str,
@@ -325,5 +360,15 @@ class ZHAEntityRegistry:
return decorator
+ def group_match(self, component: str) -> Callable[[CALLABLE_T], CALLABLE_T]:
+ """Decorate a group match rule."""
+
+ def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T:
+ """Register a group match rule."""
+ self._group_registry[component] = zha_ent
+ return zha_ent
+
+ return decorator
+
ZHA_ENTITIES = ZHAEntityRegistry()
diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py
index 46fef76b656..0171ded67fe 100644
--- a/homeassistant/components/zha/core/store.py
+++ b/homeassistant/components/zha/core/store.py
@@ -10,6 +10,8 @@ from homeassistant.core import callback
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.loader import bind_hass
+from .typing import ZhaDeviceType
+
_LOGGER = logging.getLogger(__name__)
DATA_REGISTRY = "zha_storage"
@@ -28,54 +30,57 @@ class ZhaDeviceEntry:
last_seen = attr.ib(type=float, default=None)
-class ZhaDeviceStorage:
+class ZhaStorage:
"""Class to hold a registry of zha devices."""
def __init__(self, hass: HomeAssistantType) -> None:
"""Initialize the zha device storage."""
- self.hass = hass
+ self.hass: HomeAssistantType = hass
self.devices: MutableMapping[str, ZhaDeviceEntry] = {}
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
@callback
- def async_create(self, device) -> ZhaDeviceEntry:
+ def async_create_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry:
"""Create a new ZhaDeviceEntry."""
- device_entry = ZhaDeviceEntry(
+ device_entry: ZhaDeviceEntry = ZhaDeviceEntry(
name=device.name, ieee=str(device.ieee), last_seen=device.last_seen
)
self.devices[device_entry.ieee] = device_entry
-
- return self.async_update(device)
+ self.async_schedule_save()
+ return device_entry
@callback
- def async_get_or_create(self, device) -> ZhaDeviceEntry:
+ def async_get_or_create_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry:
"""Create a new ZhaDeviceEntry."""
- ieee_str = str(device.ieee)
+ ieee_str: str = str(device.ieee)
if ieee_str in self.devices:
return self.devices[ieee_str]
- return self.async_create(device)
+ return self.async_create_device(device)
@callback
- def async_create_or_update(self, device) -> ZhaDeviceEntry:
+ def async_create_or_update_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry:
"""Create or update a ZhaDeviceEntry."""
if str(device.ieee) in self.devices:
- return self.async_update(device)
- return self.async_create(device)
+ return self.async_update_device(device)
+ return self.async_create_device(device)
@callback
- def async_delete(self, device) -> None:
+ def async_delete_device(self, device: ZhaDeviceType) -> None:
"""Delete ZhaDeviceEntry."""
- ieee_str = str(device.ieee)
+ ieee_str: str = str(device.ieee)
if ieee_str in self.devices:
del self.devices[ieee_str]
self.async_schedule_save()
@callback
- def async_update(self, device) -> ZhaDeviceEntry:
+ def async_update_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry:
"""Update name of ZhaDeviceEntry."""
- ieee_str = str(device.ieee)
+ ieee_str: str = str(device.ieee)
old = self.devices[ieee_str]
+ if old is not None and device.last_seen is None:
+ return
+
changes = {}
changes["last_seen"] = device.last_seen
@@ -122,17 +127,17 @@ class ZhaDeviceStorage:
@bind_hass
-async def async_get_registry(hass: HomeAssistantType) -> ZhaDeviceStorage:
+async def async_get_registry(hass: HomeAssistantType) -> ZhaStorage:
"""Return zha device storage instance."""
task = hass.data.get(DATA_REGISTRY)
if task is None:
- async def _load_reg() -> ZhaDeviceStorage:
- registry = ZhaDeviceStorage(hass)
+ async def _load_reg() -> ZhaStorage:
+ registry = ZhaStorage(hass)
await registry.async_load()
return registry
task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg())
- return cast(ZhaDeviceStorage, await task)
+ return cast(ZhaStorage, await task)
diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py
index fb397ea15ae..a4619d0596e 100644
--- a/homeassistant/components/zha/core/typing.py
+++ b/homeassistant/components/zha/core/typing.py
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Callable, TypeVar
import zigpy.device
import zigpy.endpoint
+import zigpy.group
import zigpy.zcl
import zigpy.zdo
@@ -12,14 +13,16 @@ CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable)
ChannelType = "ZigbeeChannel"
ChannelsType = "Channels"
ChannelPoolType = "ChannelPool"
-EventRelayChannelType = "EventRelayChannel"
+ClientChannelType = "ClientChannel"
ZDOChannelType = "ZDOChannel"
ZhaDeviceType = "ZHADevice"
ZhaEntityType = "ZHAEntity"
ZhaGatewayType = "ZHAGateway"
+ZhaGroupType = "ZHAGroupType"
ZigpyClusterType = zigpy.zcl.Cluster
ZigpyDeviceType = zigpy.device.Device
ZigpyEndpointType = zigpy.endpoint.Endpoint
+ZigpyGroupType = zigpy.group.Group
ZigpyZdoType = zigpy.zdo.ZDO
if TYPE_CHECKING:
@@ -33,8 +36,9 @@ if TYPE_CHECKING:
ChannelType = base_channels.ZigbeeChannel
ChannelsType = channels.Channels
ChannelPoolType = channels.ChannelPool
- EventRelayChannelType = base_channels.EventRelayChannel
+ ClientChannelType = base_channels.ClientChannel
ZDOChannelType = base_channels.ZDOChannel
ZhaDeviceType = homeassistant.components.zha.core.device.ZHADevice
ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity
ZhaGatewayType = homeassistant.components.zha.core.gateway.ZHAGateway
+ ZhaGroupType = homeassistant.components.zha.core.group.ZHAGroup
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index 4dd3fea016d..2d098d60bfb 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -3,11 +3,13 @@
import asyncio
import logging
import time
+from typing import Any, Awaitable, Dict, List, Optional
-from homeassistant.core import callback
+from homeassistant.core import CALLBACK_TYPE, State, callback
from homeassistant.helpers import entity
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.event import async_track_state_change
from homeassistant.helpers.restore_state import RestoreEntity
from .core.const import (
@@ -17,9 +19,12 @@ from .core.const import (
DATA_ZHA,
DATA_ZHA_BRIDGE_ID,
DOMAIN,
+ SIGNAL_GROUP_MEMBERSHIP_CHANGE,
SIGNAL_REMOVE,
+ SIGNAL_REMOVE_GROUP,
)
from .core.helpers import LogMixin
+from .core.typing import CALLABLE_T, ChannelsType, ChannelType, ZhaDeviceType
_LOGGER = logging.getLogger(__name__)
@@ -27,30 +32,24 @@ ENTITY_SUFFIX = "entity_suffix"
RESTART_GRACE_PERIOD = 7200 # 2 hours
-class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
+class BaseZhaEntity(RestoreEntity, LogMixin, entity.Entity):
"""A base class for ZHA entities."""
- def __init__(self, unique_id, zha_device, channels, skip_entity_id=False, **kwargs):
+ def __init__(self, unique_id: str, zha_device: ZhaDeviceType, **kwargs):
"""Init ZHA entity."""
- self._force_update = False
- self._should_poll = False
- self._unique_id = unique_id
- ieeetail = "".join([f"{o:02x}" for o in zha_device.ieee[:4]])
- ch_names = [ch.cluster.ep_attribute for ch in channels]
- ch_names = ", ".join(sorted(ch_names))
- self._name = f"{zha_device.name} {ieeetail} {ch_names}"
- self._state = None
- self._device_state_attributes = {}
- self._zha_device = zha_device
- self.cluster_channels = {}
- self._available = False
- self._unsubs = []
- self.remove_future = None
- for channel in channels:
- self.cluster_channels[channel.name] = channel
+ self._name: str = ""
+ self._force_update: bool = False
+ self._should_poll: bool = False
+ self._unique_id: str = unique_id
+ self._state: Any = None
+ self._device_state_attributes: Dict[str, Any] = {}
+ self._zha_device: ZhaDeviceType = zha_device
+ self._available: bool = False
+ self._unsubs: List[CALLABLE_T] = []
+ self.remove_future: Awaitable[None] = None
@property
- def name(self):
+ def name(self) -> str:
"""Return Entity's default name."""
return self._name
@@ -60,12 +59,12 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
return self._unique_id
@property
- def zha_device(self):
+ def zha_device(self) -> ZhaDeviceType:
"""Return the zha device this entity is attached to."""
return self._zha_device
@property
- def device_state_attributes(self):
+ def device_state_attributes(self) -> Dict[str, Any]:
"""Return device specific state attributes."""
return self._device_state_attributes
@@ -80,7 +79,7 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
return self._should_poll
@property
- def device_info(self):
+ def device_info(self) -> Dict[str, Any]:
"""Return a device description for device registry."""
zha_device_info = self._zha_device.device_info
ieee = zha_device_info["ieee"]
@@ -94,31 +93,94 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
}
@property
- def available(self):
+ def available(self) -> bool:
"""Return entity availability."""
return self._available
@callback
- def async_set_available(self, available):
+ def async_set_available(self, available: bool) -> None:
"""Set entity availability."""
self._available = available
self.async_write_ha_state()
@callback
- def async_update_state_attribute(self, key, value):
+ def async_update_state_attribute(self, key: str, value: Any) -> None:
"""Update a single device state attribute."""
self._device_state_attributes.update({key: value})
self.async_write_ha_state()
@callback
- def async_set_state(self, attr_id, attr_name, value):
+ def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None:
"""Set the entity state."""
pass
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass."""
await super().async_added_to_hass()
self.remove_future = asyncio.Future()
+ await self.async_accept_signal(
+ None,
+ "{}_{}".format(SIGNAL_REMOVE, str(self.zha_device.ieee)),
+ self.async_remove,
+ signal_override=True,
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect entity object when removed."""
+ for unsub in self._unsubs[:]:
+ unsub()
+ self._unsubs.remove(unsub)
+ self.zha_device.gateway.remove_entity_reference(self)
+ self.remove_future.set_result(True)
+
+ @callback
+ def async_restore_last_state(self, last_state) -> None:
+ """Restore previous state."""
+ pass
+
+ async def async_accept_signal(
+ self, channel: ChannelType, signal: str, func: CALLABLE_T, signal_override=False
+ ):
+ """Accept a signal from a channel."""
+ unsub = None
+ if signal_override:
+ unsub = async_dispatcher_connect(self.hass, signal, func)
+ else:
+ unsub = async_dispatcher_connect(
+ self.hass, f"{channel.unique_id}_{signal}", func
+ )
+ self._unsubs.append(unsub)
+
+ def log(self, level: int, msg: str, *args):
+ """Log a message."""
+ msg = f"%s: {msg}"
+ args = (self.entity_id,) + args
+ _LOGGER.log(level, msg, *args)
+
+
+class ZhaEntity(BaseZhaEntity):
+ """A base class for non group ZHA entities."""
+
+ def __init__(
+ self,
+ unique_id: str,
+ zha_device: ZhaDeviceType,
+ channels: ChannelsType,
+ **kwargs,
+ ):
+ """Init ZHA entity."""
+ super().__init__(unique_id, zha_device, **kwargs)
+ ieeetail = "".join([f"{o:02x}" for o in zha_device.ieee[:4]])
+ ch_names = [ch.cluster.ep_attribute for ch in channels]
+ ch_names = ", ".join(sorted(ch_names))
+ self._name: str = f"{zha_device.name} {ieeetail} {ch_names}"
+ self.cluster_channels: Dict[str, ChannelType] = {}
+ for channel in channels:
+ self.cluster_channels[channel.name] = channel
+
+ async def async_added_to_hass(self) -> None:
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
await self.async_check_recently_seen()
await self.async_accept_signal(
None,
@@ -126,12 +188,6 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
self.async_set_available,
signal_override=True,
)
- await self.async_accept_signal(
- None,
- "{}_{}".format(SIGNAL_REMOVE, str(self.zha_device.ieee)),
- self.async_remove,
- signal_override=True,
- )
self._zha_device.gateway.register_entity_reference(
self._zha_device.ieee,
self.entity_id,
@@ -141,7 +197,7 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
self.remove_future,
)
- async def async_check_recently_seen(self):
+ async def async_check_recently_seen(self) -> None:
"""Check if the device was seen within the last 2 hours."""
last_state = await self.async_get_last_state()
if (
@@ -155,38 +211,75 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
self.async_restore_last_state(last_state)
self._zha_device.set_available(True)
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect entity object when removed."""
- for unsub in self._unsubs[:]:
- unsub()
- self._unsubs.remove(unsub)
- self.zha_device.gateway.remove_entity_reference(self)
- self.remove_future.set_result(True)
-
- @callback
- def async_restore_last_state(self, last_state):
- """Restore previous state."""
- pass
-
- async def async_update(self):
+ async def async_update(self) -> None:
"""Retrieve latest state."""
for channel in self.cluster_channels.values():
if hasattr(channel, "async_update"):
await channel.async_update()
- async def async_accept_signal(self, channel, signal, func, signal_override=False):
- """Accept a signal from a channel."""
- unsub = None
- if signal_override:
- unsub = async_dispatcher_connect(self.hass, signal, func)
- else:
- unsub = async_dispatcher_connect(
- self.hass, f"{channel.unique_id}_{signal}", func
- )
- self._unsubs.append(unsub)
- def log(self, level, msg, *args):
- """Log a message."""
- msg = f"%s: {msg}"
- args = (self.entity_id,) + args
- _LOGGER.log(level, msg, *args)
+class ZhaGroupEntity(BaseZhaEntity):
+ """A base class for ZHA group entities."""
+
+ def __init__(
+ self, entity_ids: List[str], unique_id: str, group_id: int, zha_device, **kwargs
+ ) -> None:
+ """Initialize a light group."""
+ super().__init__(unique_id, zha_device, **kwargs)
+ self._name = (
+ f"{zha_device.gateway.groups.get(group_id).name}_zha_group_0x{group_id:04x}"
+ )
+ self._group_id: int = group_id
+ self._entity_ids: List[str] = entity_ids
+ self._async_unsub_state_changed: Optional[CALLBACK_TYPE] = None
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ None,
+ f"{SIGNAL_REMOVE_GROUP}_0x{self._group_id:04x}",
+ self.async_remove,
+ signal_override=True,
+ )
+
+ await self.async_accept_signal(
+ None,
+ f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{self._group_id:04x}",
+ self._update_group_entities,
+ signal_override=True,
+ )
+
+ self._async_unsub_state_changed = async_track_state_change(
+ self.hass, self._entity_ids, self.async_state_changed_listener
+ )
+ await self.async_update()
+
+ @callback
+ def async_state_changed_listener(
+ self, entity_id: str, old_state: State, new_state: State
+ ):
+ """Handle child updates."""
+ self.async_schedule_update_ha_state(True)
+
+ def _update_group_entities(self):
+ """Update tracked entities when membership changes."""
+ group = self.zha_device.gateway.get_group(self._group_id)
+ self._entity_ids = group.get_domain_entity_ids(self.platform.domain)
+ if self._async_unsub_state_changed is not None:
+ self._async_unsub_state_changed()
+
+ self._async_unsub_state_changed = async_track_state_change(
+ self.hass, self._entity_ids, self.async_state_changed_listener
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Handle removal from Home Assistant."""
+ await super().async_will_remove_from_hass()
+ if self._async_unsub_state_changed is not None:
+ self._async_unsub_state_changed()
+ self._async_unsub_state_changed = None
+
+ async def async_update(self) -> None:
+ """Update the state of the group entity."""
+ pass
diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py
index d04453cd675..c3cd88b0d6d 100644
--- a/homeassistant/components/zha/fan.py
+++ b/homeassistant/components/zha/fan.py
@@ -1,6 +1,10 @@
"""Fans on Zigbee Home Automation networks."""
import functools
import logging
+from typing import List
+
+from zigpy.exceptions import DeliveryError
+import zigpy.zcl.clusters.hvac as hvac
from homeassistant.components.fan import (
DOMAIN,
@@ -11,7 +15,8 @@ from homeassistant.components.fan import (
SUPPORT_SET_SPEED,
FanEntity,
)
-from homeassistant.core import callback
+from homeassistant.const import STATE_UNAVAILABLE
+from homeassistant.core import State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .core import discovery
@@ -23,7 +28,7 @@ from .core.const import (
SIGNAL_ATTR_UPDATED,
)
from .core.registries import ZHA_ENTITIES
-from .entity import ZhaEntity
+from .entity import ZhaEntity, ZhaGroupEntity
_LOGGER = logging.getLogger(__name__)
@@ -49,6 +54,7 @@ SPEED_LIST = [
VALUE_TO_SPEED = dict(enumerate(SPEED_LIST))
SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)}
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
+GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -65,31 +71,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
-@STRICT_MATCH(channel_names=CHANNEL_FAN)
-class ZhaFan(ZhaEntity, FanEntity):
- """Representation of a ZHA fan."""
+class BaseFan(FanEntity):
+ """Base representation of a ZHA fan."""
- def __init__(self, unique_id, zha_device, channels, **kwargs):
- """Init this sensor."""
- super().__init__(unique_id, zha_device, channels, **kwargs)
- self._fan_channel = self.cluster_channels.get(CHANNEL_FAN)
-
- async def async_added_to_hass(self):
- """Run when about to be added to hass."""
- await super().async_added_to_hass()
- await self.async_accept_signal(
- self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state
- )
-
- @callback
- def async_restore_last_state(self, last_state):
- """Restore previous state."""
- self._state = VALUE_TO_SPEED.get(last_state.state, last_state.state)
-
- @property
- def supported_features(self) -> int:
- """Flag supported features."""
- return SUPPORT_SET_SPEED
+ def __init__(self, *args, **kwargs):
+ """Initialize the fan."""
+ super().__init__(*args, **kwargs)
+ self._state = None
+ self._fan_channel = None
@property
def speed_list(self) -> list:
@@ -109,15 +98,9 @@ class ZhaFan(ZhaEntity, FanEntity):
return self._state != SPEED_OFF
@property
- def device_state_attributes(self):
- """Return state attributes."""
- return self.state_attributes
-
- @callback
- def async_set_state(self, attr_id, attr_name, value):
- """Handle state update from channel."""
- self._state = VALUE_TO_SPEED.get(value, self._state)
- self.async_write_ha_state()
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
async def async_turn_on(self, speed: str = None, **kwargs) -> None:
"""Turn the entity on."""
@@ -135,6 +118,39 @@ class ZhaFan(ZhaEntity, FanEntity):
await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed])
self.async_set_state(0, "fan_mode", speed)
+ @callback
+ def async_set_state(self, attr_id, attr_name, value):
+ """Handle state update from channel."""
+ pass
+
+
+@STRICT_MATCH(channel_names=CHANNEL_FAN)
+class ZhaFan(BaseFan, ZhaEntity):
+ """Representation of a ZHA fan."""
+
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
+ """Init this sensor."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._fan_channel = self.cluster_channels.get(CHANNEL_FAN)
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state
+ )
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = VALUE_TO_SPEED.get(last_state.state, last_state.state)
+
+ @callback
+ def async_set_state(self, attr_id, attr_name, value):
+ """Handle state update from channel."""
+ self._state = VALUE_TO_SPEED.get(value, self._state)
+ self.async_write_ha_state()
+
async def async_update(self):
"""Attempt to retrieve on off state from the fan."""
await super().async_update()
@@ -142,3 +158,40 @@ class ZhaFan(ZhaEntity, FanEntity):
state = await self._fan_channel.get_attribute_value("fan_mode")
if state is not None:
self._state = VALUE_TO_SPEED.get(state, self._state)
+
+
+@GROUP_MATCH()
+class FanGroup(BaseFan, ZhaGroupEntity):
+ """Representation of a fan group."""
+
+ def __init__(
+ self, entity_ids: List[str], unique_id: str, group_id: int, zha_device, **kwargs
+ ) -> None:
+ """Initialize a fan group."""
+ super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs)
+ self._available: bool = False
+ group = self.zha_device.gateway.get_group(self._group_id)
+ self._fan_channel = group.endpoint[hvac.Fan.cluster_id]
+
+ # what should we do with this hack?
+ async def async_set_speed(value) -> None:
+ """Set the speed of the fan."""
+ try:
+ await self._fan_channel.write_attributes({"fan_mode": value})
+ except DeliveryError as ex:
+ self.error("Could not set speed: %s", ex)
+ return
+
+ self._fan_channel.async_set_speed = async_set_speed
+
+ async def async_update(self):
+ """Attempt to retrieve on off state from the fan."""
+ all_states = [self.hass.states.get(x) for x in self._entity_ids]
+ states: List[State] = list(filter(None, all_states))
+ on_states: List[State] = [state for state in states if state.state != SPEED_OFF]
+ self._available = any(state.state != STATE_UNAVAILABLE for state in states)
+ # for now just use first non off state since its kind of arbitrary
+ if not on_states:
+ self._state = SPEED_OFF
+ else:
+ self._state = states[0].state
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index 8775cb2b4d8..c6ec5c2ccf9 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -1,19 +1,41 @@
"""Lights on Zigbee Home Automation networks."""
+from collections import Counter
from datetime import timedelta
import functools
+import itertools
import logging
import random
+from typing import Any, Dict, List, Optional, Tuple
+from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff
+from zigpy.zcl.clusters.lighting import Color
from zigpy.zcl.foundation import Status
from homeassistant.components import light
-from homeassistant.const import STATE_ON
-from homeassistant.core import callback
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP,
+ ATTR_EFFECT,
+ ATTR_EFFECT_LIST,
+ ATTR_HS_COLOR,
+ ATTR_MAX_MIREDS,
+ ATTR_MIN_MIREDS,
+ ATTR_WHITE_VALUE,
+ SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR,
+ SUPPORT_COLOR_TEMP,
+ SUPPORT_EFFECT,
+ SUPPORT_FLASH,
+ SUPPORT_TRANSITION,
+ SUPPORT_WHITE_VALUE,
+)
+from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_UNAVAILABLE
+from homeassistant.core import State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_track_time_interval
import homeassistant.util.color as color_util
-from .core import discovery
+from .core import discovery, helpers
from .core.const import (
CHANNEL_COLOR,
CHANNEL_LEVEL,
@@ -27,9 +49,10 @@ from .core.const import (
SIGNAL_ATTR_UPDATED,
SIGNAL_SET_LEVEL,
)
+from .core.helpers import LogMixin
from .core.registries import ZHA_ENTITIES
from .core.typing import ZhaDeviceType
-from .entity import ZhaEntity
+from .entity import ZhaEntity, ZhaGroupEntity
_LOGGER = logging.getLogger(__name__)
@@ -46,8 +69,18 @@ FLASH_EFFECTS = {light.FLASH_SHORT: EFFECT_BLINK, light.FLASH_LONG: EFFECT_BREAT
UNSUPPORTED_ATTRIBUTE = 0x86
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN)
+GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, light.DOMAIN)
PARALLEL_UPDATES = 0
-_REFRESH_INTERVAL = (45, 75)
+
+SUPPORT_GROUP_LIGHT = (
+ SUPPORT_BRIGHTNESS
+ | SUPPORT_COLOR_TEMP
+ | SUPPORT_EFFECT
+ | SUPPORT_FLASH
+ | SUPPORT_COLOR
+ | SUPPORT_TRANSITION
+ | SUPPORT_WHITE_VALUE
+)
async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -64,46 +97,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
-@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."""
+class BaseLight(LogMixin, light.Light):
+ """Operations common to all light entities."""
- def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs):
- """Initialize the ZHA light."""
- super().__init__(unique_id, zha_device, channels, **kwargs)
- self._supported_features = 0
- self._color_temp = None
- self._hs_color = None
- self._brightness = None
- self._off_brightness = None
- self._effect_list = []
- self._effect = None
- self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
- self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL)
- self._color_channel = self.cluster_channels.get(CHANNEL_COLOR)
- self._identify_channel = self.zha_device.channels.identify_ch
- self._cancel_refresh_handle = None
+ def __init__(self, *args, **kwargs):
+ """Initialize the light."""
+ super().__init__(*args, **kwargs)
+ self._available: bool = False
+ self._brightness: Optional[int] = None
+ self._off_brightness: Optional[int] = None
+ self._hs_color: Optional[Tuple[float, float]] = None
+ self._color_temp: Optional[int] = None
+ self._min_mireds: Optional[int] = 154
+ self._max_mireds: Optional[int] = 500
+ self._white_value: Optional[int] = None
+ self._effect_list: Optional[List[str]] = None
+ self._effect: Optional[str] = None
+ self._supported_features: int = 0
+ self._state: bool = False
+ self._on_off_channel = None
+ self._level_channel = None
+ self._color_channel = None
+ self._identify_channel = None
- if self._level_channel:
- self._supported_features |= light.SUPPORT_BRIGHTNESS
- self._supported_features |= light.SUPPORT_TRANSITION
- self._brightness = 0
-
- if self._color_channel:
- color_capabilities = self._color_channel.get_color_capabilities()
- if color_capabilities & CAPABILITIES_COLOR_TEMP:
- self._supported_features |= light.SUPPORT_COLOR_TEMP
-
- if color_capabilities & CAPABILITIES_COLOR_XY:
- self._supported_features |= light.SUPPORT_COLOR
- self._hs_color = (0, 0)
-
- if color_capabilities & CAPABILITIES_COLOR_LOOP:
- self._supported_features |= light.SUPPORT_EFFECT
- self._effect_list.append(light.EFFECT_COLORLOOP)
-
- if self._identify_channel:
- self._supported_features |= light.SUPPORT_FLASH
+ @property
+ def device_state_attributes(self) -> Dict[str, Any]:
+ """Return state attributes."""
+ attributes = {"off_brightness": self._off_brightness}
+ return attributes
@property
def is_on(self) -> bool:
@@ -117,12 +138,6 @@ class Light(ZhaEntity, light.Light):
"""Return the brightness of this light."""
return self._brightness
- @property
- def device_state_attributes(self):
- """Return state attributes."""
- attributes = {"off_brightness": self._off_brightness}
- return attributes
-
def set_level(self, value):
"""Set the brightness of this light between 0..254.
@@ -159,49 +174,6 @@ class Light(ZhaEntity, light.Light):
"""Flag supported features."""
return self._supported_features
- @callback
- def async_set_state(self, attr_id, attr_name, value):
- """Set the state."""
- self._state = bool(value)
- if value:
- self._off_brightness = None
- self.async_write_ha_state()
-
- async def async_added_to_hass(self):
- """Run when about to be added to hass."""
- await super().async_added_to_hass()
- await self.async_accept_signal(
- self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state
- )
- if self._level_channel:
- await self.async_accept_signal(
- self._level_channel, SIGNAL_SET_LEVEL, self.set_level
- )
- refresh_interval = random.randint(*_REFRESH_INTERVAL)
- self._cancel_refresh_handle = async_track_time_interval(
- self.hass, self._refresh, timedelta(minutes=refresh_interval)
- )
-
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect entity object when removed."""
- self._cancel_refresh_handle()
- await super().async_will_remove_from_hass()
-
- @callback
- def async_restore_last_state(self, last_state):
- """Restore previous state."""
- self._state = last_state.state == STATE_ON
- if "brightness" in last_state.attributes:
- self._brightness = last_state.attributes["brightness"]
- if "off_brightness" in last_state.attributes:
- self._off_brightness = last_state.attributes["off_brightness"]
- if "color_temp" in last_state.attributes:
- self._color_temp = last_state.attributes["color_temp"]
- if "hs_color" in last_state.attributes:
- self._hs_color = last_state.attributes["hs_color"]
- if "effect" in last_state.attributes:
- self._effect = last_state.attributes["effect"]
-
async def async_turn_on(self, **kwargs):
"""Turn the entity on."""
transition = kwargs.get(light.ATTR_TRANSITION)
@@ -330,6 +302,90 @@ class Light(ZhaEntity, light.Light):
self.async_write_ha_state()
+
+@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL})
+class Light(BaseLight, ZhaEntity):
+ """Representation of a ZHA or ZLL light."""
+
+ _REFRESH_INTERVAL = (45, 75)
+
+ def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs):
+ """Initialize the ZHA light."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
+ self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL)
+ self._color_channel = self.cluster_channels.get(CHANNEL_COLOR)
+ self._identify_channel = self.zha_device.channels.identify_ch
+ self._cancel_refresh_handle = None
+ effect_list = []
+
+ if self._level_channel:
+ self._supported_features |= light.SUPPORT_BRIGHTNESS
+ self._supported_features |= light.SUPPORT_TRANSITION
+ self._brightness = 0
+
+ if self._color_channel:
+ color_capabilities = self._color_channel.get_color_capabilities()
+ if color_capabilities & CAPABILITIES_COLOR_TEMP:
+ self._supported_features |= light.SUPPORT_COLOR_TEMP
+
+ if color_capabilities & CAPABILITIES_COLOR_XY:
+ self._supported_features |= light.SUPPORT_COLOR
+ self._hs_color = (0, 0)
+
+ if color_capabilities & CAPABILITIES_COLOR_LOOP:
+ self._supported_features |= light.SUPPORT_EFFECT
+ effect_list.append(light.EFFECT_COLORLOOP)
+
+ if self._identify_channel:
+ self._supported_features |= light.SUPPORT_FLASH
+
+ if effect_list:
+ self._effect_list = effect_list
+
+ @callback
+ def async_set_state(self, attr_id, attr_name, value):
+ """Set the state."""
+ self._state = bool(value)
+ if value:
+ self._off_brightness = None
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state
+ )
+ if self._level_channel:
+ await self.async_accept_signal(
+ self._level_channel, SIGNAL_SET_LEVEL, self.set_level
+ )
+ refresh_interval = random.randint(*[x * 60 for x in self._REFRESH_INTERVAL])
+ self._cancel_refresh_handle = async_track_time_interval(
+ self.hass, self._refresh, timedelta(seconds=refresh_interval)
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect entity object when removed."""
+ self._cancel_refresh_handle()
+ await super().async_will_remove_from_hass()
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = last_state.state == STATE_ON
+ if "brightness" in last_state.attributes:
+ self._brightness = last_state.attributes["brightness"]
+ if "off_brightness" in last_state.attributes:
+ self._off_brightness = last_state.attributes["off_brightness"]
+ if "color_temp" in last_state.attributes:
+ self._color_temp = last_state.attributes["color_temp"]
+ if "hs_color" in last_state.attributes:
+ self._hs_color = last_state.attributes["hs_color"]
+ if "effect" in last_state.attributes:
+ self._effect = last_state.attributes["effect"]
+
async def async_update(self):
"""Attempt to retrieve on off state from the light."""
await super().async_update()
@@ -398,3 +454,77 @@ class Light(ZhaEntity, light.Light):
"""Call async_get_state at an interval."""
await self.async_get_state(from_cache=False)
self.async_write_ha_state()
+
+
+@STRICT_MATCH(
+ channel_names=CHANNEL_ON_OFF,
+ aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL},
+ manufacturers="Philips",
+)
+class HueLight(Light):
+ """Representation of a HUE light which does not report attributes."""
+
+ _REFRESH_INTERVAL = (3, 5)
+
+
+@GROUP_MATCH()
+class LightGroup(BaseLight, ZhaGroupEntity):
+ """Representation of a light group."""
+
+ def __init__(
+ self, entity_ids: List[str], unique_id: str, group_id: int, zha_device, **kwargs
+ ) -> None:
+ """Initialize a light group."""
+ super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs)
+ group = self.zha_device.gateway.get_group(self._group_id)
+ self._on_off_channel = group.endpoint[OnOff.cluster_id]
+ self._level_channel = group.endpoint[LevelControl.cluster_id]
+ self._color_channel = group.endpoint[Color.cluster_id]
+ self._identify_channel = group.endpoint[Identify.cluster_id]
+
+ async def async_update(self) -> None:
+ """Query all members and determine the light group state."""
+ all_states = [self.hass.states.get(x) for x in self._entity_ids]
+ states: List[State] = list(filter(None, all_states))
+ on_states = [state for state in states if state.state == STATE_ON]
+
+ self._state = len(on_states) > 0
+ self._available = any(state.state != STATE_UNAVAILABLE for state in states)
+
+ self._brightness = helpers.reduce_attribute(on_states, ATTR_BRIGHTNESS)
+
+ self._hs_color = helpers.reduce_attribute(
+ on_states, ATTR_HS_COLOR, reduce=helpers.mean_tuple
+ )
+
+ self._white_value = helpers.reduce_attribute(on_states, ATTR_WHITE_VALUE)
+
+ self._color_temp = helpers.reduce_attribute(on_states, ATTR_COLOR_TEMP)
+ self._min_mireds = helpers.reduce_attribute(
+ states, ATTR_MIN_MIREDS, default=154, reduce=min
+ )
+ self._max_mireds = helpers.reduce_attribute(
+ states, ATTR_MAX_MIREDS, default=500, reduce=max
+ )
+
+ self._effect_list = None
+ all_effect_lists = list(helpers.find_state_attributes(states, ATTR_EFFECT_LIST))
+ if all_effect_lists:
+ # Merge all effects from all effect_lists with a union merge.
+ self._effect_list = list(set().union(*all_effect_lists))
+
+ self._effect = None
+ all_effects = list(helpers.find_state_attributes(on_states, ATTR_EFFECT))
+ if all_effects:
+ # Report the most common effect.
+ effects_count = Counter(itertools.chain(all_effects))
+ self._effect = effects_count.most_common(1)[0][0]
+
+ self._supported_features = 0
+ for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
+ # Merge supported features by emulating support for every feature
+ # we find.
+ self._supported_features |= support
+ # Bitwise-and the supported features with the GroupedLight's features
+ # so that we don't break in the future when a new feature is added.
+ self._supported_features &= SUPPORT_GROUP_LIGHT
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 19940eaea00..66b89724a2f 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -4,12 +4,12 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zha",
"requirements": [
- "bellows-homeassistant==0.14.0",
- "zha-quirks==0.0.37",
- "zigpy-cc==0.1.0",
- "zigpy-deconz==0.7.0",
- "zigpy-homeassistant==0.16.0",
- "zigpy-xbee-homeassistant==0.10.0",
+ "bellows-homeassistant==0.15.2",
+ "zha-quirks==0.0.38",
+ "zigpy-cc==0.3.1",
+ "zigpy-deconz==0.8.0",
+ "zigpy-homeassistant==0.18.1",
+ "zigpy-xbee-homeassistant==0.11.0",
"zigpy-zigate==0.5.1"
],
"dependencies": [],
diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py
index 6be3a9b3347..328d9959ad2 100644
--- a/homeassistant/components/zha/switch.py
+++ b/homeassistant/components/zha/switch.py
@@ -1,12 +1,14 @@
"""Switches on Zigbee Home Automation networks."""
import functools
import logging
+from typing import Any, List
+from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.foundation import Status
from homeassistant.components.switch import DOMAIN, SwitchDevice
-from homeassistant.const import STATE_ON
-from homeassistant.core import callback
+from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
+from homeassistant.core import State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .core import discovery
@@ -18,10 +20,11 @@ from .core.const import (
SIGNAL_ATTR_UPDATED,
)
from .core.registries import ZHA_ENTITIES
-from .entity import ZhaEntity
+from .entity import ZhaEntity, ZhaGroupEntity
_LOGGER = logging.getLogger(__name__)
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
+GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -38,14 +41,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
-@STRICT_MATCH(channel_names=CHANNEL_ON_OFF)
-class Switch(ZhaEntity, SwitchDevice):
- """ZHA switch."""
+class BaseSwitch(SwitchDevice):
+ """Common base class for zha switches."""
- def __init__(self, unique_id, zha_device, channels, **kwargs):
+ def __init__(self, *args, **kwargs):
"""Initialize the ZHA switch."""
- super().__init__(unique_id, zha_device, channels, **kwargs)
- self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
+ self._on_off_channel = None
+ self._state = None
+ super().__init__(*args, **kwargs)
@property
def is_on(self) -> bool:
@@ -54,7 +57,7 @@ class Switch(ZhaEntity, SwitchDevice):
return False
return self._state
- async def async_turn_on(self, **kwargs):
+ async def async_turn_on(self, **kwargs) -> None:
"""Turn the entity on."""
result = await self._on_off_channel.on()
if not isinstance(result, list) or result[1] is not Status.SUCCESS:
@@ -62,7 +65,7 @@ class Switch(ZhaEntity, SwitchDevice):
self._state = True
self.async_write_ha_state()
- async def async_turn_off(self, **kwargs):
+ async def async_turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
result = await self._on_off_channel.off()
if not isinstance(result, list) or result[1] is not Status.SUCCESS:
@@ -70,18 +73,23 @@ class Switch(ZhaEntity, SwitchDevice):
self._state = False
self.async_write_ha_state()
+
+@STRICT_MATCH(channel_names=CHANNEL_ON_OFF)
+class Switch(BaseSwitch, ZhaEntity):
+ """ZHA switch."""
+
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
+ """Initialize the ZHA switch."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
+
@callback
- def async_set_state(self, attr_id, attr_name, value):
+ def async_set_state(self, attr_id: int, attr_name: str, value: Any):
"""Handle state update from channel."""
self._state = bool(value)
self.async_write_ha_state()
- @property
- def device_state_attributes(self):
- """Return state attributes."""
- return self.state_attributes
-
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass."""
await super().async_added_to_hass()
await self.async_accept_signal(
@@ -89,14 +97,37 @@ class Switch(ZhaEntity, SwitchDevice):
)
@callback
- def async_restore_last_state(self, last_state):
+ def async_restore_last_state(self, last_state) -> None:
"""Restore previous state."""
self._state = last_state.state == STATE_ON
- async def async_update(self):
+ async def async_update(self) -> None:
"""Attempt to retrieve on off state from the switch."""
await super().async_update()
if self._on_off_channel:
state = await self._on_off_channel.get_attribute_value("on_off")
if state is not None:
self._state = state
+
+
+@GROUP_MATCH()
+class SwitchGroup(BaseSwitch, ZhaGroupEntity):
+ """Representation of a switch group."""
+
+ def __init__(
+ self, entity_ids: List[str], unique_id: str, group_id: int, zha_device, **kwargs
+ ) -> None:
+ """Initialize a switch group."""
+ super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs)
+ self._available: bool = False
+ group = self.zha_device.gateway.get_group(self._group_id)
+ self._on_off_channel = group.endpoint[OnOff.cluster_id]
+
+ async def async_update(self) -> None:
+ """Query all members and determine the light group state."""
+ all_states = [self.hass.states.get(x) for x in self._entity_ids]
+ states: List[State] = list(filter(None, all_states))
+ on_states = [state for state in states if state.state == STATE_ON]
+
+ self._state = len(on_states) > 0
+ self._available = any(state.state != STATE_UNAVAILABLE for state in states)
diff --git a/homeassistant/components/zone/.translations/no.json b/homeassistant/components/zone/.translations/no.json
index 3c1a91976f0..9bf6e189369 100644
--- a/homeassistant/components/zone/.translations/no.json
+++ b/homeassistant/components/zone/.translations/no.json
@@ -11,7 +11,7 @@
"longitude": "Lengdegrad",
"name": "Navn",
"passive": "Passiv",
- "radius": "Radius"
+ "radius": ""
},
"title": "Definer sone parametere"
}
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
index b1d784a7acb..74c145e19d9 100644
--- a/homeassistant/components/zone/__init__.py
+++ b/homeassistant/components/zone/__init__.py
@@ -237,7 +237,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
home_zone = Zone(_home_conf(hass), True,)
home_zone.entity_id = ENTITY_ID_HOME
- await component.async_add_entities([home_zone]) # type: ignore
+ await component.async_add_entities([home_zone])
async def core_config_updated(_: Event) -> None:
"""Handle core config updated."""
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 968c98e073b..4b829692ea5 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -1,7 +1,7 @@
"""Constants used by Home Assistant components."""
MAJOR_VERSION = 0
-MINOR_VERSION = 107
-PATCH_VERSION = "7"
+MINOR_VERSION = 108
+PATCH_VERSION = "0"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER = (3, 7, 0)
@@ -184,6 +184,7 @@ EVENT_CORE_CONFIG_UPDATE = "core_config_updated"
EVENT_HOMEASSISTANT_CLOSE = "homeassistant_close"
EVENT_HOMEASSISTANT_START = "homeassistant_start"
EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
+EVENT_HOMEASSISTANT_FINAL_WRITE = "homeassistant_final_write"
EVENT_LOGBOOK_ENTRY = "logbook_entry"
EVENT_PLATFORM_DISCOVERED = "platform_discovered"
EVENT_SCRIPT_STARTED = "script_started"
diff --git a/homeassistant/core.py b/homeassistant/core.py
index a1d9a83d1ad..9265c57bbf3 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -47,6 +47,7 @@ from homeassistant.const import (
EVENT_CALL_SERVICE,
EVENT_CORE_CONFIG_UPDATE,
EVENT_HOMEASSISTANT_CLOSE,
+ EVENT_HOMEASSISTANT_FINAL_WRITE,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
EVENT_SERVICE_REGISTERED,
@@ -93,7 +94,7 @@ SOURCE_DISCOVERED = "discovered"
SOURCE_STORAGE = "storage"
SOURCE_YAML = "yaml"
-# How long to wait till things that run on startup have to finish.
+# How long to wait until things that run on startup have to finish.
TIMEOUT_EVENT_START = 15
_LOGGER = logging.getLogger(__name__)
@@ -151,6 +152,7 @@ class CoreState(enum.Enum):
starting = "STARTING"
running = "RUNNING"
stopping = "STOPPING"
+ writing_data = "WRITING_DATA"
def __str__(self) -> str:
"""Return the event."""
@@ -249,7 +251,7 @@ class HomeAssistant:
try:
# Only block for EVENT_HOMEASSISTANT_START listener
self.async_stop_track_tasks()
- with timeout(TIMEOUT_EVENT_START):
+ async with timeout(TIMEOUT_EVENT_START):
await self.async_block_till_done()
except asyncio.TimeoutError:
_LOGGER.warning(
@@ -374,13 +376,13 @@ class HomeAssistant:
self.async_add_job(target, *args)
def block_till_done(self) -> None:
- """Block till all pending work is done."""
+ """Block until all pending work is done."""
asyncio.run_coroutine_threadsafe(
self.async_block_till_done(), self.loop
).result()
async def async_block_till_done(self) -> None:
- """Block till all pending work is done."""
+ """Block until all pending work is done."""
# To flush out any call_soon_threadsafe
await asyncio.sleep(0)
@@ -412,7 +414,7 @@ class HomeAssistant:
# regardless of the state of the loop.
if self.state == CoreState.not_running: # just ignore
return
- if self.state == CoreState.stopping:
+ if self.state == CoreState.stopping or self.state == CoreState.writing_data:
_LOGGER.info("async_stop called twice: ignored")
return
if self.state == CoreState.starting:
@@ -426,6 +428,11 @@ class HomeAssistant:
await self.async_block_till_done()
# stage 2
+ self.state = CoreState.writing_data
+ self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
+ await self.async_block_till_done()
+
+ # stage 3
self.state = CoreState.not_running
self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
await self.async_block_till_done()
@@ -901,7 +908,7 @@ class StateMachine:
).result()
@callback
- def async_remove(self, entity_id: str) -> bool:
+ def async_remove(self, entity_id: str, context: Optional[Context] = None) -> bool:
"""Remove the state of an entity.
Returns boolean to indicate if an entity was removed.
@@ -917,6 +924,8 @@ class StateMachine:
self._bus.async_fire(
EVENT_STATE_CHANGED,
{"entity_id": entity_id, "old_state": old_state, "new_state": None},
+ EventOrigin.local,
+ context=context,
)
return True
@@ -1150,25 +1159,15 @@ class ServiceRegistry:
service_data: Optional[Dict] = None,
blocking: bool = False,
context: Optional[Context] = None,
+ limit: Optional[float] = SERVICE_CALL_LIMIT,
) -> Optional[bool]:
"""
Call a service.
- Specify blocking=True to wait till service is executed.
- Waits a maximum of SERVICE_CALL_LIMIT.
-
- If blocking = True, will return boolean if service executed
- successfully within SERVICE_CALL_LIMIT.
-
- This method will fire an event to call the service.
- This event will be picked up by this ServiceRegistry and any
- other ServiceRegistry that is listening on the EventBus.
-
- Because the service is sent as an event you are not allowed to use
- the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data.
+ See description of async_call for details.
"""
return asyncio.run_coroutine_threadsafe(
- self.async_call(domain, service, service_data, blocking, context),
+ self.async_call(domain, service, service_data, blocking, context, limit),
self._hass.loop,
).result()
@@ -1179,19 +1178,18 @@ class ServiceRegistry:
service_data: Optional[Dict] = None,
blocking: bool = False,
context: Optional[Context] = None,
+ limit: Optional[float] = SERVICE_CALL_LIMIT,
) -> Optional[bool]:
"""
Call a service.
- Specify blocking=True to wait till service is executed.
- Waits a maximum of SERVICE_CALL_LIMIT.
+ Specify blocking=True to wait until service is executed.
+ Waits a maximum of limit, which may be None for no timeout.
If blocking = True, will return boolean if service executed
- successfully within SERVICE_CALL_LIMIT.
+ successfully within limit.
- This method will fire an event to call the service.
- This event will be picked up by this ServiceRegistry and any
- other ServiceRegistry that is listening on the EventBus.
+ This method will fire an event to indicate the service has been called.
Because the service is sent as an event you are not allowed to use
the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data.
@@ -1230,7 +1228,7 @@ class ServiceRegistry:
return None
try:
- with timeout(SERVICE_CALL_LIMIT):
+ async with timeout(limit):
await asyncio.shield(self._execute_service(handler, service_call))
return True
except asyncio.TimeoutError:
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index b281a322b23..dd0342a06a3 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -24,11 +24,14 @@ FLOWS = [
"deconz",
"dialogflow",
"directv",
+ "doorbird",
"dynalite",
"ecobee",
"elgato",
+ "elkm1",
"emulated_roku",
"esphome",
+ "freebox",
"garmin_connect",
"gdacs",
"geofency",
@@ -39,6 +42,7 @@ FLOWS = [
"gpslogger",
"griddy",
"hangouts",
+ "harmony",
"heos",
"hisense_aehw4a1",
"homekit_controller",
@@ -50,6 +54,7 @@ FLOWS = [
"ifttt",
"ios",
"ipma",
+ "ipp",
"iqvia",
"izone",
"konnected",
@@ -67,20 +72,29 @@ FLOWS = [
"mikrotik",
"minecraft_server",
"mobile_app",
+ "monoprice",
"mqtt",
+ "myq",
"neato",
"nest",
"netatmo",
+ "nexia",
"notion",
+ "nuheat",
+ "nut",
"opentherm_gw",
"openuv",
"owntracks",
"plaato",
"plex",
"point",
+ "powerwall",
"ps4",
+ "pvpc_hourly_pricing",
+ "rachio",
"rainmachine",
"ring",
+ "roku",
"samsungtv",
"sense",
"sentry",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index 3bf54b1d9f7..c9832ea2d86 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -17,6 +17,12 @@ SSDP = {
"manufacturer": "DIRECTV"
}
],
+ "harmony": [
+ {
+ "deviceType": "urn:myharmony-com:device:harmony:1",
+ "manufacturer": "Logitech"
+ }
+ ],
"heos": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
@@ -47,6 +53,13 @@ SSDP = {
"manufacturer": "konnected.io"
}
],
+ "roku": [
+ {
+ "deviceType": "urn:roku-com:device:player:1-0",
+ "manufacturer": "Roku",
+ "st": "roku:ecp"
+ }
+ ],
"samsungtv": [
{
"st": "urn:samsung.com:device:RemoteControlReceiver:1"
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 9817dd69f81..46b3a9943f8 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -7,7 +7,8 @@ To update, run python3 -m script.hassfest
ZEROCONF = {
"_axis-video._tcp.local.": [
- "axis"
+ "axis",
+ "doorbird"
],
"_coap._udp.local.": [
"tradfri"
@@ -24,6 +25,12 @@ ZEROCONF = {
"_hap._tcp.local.": [
"homekit_controller"
],
+ "_ipp._tcp.local.": [
+ "ipp"
+ ],
+ "_ipps._tcp.local.": [
+ "ipp"
+ ],
"_printer._tcp.local.": [
"brother"
],
@@ -39,10 +46,12 @@ ZEROCONF = {
}
HOMEKIT = {
+ "819LMB": "myq",
"BSB002": "hue",
"LIFX": "lifx",
"Netatmo Relay": "netatmo",
"Presence": "netatmo",
+ "Rachio": "rachio",
"TRADFRI": "tradfri",
"Welcome": "netatmo",
"Wemo": "wemo"
diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py
index 8234dd6ec87..e720887eb70 100644
--- a/homeassistant/helpers/collection.py
+++ b/homeassistant/helpers/collection.py
@@ -266,7 +266,7 @@ def attach_entity_component_collection(
"""Handle a collection change."""
if change_type == CHANGE_ADDED:
entity = create_entity(config)
- await entity_component.async_add_entities([entity]) # type: ignore
+ await entity_component.async_add_entities([entity])
entities[item_id] = entity
return
diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py
index 9baed41dd20..0ae91ad5591 100644
--- a/homeassistant/helpers/config_entry_oauth2_flow.py
+++ b/homeassistant/helpers/config_entry_oauth2_flow.py
@@ -58,11 +58,10 @@ class AbstractOAuth2Implementation(ABC):
Pass external data in with:
- ```python
await hass.config_entries.flow.async_configure(
flow_id=flow_id, user_input=external_data
)
- ```
+
"""
@abstractmethod
@@ -196,7 +195,9 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
"""Extra data that needs to be appended to the authorize url."""
return {}
- async def async_step_pick_implementation(self, user_input: dict = None) -> dict:
+ async def async_step_pick_implementation(
+ self, user_input: Optional[dict] = None
+ ) -> dict:
"""Handle a flow start."""
assert self.hass
implementations = await async_get_implementations(self.hass, self.DOMAIN)
@@ -224,7 +225,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
),
)
- async def async_step_auth(self, user_input: dict = None) -> dict:
+ async def async_step_auth(self, user_input: Optional[dict] = None) -> dict:
"""Create an entry for auth."""
# Flow has been triggered by external data
if user_input:
@@ -241,7 +242,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
return self.async_external_step(step_id="auth", url=url)
- async def async_step_creation(self, user_input: dict = None) -> dict:
+ async def async_step_creation(self, user_input: Optional[dict] = None) -> dict:
"""Create config entry from external data."""
token = await self.flow_impl.async_resolve_external_data(self.external_data)
token["expires_at"] = time.time() + token["expires_in"]
@@ -259,7 +260,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
"""
return self.async_create_entry(title=self.flow_impl.name, data=data)
- async def async_step_discovery(self, user_input: dict = None) -> dict:
+ async def async_step_discovery(self, user_input: Optional[dict] = None) -> dict:
"""Handle a flow initialized by discovery."""
await self.async_set_unique_id(self.DOMAIN)
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index db966d93412..7bb3223f3d7 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -338,7 +338,7 @@ def date(value: Any) -> date_sys:
def time_period_str(value: str) -> timedelta:
"""Validate and transform time offset."""
- if isinstance(value, int):
+ if isinstance(value, int): # type: ignore
raise vol.Invalid("Make sure you wrap time values in quotes")
if not isinstance(value, str):
raise vol.Invalid(TIME_PERIOD_ERROR.format(value))
diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py
index 806540e57ce..ea20d8c9216 100644
--- a/homeassistant/helpers/discovery.py
+++ b/homeassistant/helpers/discovery.py
@@ -5,7 +5,7 @@ There are two different types of discoveries that can be fired/listened for.
- listen_platform/discover_platform is for platforms. These are used by
components to allow discovery of their platforms.
"""
-from typing import Callable, Collection, Union
+from typing import Any, Callable, Collection, Dict, Optional, Union
from homeassistant import core, setup
from homeassistant.const import ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED
@@ -90,7 +90,9 @@ def listen_platform(
@bind_hass
def async_listen_platform(
- hass: core.HomeAssistant, component: str, callback: Callable
+ hass: core.HomeAssistant,
+ component: str,
+ callback: Callable[[str, Optional[Dict[str, Any]]], Any],
) -> None:
"""Register a platform loader listener.
diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py
index a4e624f119f..bb6fa3a735d 100644
--- a/homeassistant/helpers/dispatcher.py
+++ b/homeassistant/helpers/dispatcher.py
@@ -47,7 +47,10 @@ def async_dispatcher_connect(
wrapped_target = catch_log_exception(
target,
lambda *args: "Exception in {} when dispatching '{}': {}".format(
- target.__name__, signal, args
+ # Functions wrapped in partial do not have a __name__
+ getattr(target, "__name__", None) or str(target),
+ signal,
+ args,
),
)
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 186aecd78f4..62d46500451 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -504,7 +504,7 @@ class Entity(ABC):
while self._on_remove:
self._on_remove.pop()()
- self.hass.states.async_remove(self.entity_id)
+ self.hass.states.async_remove(self.entity_id, context=self._context)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.
@@ -542,6 +542,7 @@ class Entity(ABC):
data = event.data
if data["action"] == "remove" and data["entity_id"] == self.entity_id:
await self.async_removed_from_registry()
+ await self.async_remove()
if (
data["action"] != "update"
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index f6c473dd418..76c2cb9889e 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -4,12 +4,14 @@ from datetime import timedelta
from itertools import chain
import logging
from types import ModuleType
-from typing import Dict, Optional, cast
+from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
+
+import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_SCAN_INTERVAL
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_per_platform,
@@ -18,13 +20,12 @@ from homeassistant.helpers import (
entity,
service,
)
+from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.setup import async_prepare_setup_platform
from .entity_platform import EntityPlatform
-# mypy: allow-untyped-defs, no-check-untyped-defs
-
DEFAULT_SCAN_INTERVAL = timedelta(seconds=15)
DATA_INSTANCES = "entity_components"
@@ -75,18 +76,18 @@ class EntityComponent:
self.domain = domain
self.scan_interval = scan_interval
- self.config = None
+ self.config: Optional[ConfigType] = None
- self._platforms: Dict[str, EntityPlatform] = {
- domain: self._async_init_entity_platform(domain, None)
- }
+ self._platforms: Dict[
+ Union[str, Tuple[str, Optional[timedelta], Optional[str]]], EntityPlatform
+ ] = {domain: self._async_init_entity_platform(domain, None)}
self.async_add_entities = self._platforms[domain].async_add_entities
self.add_entities = self._platforms[domain].add_entities
hass.data.setdefault(DATA_INSTANCES, {})[domain] = self
@property
- def entities(self):
+ def entities(self) -> Iterable[entity.Entity]:
"""Return an iterable that returns all entities."""
return chain.from_iterable(
platform.entities.values() for platform in self._platforms.values()
@@ -95,19 +96,23 @@ class EntityComponent:
def get_entity(self, entity_id: str) -> Optional[entity.Entity]:
"""Get an entity."""
for platform in self._platforms.values():
- entity_obj = cast(Optional[entity.Entity], platform.entities.get(entity_id))
+ entity_obj = platform.entities.get(entity_id)
if entity_obj is not None:
return entity_obj
return None
- def setup(self, config):
+ def setup(self, config: ConfigType) -> None:
"""Set up a full entity component.
This doesn't block the executor to protect from deadlocks.
"""
- self.hass.add_job(self.async_setup(config))
+ self.hass.add_job(
+ self.async_setup( # type: ignore
+ config
+ )
+ )
- async def async_setup(self, config):
+ async def async_setup(self, config: ConfigType) -> None:
"""Set up a full entity component.
Loads the platforms from the config and will listen for supported
@@ -123,11 +128,13 @@ class EntityComponent:
tasks.append(self.async_setup_platform(p_type, p_config))
if tasks:
- await asyncio.wait(tasks)
+ await asyncio.gather(*tasks)
# Generic discovery listener for loading platform dynamically
# Refer to: homeassistant.components.discovery.load_platform()
- async def component_platform_discovered(platform, info):
+ async def component_platform_discovered(
+ platform: str, info: Optional[Dict[str, Any]]
+ ) -> None:
"""Handle the loading of a platform."""
await self.async_setup_platform(platform, {}, info)
@@ -135,7 +142,7 @@ class EntityComponent:
self.hass, self.domain, component_platform_discovered
)
- async def async_setup_entry(self, config_entry):
+ async def async_setup_entry(self, config_entry: ConfigEntry) -> bool:
"""Set up a config entry."""
platform_type = config_entry.domain
platform = await async_prepare_setup_platform(
@@ -161,7 +168,7 @@ class EntityComponent:
scan_interval=getattr(platform, "SCAN_INTERVAL", None),
)
- return await self._platforms[key].async_setup_entry(config_entry)
+ return await self._platforms[key].async_setup_entry(config_entry) # type: ignore
async def async_unload_entry(self, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
@@ -175,24 +182,32 @@ class EntityComponent:
await platform.async_reset()
return True
- async def async_extract_from_service(self, service_call, expand_group=True):
+ async def async_extract_from_service(
+ self, service_call: ServiceCall, expand_group: bool = True
+ ) -> List[entity.Entity]:
"""Extract all known and available entities from a service call.
Will return an empty list if entities specified but unknown.
This method must be run in the event loop.
"""
- return await service.async_extract_entities(
+ return await service.async_extract_entities( # type: ignore
self.hass, self.entities, service_call, expand_group
)
@callback
- def async_register_entity_service(self, name, schema, func, required_features=None):
+ def async_register_entity_service(
+ self,
+ name: str,
+ schema: Union[Dict[str, Any], vol.Schema],
+ func: str,
+ required_features: Optional[int] = None,
+ ) -> None:
"""Register an entity service."""
if isinstance(schema, dict):
schema = cv.make_entity_service_schema(schema)
- async def handle_service(call):
+ async def handle_service(call: Callable) -> None:
"""Handle the service."""
await self.hass.helpers.service.entity_service_call(
self._platforms.values(), func, call, required_features
@@ -201,8 +216,11 @@ class EntityComponent:
self.hass.services.async_register(self.domain, name, handle_service, schema)
async def async_setup_platform(
- self, platform_type, platform_config, discovery_info=None
- ):
+ self,
+ platform_type: str,
+ platform_config: ConfigType,
+ discovery_info: Optional[DiscoveryInfoType] = None,
+ ) -> None:
"""Set up a platform for this component."""
if self.config is None:
raise RuntimeError("async_setup needs to be called first")
@@ -227,17 +245,25 @@ class EntityComponent:
platform_type, platform, scan_interval, entity_namespace
)
- await self._platforms[key].async_setup(platform_config, discovery_info)
+ await self._platforms[key].async_setup( # type: ignore
+ platform_config, discovery_info
+ )
async def _async_reset(self) -> None:
"""Remove entities and reset the entity component to initial values.
This method must be run in the event loop.
"""
- tasks = [platform.async_reset() for platform in self._platforms.values()]
+ tasks = []
+
+ for key, platform in self._platforms.items():
+ if key == self.domain:
+ tasks.append(platform.async_reset())
+ else:
+ tasks.append(platform.async_destroy())
if tasks:
- await asyncio.wait(tasks)
+ await asyncio.gather(*tasks)
self._platforms = {self.domain: self._platforms[self.domain]}
self.config = None
@@ -285,7 +311,7 @@ class EntityComponent:
if scan_interval is None:
scan_interval = self.scan_interval
- return EntityPlatform( # type: ignore
+ return EntityPlatform(
hass=self.hass,
logger=self.logger,
domain=self.domain,
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index e1e046eaa6d..4cbb7a23496 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -1,23 +1,30 @@
"""Class to manage the entities for a single platform."""
import asyncio
from contextvars import ContextVar
-from datetime import datetime
-from typing import Optional
+from datetime import datetime, timedelta
+from logging import Logger
+from types import ModuleType
+from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, cast
from homeassistant.const import DEVICE_DEFAULT_NAME
-from homeassistant.core import callback, split_entity_id, valid_entity_id
+from homeassistant.core import CALLBACK_TYPE, callback, split_entity_id, valid_entity_id
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import config_validation as cv, service
+from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.async_ import run_callback_threadsafe
from .entity_registry import DISABLED_INTEGRATION
from .event import async_call_later, async_track_time_interval
+if TYPE_CHECKING:
+ from .entity import Entity
+
# mypy: allow-untyped-defs, no-check-untyped-defs
SLOW_SETUP_WARNING = 10
SLOW_SETUP_MAX_WAIT = 60
PLATFORM_NOT_READY_RETRIES = 10
+DATA_ENTITY_PLATFORM = "entity_platform"
class EntityPlatform:
@@ -26,23 +33,15 @@ class EntityPlatform:
def __init__(
self,
*,
- hass,
- logger,
- domain,
- platform_name,
- platform,
- scan_interval,
- entity_namespace,
+ hass: HomeAssistantType,
+ logger: Logger,
+ domain: str,
+ platform_name: str,
+ platform: Optional[ModuleType],
+ scan_interval: timedelta,
+ entity_namespace: Optional[str],
):
- """Initialize the entity platform.
-
- hass: HomeAssistant
- logger: Logger
- domain: str
- platform_name: str
- scan_interval: timedelta
- entity_namespace: str
- """
+ """Initialize the entity platform."""
self.hass = hass
self.logger = logger
self.domain = domain
@@ -51,23 +50,23 @@ class EntityPlatform:
self.scan_interval = scan_interval
self.entity_namespace = entity_namespace
self.config_entry = None
- self.entities = {}
- self._tasks = []
+ self.entities: Dict[str, Entity] = {} # pylint: disable=used-before-assignment
+ self._tasks: List[asyncio.Future] = []
# Method to cancel the state change listener
- self._async_unsub_polling = None
+ self._async_unsub_polling: Optional[CALLBACK_TYPE] = None
# Method to cancel the retry of setup
- self._async_cancel_retry_setup = None
- self._process_updates = None
+ self._async_cancel_retry_setup: Optional[CALLBACK_TYPE] = None
+ self._process_updates: Optional[asyncio.Lock] = None
+
+ self.parallel_updates: Optional[asyncio.Semaphore] = None
# Platform is None for the EntityComponent "catch-all" EntityPlatform
# which powers entity_component.add_entities
- if platform is None:
- self.parallel_updates_created = True
- self.parallel_updates: Optional[asyncio.Semaphore] = None
- return
+ self.parallel_updates_created = platform is None
- self.parallel_updates_created = False
- self.parallel_updates = None
+ hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault(
+ self.platform_name, []
+ ).append(self)
@callback
def _get_parallel_updates_semaphore(
@@ -184,7 +183,7 @@ class EntityPlatform:
self._tasks.clear()
if pending:
- await asyncio.wait(pending)
+ await asyncio.gather(*pending)
hass.config.components.add(full_name)
return True
@@ -224,7 +223,9 @@ class EntityPlatform:
finally:
warn_task.cancel()
- def _schedule_add_entities(self, new_entities, update_before_add=False):
+ def _schedule_add_entities(
+ self, new_entities: Iterable["Entity"], update_before_add: bool = False
+ ) -> None:
"""Schedule adding entities for a single platform, synchronously."""
run_callback_threadsafe(
self.hass.loop,
@@ -234,17 +235,24 @@ class EntityPlatform:
).result()
@callback
- def _async_schedule_add_entities(self, new_entities, update_before_add=False):
+ def _async_schedule_add_entities(
+ self, new_entities: Iterable["Entity"], update_before_add: bool = False
+ ) -> None:
"""Schedule adding entities for a single platform async."""
self._tasks.append(
- self.hass.async_add_job(
- self.async_add_entities(
- new_entities, update_before_add=update_before_add
- )
+ cast(
+ asyncio.Future,
+ self.hass.async_add_job(
+ self.async_add_entities( # type: ignore
+ new_entities, update_before_add=update_before_add
+ ),
+ ),
)
)
- def add_entities(self, new_entities, update_before_add=False):
+ def add_entities(
+ self, new_entities: Iterable["Entity"], update_before_add: bool = False
+ ) -> None:
"""Add entities for a single platform."""
# That avoid deadlocks
if update_before_add:
@@ -258,7 +266,9 @@ class EntityPlatform:
self.hass.loop,
).result()
- async def async_add_entities(self, new_entities, update_before_add=False):
+ async def async_add_entities(
+ self, new_entities: Iterable["Entity"], update_before_add: bool = False
+ ) -> None:
"""Add entities for a single platform async.
This method must be run in the event loop.
@@ -272,7 +282,7 @@ class EntityPlatform:
device_registry = await hass.helpers.device_registry.async_get_registry()
entity_registry = await hass.helpers.entity_registry.async_get_registry()
tasks = [
- self._async_add_entity(
+ self._async_add_entity( # type: ignore
entity, update_before_add, entity_registry, device_registry
)
for entity in new_entities
@@ -282,7 +292,7 @@ class EntityPlatform:
if not tasks:
return
- await asyncio.wait(tasks)
+ await asyncio.gather(*tasks)
if self._async_unsub_polling is not None or not any(
entity.should_poll for entity in self.entities.values()
@@ -290,7 +300,9 @@ class EntityPlatform:
return
self._async_unsub_polling = async_track_time_interval(
- self.hass, self._update_entity_states, self.scan_interval
+ self.hass,
+ self._update_entity_states, # type: ignore
+ self.scan_interval,
)
async def _async_add_entity(
@@ -419,10 +431,11 @@ class EntityPlatform:
already_exists = True
if already_exists:
- msg = f"Entity id already exists: {entity.entity_id}"
+ msg = f"Entity id already exists - ignoring: {entity.entity_id}"
if entity.unique_id is not None:
msg += f". Platform {self.platform_name} does not generate unique IDs"
- raise HomeAssistantError(msg)
+ self.logger.error(msg)
+ return
entity_id = entity.entity_id
self.entities[entity_id] = entity
@@ -447,12 +460,20 @@ class EntityPlatform:
tasks = [self.async_remove_entity(entity_id) for entity_id in self.entities]
- await asyncio.wait(tasks)
+ await asyncio.gather(*tasks)
if self._async_unsub_polling is not None:
self._async_unsub_polling()
self._async_unsub_polling = None
+ async def async_destroy(self) -> None:
+ """Destroy an entity platform.
+
+ Call before discarding the object.
+ """
+ await self.async_reset()
+ self.hass.data[DATA_ENTITY_PLATFORM][self.platform_name].remove(self)
+
async def async_remove_entity(self, entity_id: str) -> None:
"""Remove entity id from platform."""
await self.entities[entity_id].async_remove()
@@ -477,14 +498,24 @@ class EntityPlatform:
@callback
def async_register_entity_service(self, name, schema, func, required_features=None):
- """Register an entity service."""
+ """Register an entity service.
+
+ Services will automatically be shared by all platforms of the same domain.
+ """
+ if self.hass.services.has_service(self.platform_name, name):
+ return
+
if isinstance(schema, dict):
schema = cv.make_entity_service_schema(schema)
async def handle_service(call):
"""Handle the service."""
await service.entity_service_call(
- self.hass, [self], func, call, required_features
+ self.hass,
+ self.hass.data[DATA_ENTITY_PLATFORM][self.platform_name],
+ func,
+ call,
+ required_features,
)
self.hass.services.async_register(
@@ -515,10 +546,10 @@ class EntityPlatform:
for entity in self.entities.values():
if not entity.should_poll:
continue
- tasks.append(entity.async_update_ha_state(True))
+ tasks.append(entity.async_update_ha_state(True)) # type: ignore
if tasks:
- await asyncio.wait(tasks)
+ await asyncio.gather(*tasks)
current_platform: ContextVar[Optional[EntityPlatform]] = ContextVar(
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 87383d45635..b8e54155922 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -518,7 +518,7 @@ def async_setup_entity_restore(
if state is None or not state.attributes.get(ATTR_RESTORED):
return
- hass.states.async_remove(event.data["entity_id"])
+ hass.states.async_remove(event.data["entity_id"], context=event.context)
hass.bus.async_listen(EVENT_ENTITY_REGISTRY_UPDATED, cleanup_restored_states)
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 74faca6a1d2..8a4b4bc2b76 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -67,8 +67,8 @@ def async_track_state_change(
Must be run within the event loop.
"""
- match_from_state = _process_state_match(from_state)
- match_to_state = _process_state_match(to_state)
+ match_from_state = process_state_match(from_state)
+ match_to_state = process_state_match(to_state)
# Ensure it is a lowercase list with entity ids we want to match on
if entity_ids == MATCH_ALL:
@@ -473,7 +473,7 @@ def async_track_time_change(
track_time_change = threaded_listener_factory(async_track_time_change)
-def _process_state_match(
+def process_state_match(
parameter: Union[None, str, Iterable[str]]
) -> Callable[[str], bool]:
"""Convert parameter to function that matches input against parameter."""
diff --git a/homeassistant/helpers/logging.py b/homeassistant/helpers/logging.py
index 2e3270879f0..49b9bfcffec 100644
--- a/homeassistant/helpers/logging.py
+++ b/homeassistant/helpers/logging.py
@@ -35,7 +35,7 @@ class KeywordStyleAdapter(logging.LoggerAdapter):
"""Log the message provided at the appropriate level."""
if self.isEnabledFor(level):
msg, log_kwargs = self.process(msg, kwargs)
- self.logger._log( # type: ignore # pylint: disable=protected-access
+ self.logger._log( # pylint: disable=protected-access
level, KeywordMessage(msg, args, kwargs), (), **log_kwargs
)
@@ -48,7 +48,7 @@ class KeywordStyleAdapter(logging.LoggerAdapter):
{
k: kwargs[k]
for k in inspect.getfullargspec(
- self.logger._log # type: ignore # pylint: disable=protected-access
+ self.logger._log # pylint: disable=protected-access
).args[1:]
if k in kwargs
},
diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py
index d57d3ad9920..0757770d2f7 100644
--- a/homeassistant/helpers/restore_state.py
+++ b/homeassistant/helpers/restore_state.py
@@ -4,7 +4,10 @@ from datetime import datetime, timedelta
import logging
from typing import Any, Awaitable, Dict, List, Optional, Set, cast
-from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_FINAL_WRITE,
+ EVENT_HOMEASSISTANT_START,
+)
from homeassistant.core import (
CoreState,
HomeAssistant,
@@ -184,7 +187,9 @@ class RestoreStateData:
async_track_time_interval(self.hass, _async_dump_states, STATE_DUMP_INTERVAL)
# Dump states when stopping hass
- self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_dump_states)
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_FINAL_WRITE, _async_dump_states
+ )
@callback
def async_restore_entity_added(self, entity_id: str) -> None:
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 4fe6d062bd5..145bb42af5b 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -7,6 +7,7 @@ from itertools import islice
import logging
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast
+from async_timeout import timeout
import voluptuous as vol
from homeassistant import exceptions
@@ -14,6 +15,7 @@ import homeassistant.components.device_automation as device_automation
import homeassistant.components.scene as scene
from homeassistant.const import (
ATTR_ENTITY_ID,
+ CONF_ALIAS,
CONF_CONDITION,
CONF_CONTINUE_ON_TIMEOUT,
CONF_DELAY,
@@ -25,47 +27,53 @@ from homeassistant.const import (
CONF_SCENE,
CONF_TIMEOUT,
CONF_WAIT_TEMPLATE,
+ SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
-from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ SERVICE_CALL_LIMIT,
+ Context,
+ HomeAssistant,
+ callback,
+ is_callback,
+)
from homeassistant.helpers import (
condition,
config_validation as cv,
- service,
template as template,
)
from homeassistant.helpers.event import (
async_track_point_in_utc_time,
async_track_template,
)
+from homeassistant.helpers.service import (
+ CONF_SERVICE_DATA,
+ async_prepare_call_from_config,
+)
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
-CONF_ALIAS = "alias"
-
-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,
+SCRIPT_MODE_ERROR = "error"
+SCRIPT_MODE_IGNORE = "ignore"
+SCRIPT_MODE_LEGACY = "legacy"
+SCRIPT_MODE_PARALLEL = "parallel"
+SCRIPT_MODE_QUEUE = "queue"
+SCRIPT_MODE_RESTART = "restart"
+SCRIPT_MODE_CHOICES = [
+ SCRIPT_MODE_ERROR,
+ SCRIPT_MODE_IGNORE,
+ SCRIPT_MODE_LEGACY,
+ SCRIPT_MODE_PARALLEL,
+ SCRIPT_MODE_QUEUE,
+ SCRIPT_MODE_RESTART,
]
+DEFAULT_SCRIPT_MODE = SCRIPT_MODE_LEGACY
-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,
-]
+DEFAULT_QUEUE_MAX = 10
_LOG_EXCEPTION = logging.ERROR + 1
_TIMEOUT_MSG = "Timeout reached, abort script."
@@ -102,6 +110,14 @@ class _SuspendScript(Exception):
"""Throw if script needs to suspend."""
+class AlreadyRunning(exceptions.HomeAssistantError):
+ """Throw if script already running and user wants error."""
+
+
+class QueueFull(exceptions.HomeAssistantError):
+ """Throw if script already running, user wants new run queued, but queue is full."""
+
+
class _ScriptRunBase(ABC):
"""Common data & methods for managing Script sequence run."""
@@ -137,11 +153,11 @@ class _ScriptRunBase(ABC):
await getattr(
self, f"_async_{cv.determine_script_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)
+ except Exception as ex:
+ if not isinstance(
+ ex, (_SuspendScript, _StopScript, asyncio.CancelledError)
+ ) and (self._log_exceptions or log_exceptions):
+ self._log_exception(ex)
raise
@abstractmethod
@@ -166,6 +182,12 @@ class _ScriptRunBase(ABC):
elif isinstance(exception, exceptions.ServiceNotFound):
error_desc = "Service not found"
+ elif isinstance(exception, AlreadyRunning):
+ error_desc = "Already running"
+
+ elif isinstance(exception, QueueFull):
+ error_desc = "Run queue is full"
+
else:
error_desc = "Unexpected error"
level = _LOG_EXCEPTION
@@ -189,12 +211,13 @@ class _ScriptRunBase(ABC):
template.render_complex(self._action[CONF_DELAY], self._variables)
)
except (exceptions.TemplateError, vol.Invalid) as ex:
- self._raise(
+ self._log(
"Error rendering %s delay template: %s",
self._script.name,
ex,
- exception=_StopScript,
+ level=logging.ERROR,
)
+ raise _StopScript
self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}")
self._log("Executing step %s", self._script.last_action)
@@ -220,18 +243,14 @@ class _ScriptRunBase(ABC):
self._hass, wait_template, async_script_wait, self._variables
)
+ @abstractmethod
async def _async_call_service_step(self):
"""Call the service specified in the action."""
+
+ def _prep_call_service_step(self):
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,
- self._action,
- blocking=True,
- variables=self._variables,
- validate_config=False,
- context=self._context,
- )
+ return async_prepare_call_from_config(self._hass, self._action, self._variables)
async def _async_device_step(self):
"""Perform the device automation specified in the action."""
@@ -298,10 +317,6 @@ class _ScriptRunBase(ABC):
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."""
@@ -318,24 +333,33 @@ class _ScriptRun(_ScriptRunBase):
self._stop = asyncio.Event()
self._stopped = asyncio.Event()
- async def _async_run(self, propagate_exceptions=True):
- self._log("Running script")
+ def _changed(self):
+ if not self._stop.is_set():
+ super()._changed()
+
+ async def async_run(self) -> None:
+ """Run script."""
try:
+ if self._stop.is_set():
+ return
+ self._script.last_triggered = utcnow()
+ self._changed()
+ self._log("Running script")
for self._step, self._action in enumerate(self._script.sequence):
if self._stop.is_set():
break
- await self._async_step(not propagate_exceptions)
+ await self._async_step(log_exceptions=False)
except _StopScript:
pass
- except Exception: # pylint: disable=broad-except
- if propagate_exceptions:
- raise
finally:
- if not self._stop.is_set():
- self._changed()
+ self._finish()
+
+ def _finish(self):
+ self._script._runs.remove(self) # pylint: disable=protected-access
+ if not self._script.is_running:
self._script.last_action = None
- self._script._runs.remove(self) # pylint: disable=protected-access
- self._stopped.set()
+ self._changed()
+ self._stopped.set()
async def async_stop(self) -> None:
"""Stop script run."""
@@ -344,10 +368,13 @@ class _ScriptRun(_ScriptRunBase):
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)
+ delay = self._prep_delay_step().total_seconds()
+ self._changed()
+ try:
+ async with timeout(delay):
+ await self._stop.wait()
+ except asyncio.TimeoutError:
+ pass
async def _async_wait_template_step(self):
"""Handle a wait template."""
@@ -361,21 +388,20 @@ class _ScriptRun(_ScriptRunBase):
if not unsub:
return
- if not self._stop.is_set():
- self._changed()
+ self._changed()
try:
- timeout = self._action[CONF_TIMEOUT].total_seconds()
+ delay = self._action[CONF_TIMEOUT].total_seconds()
except KeyError:
- timeout = None
+ delay = None
done = asyncio.Event()
try:
- await asyncio.wait_for(
- asyncio.wait(
+ async with timeout(delay):
+ _, pending = await asyncio.wait(
{self._stop.wait(), done.wait()},
return_when=asyncio.FIRST_COMPLETED,
- ),
- timeout,
- )
+ )
+ for pending_task in pending:
+ pending_task.cancel()
except asyncio.TimeoutError:
if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
self._log(_TIMEOUT_MSG)
@@ -383,25 +409,78 @@ class _ScriptRun(_ScriptRunBase):
finally:
unsub()
+ async def _async_call_service_step(self):
+ """Call the service specified in the action."""
+ domain, service, service_data = self._prep_call_service_step()
-class _BackgroundScriptRun(_ScriptRun):
- """Manage background Script sequence run."""
+ # If this might start a script then disable the call timeout.
+ # Otherwise use the normal service call limit.
+ if domain == "script" and service != SERVICE_TURN_OFF:
+ limit = None
+ else:
+ limit = SERVICE_CALL_LIMIT
+
+ coro = self._hass.services.async_call(
+ domain,
+ service,
+ service_data,
+ blocking=True,
+ context=self._context,
+ limit=limit,
+ )
+
+ if limit is not None:
+ # There is a call limit, so just wait for it to finish.
+ await coro
+ return
+
+ # No call limit (i.e., potentially starting one or more fully blocking scripts)
+ # so watch for a stop request.
+ done, pending = await asyncio.wait(
+ {self._stop.wait(), coro}, return_when=asyncio.FIRST_COMPLETED,
+ )
+ # Note that cancelling the service call, if it has not yet returned, will also
+ # stop any non-background script runs that it may have started.
+ for pending_task in pending:
+ pending_task.cancel()
+ # Propagate any exceptions that might have happened.
+ for done_task in done:
+ done_task.result()
+
+
+class _QueuedScriptRun(_ScriptRun):
+ """Manage queued Script sequence run."""
+
+ lock_acquired = False
async def async_run(self) -> None:
"""Run script."""
- self._hass.async_create_task(self._async_run(False))
+ # Wait for previous run, if any, to finish by attempting to acquire the script's
+ # shared lock. At the same time monitor if we've been told to stop.
+ lock_task = self._hass.async_create_task(
+ self._script._queue_lck.acquire() # pylint: disable=protected-access
+ )
+ done, pending = await asyncio.wait(
+ {self._stop.wait(), lock_task}, return_when=asyncio.FIRST_COMPLETED
+ )
+ for pending_task in pending:
+ pending_task.cancel()
+ self.lock_acquired = lock_task in done
+ # If we've been told to stop, then just finish up. Otherwise, we've acquired the
+ # lock so we can go ahead and start the run.
+ if self._stop.is_set():
+ self._finish()
+ else:
+ await super().async_run()
-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
+ def _finish(self):
+ # pylint: disable=protected-access
+ self._script._queue_len -= 1
+ if self.lock_acquired:
+ self._script._queue_lck.release()
+ self.lock_acquired = False
+ super()._finish()
class _LegacyScriptRun(_ScriptRunBase):
@@ -445,6 +524,7 @@ class _LegacyScriptRun(_ScriptRunBase):
async def _async_run(self, propagate_exceptions=True):
if self._cur == -1:
+ self._script.last_triggered = utcnow()
self._log("Running script")
self._cur = 0
@@ -457,7 +537,7 @@ class _LegacyScriptRun(_ScriptRunBase):
for self._step, self._action in islice(
enumerate(self._script.sequence), self._cur, None
):
- await self._async_step(not propagate_exceptions)
+ await self._async_step(log_exceptions=not propagate_exceptions)
except _StopScript:
pass
except _SuspendScript:
@@ -469,11 +549,12 @@ class _LegacyScriptRun(_ScriptRunBase):
if propagate_exceptions:
raise
finally:
- if self._cur != -1:
- self._changed()
+ _cur_was = self._cur
if not suspended:
self._script.last_action = None
await self.async_stop()
+ if _cur_was != -1:
+ self._changed()
async def async_stop(self) -> None:
"""Stop script run."""
@@ -512,9 +593,9 @@ class _LegacyScriptRun(_ScriptRunBase):
@callback
def async_script_timeout(now):
- """Call after timeout is retrieve."""
+ """Call after timeout has expired."""
with suppress(ValueError):
- self._async_listener.remove(unsub)
+ self._async_listener.remove(unsub_timeout)
# Check if we want to continue to execute
# the script after the timeout
@@ -530,13 +611,19 @@ class _LegacyScriptRun(_ScriptRunBase):
self._async_listener.append(unsub_wait)
if CONF_TIMEOUT in self._action:
- unsub = async_track_point_in_utc_time(
+ unsub_timeout = async_track_point_in_utc_time(
self._hass, async_script_timeout, utcnow() + self._action[CONF_TIMEOUT]
)
- self._async_listener.append(unsub)
+ self._async_listener.append(unsub_timeout)
raise _SuspendScript
+ async def _async_call_service_step(self):
+ """Call the service specified in the action."""
+ await self._hass.services.async_call(
+ *self._prep_call_service_step(), blocking=True, context=self._context
+ )
+
def _async_remove_listener(self):
"""Remove listeners, if any."""
for unsub in self._async_listener:
@@ -553,47 +640,60 @@ class Script:
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,
+ script_mode: str = DEFAULT_SCRIPT_MODE,
+ queue_max: int = DEFAULT_QUEUE_MAX,
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.change_listener = change_listener
+ self._script_mode = script_mode
+ if logger:
+ self._logger = logger
+ else:
+ logger_name = __name__
+ if name:
+ logger_name = ".".join([logger_name, slugify(name)])
+ self._logger = logging.getLogger(logger_name)
+ self._log_exceptions = log_exceptions
+
self.last_action = None
self.last_triggered: Optional[datetime] = None
- self.can_cancel = any(
+ self.can_cancel = not self.is_legacy or 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
+ if script_mode == SCRIPT_MODE_QUEUE:
+ self._queue_max = queue_max
+ self._queue_len = 0
+ self._queue_lck = asyncio.Lock()
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)
+ if self.change_listener:
+ if is_callback(self.change_listener):
+ self.change_listener()
+ else:
+ 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 is_legacy(self) -> bool:
+ """Return if using legacy mode."""
+ return self._script_mode == SCRIPT_MODE_LEGACY
+
@property
def referenced_devices(self):
"""Return a set of referenced devices."""
@@ -626,7 +726,7 @@ class Script:
action = cv.determine_script_action(step)
if action == cv.SCRIPT_ACTION_CALL_SERVICE:
- data = step.get(service.CONF_SERVICE_DATA)
+ data = step.get(CONF_SERVICE_DATA)
if not data:
continue
@@ -661,18 +761,26 @@ class Script:
) -> None:
"""Run script."""
if self.is_running:
- if self._if_running == IF_RUNNING_IGNORE:
+ if self._script_mode == SCRIPT_MODE_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()
+ if self._script_mode == SCRIPT_MODE_ERROR:
+ raise AlreadyRunning
- self.last_triggered = utcnow()
- if self._run_mode == RUN_MODE_LEGACY:
+ if self._script_mode == SCRIPT_MODE_RESTART:
+ self._log("Restarting script")
+ await self.async_stop(update_state=False)
+ elif self._script_mode == SCRIPT_MODE_QUEUE:
+ self._log(
+ "Queueing script behind %i run%s",
+ self._queue_len,
+ "s" if self._queue_len > 1 else "",
+ )
+ if self._queue_len >= self._queue_max:
+ raise QueueFull
+
+ if self.is_legacy:
if self._runs:
shared = cast(Optional[_LegacyScriptRun], self._runs[0])
else:
@@ -681,23 +789,31 @@ class Script:
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
- )
+ if self._script_mode != SCRIPT_MODE_QUEUE:
+ cls = _ScriptRun
else:
- run = _BlockingScriptRun(
- self._hass, self, variables, context, self._log_exceptions
- )
+ cls = _QueuedScriptRun
+ self._queue_len += 1
+ run = cls(self._hass, self, variables, context, self._log_exceptions)
self._runs.append(run)
- await run.async_run()
- async def async_stop(self) -> None:
+ try:
+ if self.is_legacy:
+ await run.async_run()
+ else:
+ await asyncio.shield(run.async_run())
+ except asyncio.CancelledError:
+ await run.async_stop()
+ self._changed()
+ raise
+
+ async def async_stop(self, update_state: bool = True) -> 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()
+ if update_state:
+ self._changed()
def _log(self, msg, *args, level=logging.INFO):
if self.name:
@@ -708,9 +824,3 @@ class Script:
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 578d5368314..7a352b4e8d1 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -56,12 +56,27 @@ async def async_call_from_config(
hass, config, blocking=False, variables=None, validate_config=True, context=None
):
"""Call a service based on a config hash."""
+ try:
+ parms = async_prepare_call_from_config(hass, config, variables, validate_config)
+ except HomeAssistantError as ex:
+ if blocking:
+ raise
+ _LOGGER.error(ex)
+ else:
+ await hass.services.async_call(*parms, blocking, context)
+
+
+@ha.callback
+@bind_hass
+def async_prepare_call_from_config(hass, config, variables=None, validate_config=False):
+ """Prepare to call a service based on a config hash."""
if validate_config:
try:
config = cv.SERVICE_SCHEMA(config)
except vol.Invalid as ex:
- _LOGGER.error("Invalid config for calling service: %s", ex)
- return
+ raise HomeAssistantError(
+ f"Invalid config for calling service: {ex}"
+ ) from ex
if CONF_SERVICE in config:
domain_service = config[CONF_SERVICE]
@@ -71,17 +86,15 @@ async def async_call_from_config(
domain_service = config[CONF_SERVICE_TEMPLATE].async_render(variables)
domain_service = cv.service(domain_service)
except TemplateError as ex:
- if blocking:
- raise
- _LOGGER.error("Error rendering service name template: %s", ex)
- return
- except vol.Invalid:
- if blocking:
- raise
- _LOGGER.error("Template rendered invalid service: %s", domain_service)
- return
+ raise HomeAssistantError(
+ f"Error rendering service name template: {ex}"
+ ) from ex
+ except vol.Invalid as ex:
+ raise HomeAssistantError(
+ f"Template rendered invalid service: {domain_service}"
+ ) from ex
- domain, service_name = domain_service.split(".", 1)
+ domain, service = domain_service.split(".", 1)
service_data = dict(config.get(CONF_SERVICE_DATA, {}))
if CONF_SERVICE_DATA_TEMPLATE in config:
@@ -91,15 +104,12 @@ async def async_call_from_config(
template.render_complex(config[CONF_SERVICE_DATA_TEMPLATE], variables)
)
except TemplateError as ex:
- _LOGGER.error("Error rendering data template: %s", ex)
- return
+ raise HomeAssistantError(f"Error rendering data template: {ex}") from ex
if CONF_SERVICE_ENTITY_ID in config:
service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID]
- await hass.services.async_call(
- domain, service_name, service_data, blocking=blocking, context=context
- )
+ return domain, service, service_data
@bind_hass
diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py
index 1cad8eec473..5885aa01e6f 100644
--- a/homeassistant/helpers/storage.py
+++ b/homeassistant/helpers/storage.py
@@ -5,7 +5,7 @@ import logging
import os
from typing import Any, Callable, Dict, List, Optional, Type, Union
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import bind_hass
@@ -153,7 +153,7 @@ class Store:
"""Ensure that we write if we quit before delay has passed."""
if self._unsub_stop_listener is None:
self._unsub_stop_listener = self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_STOP, self._async_callback_stop_write
+ EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_stop_write
)
@callback
diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py
index e0846d6f893..18ca8355159 100644
--- a/homeassistant/helpers/temperature.py
+++ b/homeassistant/helpers/temperature.py
@@ -22,8 +22,7 @@ def display_temp(
if not isinstance(temperature, Number):
raise TypeError(f"Temperature is not a number: {temperature}")
- # type ignore: https://github.com/python/mypy/issues/7207
- if temperature_unit != ha_unit: # type: ignore
+ if temperature_unit != ha_unit:
temperature = convert_temperature(temperature, temperature_unit, ha_unit)
# Round in the units appropriate
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index e7f89b482e2..5cd15fefd99 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -192,7 +192,7 @@ class Template:
raise TemplateError(err)
def extract_entities(
- self, variables: Dict[str, Any] = None
+ self, variables: Optional[Dict[str, Any]] = None
) -> Union[str, List[str]]:
"""Extract all entities for state_changed listener."""
return extract_entities(self.template, variables)
diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py
index 6e31301066c..c7859d9d1d9 100644
--- a/homeassistant/helpers/typing.py
+++ b/homeassistant/helpers/typing.py
@@ -8,6 +8,7 @@ import homeassistant.core
GPSType = Tuple[float, float]
ConfigType = Dict[str, Any]
ContextType = homeassistant.core.Context
+DiscoveryInfoType = Dict[str, Any]
EventType = homeassistant.core.Event
HomeAssistantType = homeassistant.core.HomeAssistant
ServiceCallType = homeassistant.core.ServiceCall
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index b2fe87148b1..b2b04816616 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -30,8 +30,8 @@ class DataUpdateCoordinator:
logger: logging.Logger,
*,
name: str,
- update_method: Callable[[], Awaitable],
update_interval: timedelta,
+ update_method: Optional[Callable[[], Awaitable]] = None,
request_refresh_debouncer: Optional[Debouncer] = None,
):
"""Initialize global data updater."""
@@ -88,8 +88,14 @@ class DataUpdateCoordinator:
self._unsub_refresh()
self._unsub_refresh = None
+ # We _floor_ utcnow to create a schedule on a rounded second,
+ # minimizing the time between the point and the real activation.
+ # That way we obtain a constant update frequency,
+ # as long as the update process takes less than a second
self._unsub_refresh = async_track_point_in_utc_time(
- self.hass, self._handle_refresh_interval, utcnow() + self.update_interval
+ self.hass,
+ self._handle_refresh_interval,
+ utcnow().replace(microsecond=0) + self.update_interval,
)
async def _handle_refresh_interval(self, _now: datetime) -> None:
@@ -104,8 +110,14 @@ class DataUpdateCoordinator:
"""
await self._debounced_refresh.async_call()
+ async def _async_update_data(self) -> Optional[Any]:
+ """Fetch the latest data from the source."""
+ if self.update_method is None:
+ raise NotImplementedError("Update method not implemented")
+ return await self.update_method()
+
async def async_refresh(self) -> None:
- """Update data."""
+ """Refresh data."""
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
@@ -114,7 +126,7 @@ class DataUpdateCoordinator:
try:
start = monotonic()
- self.data = await self.update_method()
+ self.data = await self._async_update_data()
except asyncio.TimeoutError:
if self.last_update_success:
@@ -131,6 +143,9 @@ class DataUpdateCoordinator:
self.logger.error("Error fetching %s data: %s", self.name, err)
self.last_update_success = False
+ except NotImplementedError as err:
+ raise err
+
except Exception as err: # pylint: disable=broad-except
self.last_update_success = False
self.logger.exception(
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 155dd0e059d..b2e1fa74fba 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -74,7 +74,7 @@ async def _async_get_custom_components(
except ImportError:
return {}
- def get_sub_directories(paths: List) -> List:
+ def get_sub_directories(paths: List[str]) -> List[pathlib.Path]:
"""Return all sub directories in a set of paths."""
return [
entry
@@ -506,7 +506,7 @@ async def async_component_dependencies(hass: "HomeAssistant", domain: str) -> Se
async def _async_component_dependencies(
- hass: "HomeAssistant", domain: str, loaded: Set[str], loading: Set
+ hass: "HomeAssistant", domain: str, loaded: Set[str], loading: Set[str]
) -> Set[str]:
"""Recursive function to get component dependencies.
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 1f36e6e7db5..bf6888e7073 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -12,20 +12,20 @@ cryptography==2.8
defusedxml==0.6.0
distro==1.4.0
hass-nabucasa==0.32.2
-home-assistant-frontend==20200318.1
+home-assistant-frontend==20200407.1
importlib-metadata==1.5.0
-jinja2>=2.10.3
+jinja2>=2.11.1
netdisco==2.6.0
pip>=8.0.3
python-slugify==4.0.0
pytz>=2019.03
-pyyaml==5.3
+pyyaml==5.3.1
requests==2.23.0
ruamel.yaml==0.15.100
-sqlalchemy==1.3.13
+sqlalchemy==1.3.15
voluptuous-serialize==2.3.0
voluptuous==0.11.7
-zeroconf==0.24.5
+zeroconf==0.25.0
pycryptodome>=3.6.6
diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py
index 7b2d3fe9bc3..317fffe84bf 100644
--- a/homeassistant/requirements.py
+++ b/homeassistant/requirements.py
@@ -32,7 +32,7 @@ class RequirementsNotFound(HomeAssistantError):
async def async_get_integration_with_requirements(
- hass: HomeAssistant, domain: str, done: Set[str] = None
+ hass: HomeAssistant, domain: str, done: Optional[Set[str]] = None
) -> Integration:
"""Get an integration with all requirements installed, including the dependencies.
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
index 2c885dd1713..2bc821c8495 100644
--- a/homeassistant/scripts/benchmark/__init__.py
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -5,7 +5,7 @@ from contextlib import suppress
from datetime import datetime
import logging
from timeit import default_timer as timer
-from typing import Callable, Dict
+from typing import Callable, Dict, TypeVar
from homeassistant import core
from homeassistant.components.websocket_api.const import JSON_DUMP
@@ -15,6 +15,8 @@ from homeassistant.util import dt as dt_util
# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
# mypy: no-warn-return-any
+CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name
+
BENCHMARKS: Dict[str, Callable] = {}
@@ -44,7 +46,7 @@ def run(args):
loop.close()
-def benchmark(func):
+def benchmark(func: CALLABLE_T) -> CALLABLE_T:
"""Decorate to mark a benchmark."""
BENCHMARKS[func.__name__] = func
return func
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index f62228b28f5..40d767728d3 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -3,12 +3,13 @@ import asyncio
import logging.handlers
from timeit import default_timer as timer
from types import ModuleType
-from typing import Awaitable, Callable, Dict, List, Optional
+from typing import Awaitable, Callable, List, Optional
from homeassistant import config as conf_util, core, loader, requirements
from homeassistant.config import async_notify_setup_error
from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -20,7 +21,7 @@ DATA_DEPS_REQS = "deps_reqs_processed"
SLOW_SETUP_WARNING = 10
-def setup_component(hass: core.HomeAssistant, domain: str, config: Dict) -> bool:
+def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool:
"""Set up a component and all its dependencies."""
return asyncio.run_coroutine_threadsafe(
async_setup_component(hass, domain, config), hass.loop
@@ -28,7 +29,7 @@ def setup_component(hass: core.HomeAssistant, domain: str, config: Dict) -> bool
async def async_setup_component(
- hass: core.HomeAssistant, domain: str, config: Dict
+ hass: core.HomeAssistant, domain: str, config: ConfigType
) -> bool:
"""Set up a component and all its dependencies.
@@ -50,7 +51,7 @@ async def async_setup_component(
async def _async_process_dependencies(
- hass: core.HomeAssistant, config: Dict, name: str, dependencies: List[str]
+ hass: core.HomeAssistant, config: ConfigType, name: str, dependencies: List[str]
) -> bool:
"""Ensure all dependencies are set up."""
blacklisted = [dep for dep in dependencies if dep in loader.DEPENDENCY_BLACKLIST]
@@ -85,7 +86,7 @@ async def _async_process_dependencies(
async def _async_setup_component(
- hass: core.HomeAssistant, domain: str, config: Dict
+ hass: core.HomeAssistant, domain: str, config: ConfigType
) -> bool:
"""Set up a component for Home Assistant.
@@ -212,7 +213,7 @@ async def _async_setup_component(
async def async_prepare_setup_platform(
- hass: core.HomeAssistant, hass_config: Dict, domain: str, platform_name: str
+ hass: core.HomeAssistant, hass_config: ConfigType, domain: str, platform_name: str
) -> Optional[ModuleType]:
"""Load a platform and makes sure dependencies are setup.
@@ -267,7 +268,7 @@ async def async_prepare_setup_platform(
async def async_process_deps_reqs(
- hass: core.HomeAssistant, config: Dict, integration: loader.Integration
+ hass: core.HomeAssistant, config: ConfigType, integration: loader.Integration
) -> None:
"""Process all dependencies and requirements for a module.
diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py
index 4fdc40bde2f..70fc9ad4eaa 100644
--- a/homeassistant/util/distance.py
+++ b/homeassistant/util/distance.py
@@ -27,11 +27,10 @@ def convert(value: float, unit_1: str, unit_2: str) -> float:
if not isinstance(value, Number):
raise TypeError(f"{value} is not of numeric type")
- # type ignore: https://github.com/python/mypy/issues/7207
- if unit_1 == unit_2 or unit_1 not in VALID_UNITS: # type: ignore
+ if unit_1 == unit_2 or unit_1 not in VALID_UNITS:
return value
- meters = value
+ meters: float = value
if unit_1 == LENGTH_MILES:
meters = __miles_to_meters(value)
diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py
index cc028478e51..e4d376dc487 100644
--- a/homeassistant/util/network.py
+++ b/homeassistant/util/network.py
@@ -1,18 +1,41 @@
"""Network utilities."""
-from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
+from ipaddress import IPv4Address, IPv6Address, ip_network
from typing import Union
-# IP addresses of loopback interfaces
-LOCAL_IPS = (ip_address("127.0.0.1"), ip_address("::1"))
+# RFC6890 - IP addresses of loopback interfaces
+LOOPBACK_NETWORKS = (
+ ip_network("127.0.0.0/8"),
+ ip_network("::1/128"),
+ ip_network("::ffff:127.0.0.0/104"),
+)
-# RFC1918 - Address allocation for Private Internets
-LOCAL_NETWORKS = (
+# RFC6890 - Address allocation for Private Internets
+PRIVATE_NETWORKS = (
+ ip_network("fd00::/8"),
ip_network("10.0.0.0/8"),
ip_network("172.16.0.0/12"),
ip_network("192.168.0.0/16"),
)
+# RFC6890 - Link local ranges
+LINK_LOCAL_NETWORK = ip_network("169.254.0.0/16")
+
+
+def is_loopback(address: Union[IPv4Address, IPv6Address]) -> bool:
+ """Check if an address is a loopback address."""
+ return any(address in network for network in LOOPBACK_NETWORKS)
+
+
+def is_private(address: Union[IPv4Address, IPv6Address]) -> bool:
+ """Check if an address is a private address."""
+ return any(address in network for network in PRIVATE_NETWORKS)
+
+
+def is_link_local(address: Union[IPv4Address, IPv6Address]) -> bool:
+ """Check if an address is link local."""
+ return address in LINK_LOCAL_NETWORK
+
def is_local(address: Union[IPv4Address, IPv6Address]) -> bool:
- """Check if an address is local."""
- return address in LOCAL_IPS or any(address in network for network in LOCAL_NETWORKS)
+ """Check if an address is loopback or private."""
+ return is_loopback(address) or is_private(address)
diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py
index df791fd0235..046b65122a9 100644
--- a/homeassistant/util/pressure.py
+++ b/homeassistant/util/pressure.py
@@ -36,8 +36,7 @@ def convert(value: float, unit_1: str, unit_2: str) -> float:
if not isinstance(value, Number):
raise TypeError(f"{value} is not of numeric type")
- # type ignore: https://github.com/python/mypy/issues/7207
- if unit_1 == unit_2 or unit_1 not in VALID_UNITS: # type: ignore
+ if unit_1 == unit_2 or unit_1 not in VALID_UNITS:
return value
pascals = value / UNIT_CONVERSION[unit_1]
diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py
index 68a357d91f6..71d3ab2cc43 100644
--- a/homeassistant/util/ruamel_yaml.py
+++ b/homeassistant/util/ruamel_yaml.py
@@ -6,7 +6,7 @@ from os import O_CREAT, O_TRUNC, O_WRONLY, stat_result
from typing import Dict, List, Optional, Union
import ruamel.yaml
-from ruamel.yaml import YAML
+from ruamel.yaml import YAML # type: ignore
from ruamel.yaml.compat import StringIO
from ruamel.yaml.constructor import SafeConstructor
from ruamel.yaml.error import YAMLError
@@ -89,8 +89,7 @@ def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE:
"""Load a YAML file."""
if round_trip:
yaml = YAML(typ="rt")
- # type ignore: https://bitbucket.org/ruamel/yaml/pull-requests/42
- yaml.preserve_quotes = True # type: ignore
+ yaml.preserve_quotes = True
else:
if ExtSafeConstructor.name is None:
ExtSafeConstructor.name = fname
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index 5540936c8b4..8b276da432d 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -109,10 +109,7 @@ class UnitSystem:
if not isinstance(temperature, Number):
raise TypeError(f"{temperature!s} is not a numeric value.")
- # type ignore: https://github.com/python/mypy/issues/7207
- return temperature_util.convert( # type: ignore
- temperature, from_unit, self.temperature_unit
- )
+ return temperature_util.convert(temperature, from_unit, self.temperature_unit)
def length(self, length: Optional[float], from_unit: str) -> float:
"""Convert the given length to this unit system."""
diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py
index 2e033beb35c..30185e4346c 100644
--- a/homeassistant/util/volume.py
+++ b/homeassistant/util/volume.py
@@ -37,11 +37,10 @@ def convert(volume: float, from_unit: str, to_unit: str) -> float:
if not isinstance(volume, Number):
raise TypeError(f"{volume} is not of numeric type")
- # type ignore: https://github.com/python/mypy/issues/7207
- if from_unit == to_unit: # type: ignore
+ if from_unit == to_unit:
return volume
- result = volume
+ result: float = volume
if from_unit == VOLUME_LITERS and to_unit == VOLUME_GALLONS:
result = __liter_to_gallon(volume)
elif from_unit == VOLUME_GALLONS and to_unit == VOLUME_LITERS:
diff --git a/requirements_all.txt b/requirements_all.txt
index 8f27c68655f..15168678a85 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -7,13 +7,13 @@ bcrypt==3.1.7
certifi>=2019.11.28
ciso8601==2.1.3
importlib-metadata==1.5.0
-jinja2>=2.10.3
+jinja2>=2.11.1
PyJWT==1.7.1
cryptography==2.8
pip>=8.0.3
python-slugify==4.0.0
pytz>=2019.03
-pyyaml==5.3
+pyyaml==5.3.1
requests==2.23.0
ruamel.yaml==0.15.100
voluptuous==0.11.7
@@ -35,7 +35,7 @@ Adafruit-SHT31==1.0.2
# Adafruit_BBIO==1.1.1
# homeassistant.components.homekit
-HAP-python==2.7.0
+HAP-python==2.8.1
# homeassistant.components.mastodon
Mastodon.py==1.5.0
@@ -72,17 +72,18 @@ PyRMVtransport==0.2.9
PySocks==1.7.1
# homeassistant.components.switchbot
-# PySwitchbot==0.6.2
+# PySwitchbot==0.8.0
# homeassistant.components.transport_nsw
PyTransportNSW==0.1.1
# homeassistant.components.vicare
-PyViCare==0.1.7
+PyViCare==0.1.10
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.12.4
+# homeassistant.components.bmp280
# homeassistant.components.mcp23017
# homeassistant.components.rpi_gpio
# RPi.GPIO==0.7.0
@@ -111,6 +112,9 @@ abodepy==0.18.1
# homeassistant.components.mcp23017
adafruit-blinka==3.9.0
+# homeassistant.components.bmp280
+adafruit-circuitpython-bmp280==3.1.1
+
# homeassistant.components.mcp23017
adafruit-circuitpython-mcp230xx==2.2.2
@@ -118,7 +122,7 @@ adafruit-circuitpython-mcp230xx==2.2.2
adb-shell==0.1.1
# homeassistant.components.adguard
-adguardhome==0.4.1
+adguardhome==0.4.2
# homeassistant.components.frontier_silicon
afsapi==0.0.4
@@ -136,7 +140,7 @@ aio_geojson_nsw_rfs_incidents==0.3
aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
-aioambient==1.0.4
+aioambient==1.1.1
# homeassistant.components.asuswrt
aioasuswrt==1.2.3
@@ -148,6 +152,7 @@ aioautomatic==0.6.5
aiobotocore==0.11.1
# homeassistant.components.dnsip
+# homeassistant.components.minecraft_server
aiodns==2.0.0
# homeassistant.components.esphome
@@ -163,14 +168,14 @@ aioftp==0.12.0
aioharmony==0.1.13
# homeassistant.components.homekit_controller
-aiohomekit[IP]==0.2.29.2
+aiohomekit[IP]==0.2.37
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
# homeassistant.components.hue
-aiohue==2.0.0
+aiohue==2.1.0
# homeassistant.components.imap
aioimaplib==0.7.15
@@ -179,7 +184,7 @@ aioimaplib==0.7.15
aiokafka==0.5.1
# homeassistant.components.kef
-aiokef==0.2.7
+aiokef==0.2.9
# homeassistant.components.lifx
aiolifx==0.6.7
@@ -196,6 +201,9 @@ aionotion==1.1.0
# homeassistant.components.hunterdouglas_powerview
aiopvapi==1.6.14
+# homeassistant.components.pvpc_hourly_pricing
+aiopvpc==1.0.2
+
# homeassistant.components.webostv
aiopylgtv==0.3.3
@@ -224,7 +232,7 @@ alpha_vantage==2.1.3
ambiclimate==0.2.1
# homeassistant.components.amcrest
-amcrest==1.5.6
+amcrest==1.7.0
# homeassistant.components.androidtv
androidtv==0.0.39
@@ -242,7 +250,7 @@ apcaccess==0.0.13
apns2==0.3.0
# homeassistant.components.apprise
-apprise==0.8.4
+apprise==0.8.5
# homeassistant.components.aprs
aprslib==0.6.46
@@ -309,7 +317,7 @@ beautifulsoup4==4.8.2
beewi_smartclim==0.0.7
# homeassistant.components.zha
-bellows-homeassistant==0.14.0
+bellows-homeassistant==0.15.2
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.7.1
@@ -347,10 +355,10 @@ boto3==1.9.252
bravia-tv==1.0.1
# homeassistant.components.broadlink
-broadlink==0.12.0
+broadlink==0.13.0
# homeassistant.components.brother
-brother==0.1.8
+brother==0.1.11
# homeassistant.components.brottsplatskartan
brottsplatskartan==0.0.1
@@ -436,16 +444,16 @@ defusedxml==0.6.0
deluge-client==1.7.1
# homeassistant.components.denonavr
-denonavr==0.8.0
+denonavr==0.8.1
# homeassistant.components.directv
-directpy==0.7
+directv==0.2.0
# homeassistant.components.discogs
discogs_client==2.2.2
# homeassistant.components.discord
-discord.py==1.3.1
+discord.py==1.3.2
# homeassistant.components.updater
distro==1.4.0
@@ -487,7 +495,7 @@ elgato==0.2.0
eliqonline==1.2.2
# homeassistant.components.elkm1
-elkm1-lib==0.7.15
+elkm1-lib==0.7.17
# homeassistant.components.emulated_roku
emulated_roku==0.2.1
@@ -568,7 +576,7 @@ fritzconnection==1.2.0
gTTS-token==1.1.3
# homeassistant.components.garmin_connect
-garminconnect==0.1.8
+garminconnect==0.1.10
# homeassistant.components.gearbest
gearbest_parser==1.0.7
@@ -603,7 +611,7 @@ georss_qld_bushfire_alert_client==0.3
getmac==0.8.1
# homeassistant.components.gios
-gios==0.0.3
+gios==0.1.1
# homeassistant.components.gitter
gitterpy==0.1.7
@@ -690,13 +698,13 @@ hkavr==0.0.5
hlk-sw16==0.0.8
# homeassistant.components.pi_hole
-hole==0.5.0
+hole==0.5.1
# homeassistant.components.workday
holidays==0.10.1
# homeassistant.components.frontend
-home-assistant-frontend==20200318.1
+home-assistant-frontend==20200407.1
# homeassistant.components.zwave
homeassistant-pyozw==0.1.10
@@ -832,7 +840,7 @@ logi_circle==0.2.2
london-tube-status==0.2
# homeassistant.components.luftdaten
-luftdaten==0.6.3
+luftdaten==0.6.4
# homeassistant.components.lupusec
lupupy==0.0.18
@@ -882,9 +890,6 @@ minio==4.0.9
# homeassistant.components.mitemp_bt
mitemp_bt==0.0.3
-# homeassistant.components.mopar
-motorparts==1.1.0
-
# homeassistant.components.tts
mutagen==1.43.0
@@ -916,6 +921,12 @@ netdisco==2.6.0
# homeassistant.components.neurio_energy
neurio==0.3.1
+# homeassistant.components.nexia
+nexia==0.8.0
+
+# homeassistant.components.nextcloud
+nextcloudmonitor==1.1.0
+
# homeassistant.components.niko_home_control
niko-home-control==0.2.1
@@ -953,7 +964,7 @@ onkyo-eiscp==1.2.7
onvif-zeep-async==0.2.0
# homeassistant.components.opencv
-# opencv-python-headless==4.1.2.30
+# opencv-python-headless==4.2.0.32
# homeassistant.components.openevse
openevsewifi==0.4
@@ -1031,7 +1042,7 @@ plexapi==3.3.0
plexauth==0.0.5
# homeassistant.components.plex
-plexwebsocket==0.0.6
+plexwebsocket==0.0.7
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1065,7 +1076,7 @@ protobuf==3.6.1
proxmoxer==1.0.4
# homeassistant.components.systemmonitor
-psutil==5.6.7
+psutil==5.7.0
# homeassistant.components.ptvsd
ptvsd==4.2.8
@@ -1100,6 +1111,9 @@ py-cpuinfo==5.0.0
# homeassistant.components.melissa
py-melissa-climate==2.0.0
+# homeassistant.components.schluter
+py-schluter==0.1.7
+
# homeassistant.components.synology
py-synology==0.2.0
@@ -1123,10 +1137,10 @@ pyRFXtrx==0.25
# pySwitchmate==0.4.6
# homeassistant.components.tibber
-pyTibber==0.13.3
+pyTibber==0.13.6
# homeassistant.components.dlink
-pyW215==0.6.0
+pyW215==0.7.0
# homeassistant.components.w800rf32
pyW800rf32==0.1
@@ -1149,9 +1163,6 @@ pyaftership==0.1.2
# homeassistant.components.airvisual
pyairvisual==3.0.1
-# homeassistant.components.alarmdotcom
-pyalarmdotcom==0.3.2
-
# homeassistant.components.almond
pyalmond==0.0.2
@@ -1295,7 +1306,7 @@ pygogogate2==0.1.1
pygtfs==0.1.5
# homeassistant.components.version
-pyhaversion==3.2.0
+pyhaversion==3.3.0
# homeassistant.components.heos
pyheos==0.6.0
@@ -1316,14 +1327,17 @@ pyhomeworks==0.0.6
pyialarm==0.3
# homeassistant.components.icloud
-pyicloud==0.9.5
+pyicloud==0.9.6.1
# homeassistant.components.intesishome
-pyintesishome==1.6
+pyintesishome==1.7.1
# homeassistant.components.ipma
pyipma==2.0.5
+# homeassistant.components.ipp
+pyipp==0.9.0
+
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -1346,7 +1360,7 @@ pykwb==0.0.8
pylacrosse==0.4.0
# homeassistant.components.lastfm
-pylast==3.2.0
+pylast==3.2.1
# homeassistant.components.launch_library
pylaunches==0.2.0
@@ -1364,7 +1378,7 @@ pylitejet==0.1
pyloopenergy==0.1.3
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.5.1
+pylutron-caseta==0.6.0
# homeassistant.components.lutron
pylutron==0.2.5
@@ -1388,7 +1402,7 @@ pymitv==1.4.3
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==1.5.2
+pymodbus==2.3.0
# homeassistant.components.monoprice
pymonoprice==0.3
@@ -1433,7 +1447,7 @@ pynx584==0.4
pynzbgetapi==0.2.0
# homeassistant.components.obihai
-pyobihai==1.2.0
+pyobihai==1.2.1
# homeassistant.components.ombi
pyombi==0.1.10
@@ -1468,7 +1482,7 @@ pyownet==0.10.0.post1
pypca==0.0.7
# homeassistant.components.lcn
-pypck==0.6.3
+pypck==0.6.4
# homeassistant.components.pjlink
pypjlink2==1.2.0
@@ -1546,7 +1560,7 @@ pysnmp==4.4.12
pysoma==0.0.10
# homeassistant.components.sonos
-pysonos==0.0.24
+pysonos==0.0.25
# homeassistant.components.spc
pyspcwebgw==0.4.0
@@ -1585,7 +1599,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2
# homeassistant.components.ecobee
-python-ecobee-api==0.2.2
+python-ecobee-api==0.2.5
# homeassistant.components.eq3btsmart
# python-eq3bt==0.1.11
@@ -1654,7 +1668,7 @@ python-songpal==0.11.2
python-synology==0.4.0
# homeassistant.components.tado
-python-tado==0.3.0
+python-tado==0.6.0
# homeassistant.components.telegram_bot
python-telegram-bot==11.1.0
@@ -1706,7 +1720,7 @@ pytradfri[async]==6.4.0
pytrafikverket==0.1.6.1
# homeassistant.components.ubee
-pyubee==0.9
+pyubee==0.10
# homeassistant.components.uptimerobot
pyuptimerobot==0.0.5
@@ -1724,7 +1738,7 @@ pyversasense==0.0.6
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.35
+pyvizio==0.1.44
# homeassistant.components.velux
pyvlx==0.2.12
@@ -1793,7 +1807,7 @@ rjpl==0.3.5
rocketchat-API==0.6.1
# homeassistant.components.roku
-roku==4.0.0
+roku==4.1.0
# homeassistant.components.roomba
roombapy==1.4.3
@@ -1832,7 +1846,7 @@ schiene==0.23
scsgate==0.1.0
# homeassistant.components.sendgrid
-sendgrid==6.1.1
+sendgrid==6.1.3
# homeassistant.components.sensehat
sense-hat==2.2.0
@@ -1847,7 +1861,7 @@ sentry-sdk==0.13.5
sharp_aquos_rc==0.3.2
# homeassistant.components.shodan
-shodan==1.21.3
+shodan==1.22.0
# homeassistant.components.sighthound
simplehound==0.3
@@ -1856,7 +1870,7 @@ simplehound==0.3
simplepush==1.1.4
# homeassistant.components.simplisafe
-simplisafe-python==9.0.4
+simplisafe-python==9.0.6
# homeassistant.components.sisyphus
sisyphus-control==2.2.1
@@ -1865,7 +1879,7 @@ sisyphus-control==2.2.1
skybellpy==0.4.0
# homeassistant.components.slack
-slacker==0.13.0
+slackclient==2.5.0
# homeassistant.components.sleepiq
sleepyq==0.7
@@ -1888,7 +1902,7 @@ smarthab==0.20
# smbus-cffi==0.5.1
# homeassistant.components.smhi
-smhi-pkg==1.0.10
+smhi-pkg==1.0.13
# homeassistant.components.snapcast
snapcast==2.0.10
@@ -1924,11 +1938,11 @@ spiderpy==1.3.1
spotcrime==1.0.4
# homeassistant.components.spotify
-spotipy==2.7.1
+spotipy==2.10.0
# homeassistant.components.recorder
# homeassistant.components.sql
-sqlalchemy==1.3.13
+sqlalchemy==1.3.15
# homeassistant.components.starline
starline==0.1.3
@@ -1996,8 +2010,11 @@ temperusb==1.5.3
# homeassistant.components.tensorflow
# tensorflow==1.13.2
+# homeassistant.components.powerwall
+tesla-powerwall==0.1.3
+
# homeassistant.components.tesla
-teslajsonpy==0.5.1
+teslajsonpy==0.6.0
# homeassistant.components.thermoworks_smoke
thermoworks_smoke==0.1.8
@@ -2030,7 +2047,7 @@ transmissionrpc==0.11
tuyaha==0.0.5
# homeassistant.components.twentemilieu
-twentemilieu==0.2.0
+twentemilieu==0.3.0
# homeassistant.components.twilio
twilio==6.32.0
@@ -2106,7 +2123,7 @@ wirelesstagpy==0.4.0
withings-api==2.1.3
# homeassistant.components.wled
-wled==0.2.1
+wled==0.3.0
# homeassistant.components.wunderlist
wunderpy2==0.1.6
@@ -2144,22 +2161,22 @@ yahooweather==0.10
yalesmartalarmclient==0.1.6
# homeassistant.components.yeelight
-yeelight==0.5.0
+yeelight==0.5.1
# homeassistant.components.yeelightsunflower
yeelightsunflower==0.0.10
# homeassistant.components.media_extractor
-youtube_dl==2020.03.08
+youtube_dl==2020.03.24
# homeassistant.components.zengge
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.24.5
+zeroconf==0.25.0
# homeassistant.components.zha
-zha-quirks==0.0.37
+zha-quirks==0.0.38
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
@@ -2168,16 +2185,16 @@ zhong_hong_hvac==1.0.9
ziggo-mediabox-xl==1.1.0
# homeassistant.components.zha
-zigpy-cc==0.1.0
+zigpy-cc==0.3.1
# homeassistant.components.zha
-zigpy-deconz==0.7.0
+zigpy-deconz==0.8.0
# homeassistant.components.zha
-zigpy-homeassistant==0.16.0
+zigpy-homeassistant==0.18.1
# homeassistant.components.zha
-zigpy-xbee-homeassistant==0.10.0
+zigpy-xbee-homeassistant==0.11.0
# homeassistant.components.zha
zigpy-zigate==0.5.1
diff --git a/requirements_docs.txt b/requirements_docs.txt
index a27f3a4a306..17b38d6ebc3 100644
--- a/requirements_docs.txt
+++ b/requirements_docs.txt
@@ -1,3 +1,3 @@
-Sphinx==2.3.1
+Sphinx==2.4.4
sphinx-autodoc-typehints==1.10.3
sphinx-autodoc-annotation==1.0.post1
\ No newline at end of file
diff --git a/requirements_test.txt b/requirements_test.txt
index 6fc7e10a78d..8b4b5d0edcf 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -6,8 +6,8 @@
asynctest==0.13.0
codecov==2.0.15
mock-open==1.3.1
-mypy==0.761
-pre-commit==2.1.1
+mypy==0.770
+pre-commit==2.2.0
pylint==2.4.4
astroid==2.3.3
pylint-strict-informational==0.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index eaeca47589d..3403ad5a519 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.7.0
+HAP-python==2.8.1
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -32,7 +32,7 @@ abodepy==0.18.1
adb-shell==0.1.1
# homeassistant.components.adguard
-adguardhome==0.4.1
+adguardhome==0.4.2
# homeassistant.components.geonetnz_quakes
aio_geojson_geonetnz_quakes==0.12
@@ -47,7 +47,7 @@ aio_geojson_nsw_rfs_incidents==0.3
aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
-aioambient==1.0.4
+aioambient==1.1.1
# homeassistant.components.asuswrt
aioasuswrt==1.2.3
@@ -58,22 +58,35 @@ aioautomatic==0.6.5
# homeassistant.components.aws
aiobotocore==0.11.1
+# homeassistant.components.dnsip
+# homeassistant.components.minecraft_server
+aiodns==2.0.0
+
# homeassistant.components.esphome
aioesphomeapi==2.6.1
+# homeassistant.components.freebox
+aiofreepybox==0.0.8
+
+# homeassistant.components.harmony
+aioharmony==0.1.13
+
# homeassistant.components.homekit_controller
-aiohomekit[IP]==0.2.29.2
+aiohomekit[IP]==0.2.37
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
# homeassistant.components.hue
-aiohue==2.0.0
+aiohue==2.1.0
# homeassistant.components.notion
aionotion==1.1.0
+# homeassistant.components.pvpc_hourly_pricing
+aiopvpc==1.0.2
+
# homeassistant.components.webostv
aiopylgtv==0.3.3
@@ -99,7 +112,7 @@ androidtv==0.0.39
apns2==0.3.0
# homeassistant.components.apprise
-apprise==0.8.4
+apprise==0.8.5
# homeassistant.components.aprs
aprslib==0.6.46
@@ -118,16 +131,16 @@ av==6.1.2
axis==25
# homeassistant.components.zha
-bellows-homeassistant==0.14.0
+bellows-homeassistant==0.15.2
# homeassistant.components.bom
bomradarloop==0.1.4
# homeassistant.components.broadlink
-broadlink==0.12.0
+broadlink==0.13.0
# homeassistant.components.brother
-brother==0.1.8
+brother==0.1.11
# homeassistant.components.buienradar
buienradar==1.0.4
@@ -162,14 +175,17 @@ datadog==0.15.0
defusedxml==0.6.0
# homeassistant.components.denonavr
-denonavr==0.8.0
+denonavr==0.8.1
# homeassistant.components.directv
-directpy==0.7
+directv==0.2.0
# homeassistant.components.updater
distro==1.4.0
+# homeassistant.components.doorbird
+doorbirdpy==2.0.8
+
# homeassistant.components.dsmr
dsmr_parser==0.18
@@ -182,6 +198,9 @@ eebrightbox==0.0.4
# homeassistant.components.elgato
elgato==0.2.0
+# homeassistant.components.elkm1
+elkm1-lib==0.7.17
+
# homeassistant.components.emulated_roku
emulated_roku==0.2.1
@@ -198,7 +217,7 @@ foobot_async==0.3.1
gTTS-token==1.1.3
# homeassistant.components.garmin_connect
-garminconnect==0.1.8
+garminconnect==0.1.10
# homeassistant.components.geo_json_events
# homeassistant.components.usgs_earthquakes_feed
@@ -224,7 +243,7 @@ georss_qld_bushfire_alert_client==0.3
getmac==0.8.1
# homeassistant.components.gios
-gios==0.0.3
+gios==0.1.1
# homeassistant.components.glances
glances_api==0.2.0
@@ -257,13 +276,13 @@ hdate==0.9.5
herepy==2.0.0
# homeassistant.components.pi_hole
-hole==0.5.0
+hole==0.5.1
# homeassistant.components.workday
holidays==0.10.1
# homeassistant.components.frontend
-home-assistant-frontend==20200318.1
+home-assistant-frontend==20200407.1
# homeassistant.components.zwave
homeassistant-pyozw==0.1.10
@@ -310,7 +329,7 @@ libsoundtouch==0.7.2
logi_circle==0.2.2
# homeassistant.components.luftdaten
-luftdaten==0.6.3
+luftdaten==0.6.4
# homeassistant.components.mythicbeastsdns
mbddns==0.1.2
@@ -337,6 +356,9 @@ nessclient==0.9.15
# homeassistant.components.ssdp
netdisco==2.6.0
+# homeassistant.components.nexia
+nexia==0.8.0
+
# homeassistant.components.nsw_fuel_station
nsw-fuel-api-client==1.0.10
@@ -380,7 +402,7 @@ plexapi==3.3.0
plexauth==0.0.5
# homeassistant.components.plex
-plexwebsocket==0.0.6
+plexwebsocket==0.0.7
# homeassistant.components.mhz19
# homeassistant.components.serial_pm
@@ -482,7 +504,7 @@ pyfttt==0.3
pygatt[GATTTOOL]==4.0.5
# homeassistant.components.version
-pyhaversion==3.2.0
+pyhaversion==3.3.0
# homeassistant.components.heos
pyheos==0.6.0
@@ -491,11 +513,14 @@ pyheos==0.6.0
pyhomematic==0.1.65
# homeassistant.components.icloud
-pyicloud==0.9.5
+pyicloud==0.9.6.1
# homeassistant.components.ipma
pyipma==2.0.5
+# homeassistant.components.ipp
+pyipp==0.9.0
+
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -521,11 +546,17 @@ pymfy==0.7.1
pymochad==0.2.0
# homeassistant.components.modbus
-pymodbus==1.5.2
+pymodbus==2.3.0
# homeassistant.components.monoprice
pymonoprice==0.3
+# homeassistant.components.myq
+pymyq==2.0.1
+
+# homeassistant.components.nut
+pynut2==2.1.2
+
# homeassistant.components.nws
pynws==0.10.4
@@ -571,13 +602,13 @@ pysmartthings==0.7.0
pysoma==0.0.10
# homeassistant.components.sonos
-pysonos==0.0.24
+pysonos==0.0.25
# homeassistant.components.spc
pyspcwebgw==0.4.0
# homeassistant.components.ecobee
-python-ecobee-api==0.2.2
+python-ecobee-api==0.2.5
# homeassistant.components.darksky
python-forecastio==1.4.0
@@ -591,6 +622,9 @@ python-miio==0.4.8
# homeassistant.components.nest
python-nest==4.1.0
+# homeassistant.components.tado
+python-tado==0.6.0
+
# homeassistant.components.twitch
python-twitch-client==0.6.0
@@ -613,11 +647,14 @@ pyvera==0.3.7
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.35
+pyvizio==0.1.44
# homeassistant.components.html5
pywebpush==1.9.2
+# homeassistant.components.rachio
+rachiopy==0.1.3
+
# homeassistant.components.rainmachine
regenmaschine==1.5.1
@@ -630,6 +667,9 @@ rflink==0.0.52
# homeassistant.components.ring
ring_doorbell==0.6.0
+# homeassistant.components.roku
+roku==4.1.0
+
# homeassistant.components.yamaha
rxv==0.6.0
@@ -649,13 +689,13 @@ sentry-sdk==0.13.5
simplehound==0.3
# homeassistant.components.simplisafe
-simplisafe-python==9.0.4
+simplisafe-python==9.0.6
# homeassistant.components.sleepiq
sleepyq==0.7
# homeassistant.components.smhi
-smhi-pkg==1.0.10
+smhi-pkg==1.0.13
# homeassistant.components.solaredge
solaredge==0.0.2
@@ -667,11 +707,11 @@ somecomfort==0.5.2
speak2mary==1.4.0
# homeassistant.components.spotify
-spotipy==2.7.1
+spotipy==2.10.0
# homeassistant.components.recorder
# homeassistant.components.sql
-sqlalchemy==1.3.13
+sqlalchemy==1.3.15
# homeassistant.components.starline
starline==0.1.3
@@ -691,8 +731,11 @@ sunwatcher==0.2.1
# homeassistant.components.tellduslive
tellduslive==0.10.10
+# homeassistant.components.powerwall
+tesla-powerwall==0.1.3
+
# homeassistant.components.tesla
-teslajsonpy==0.5.1
+teslajsonpy==0.6.0
# homeassistant.components.toon
toonapilib==3.2.4
@@ -701,7 +744,7 @@ toonapilib==3.2.4
transmissionrpc==0.11
# homeassistant.components.twentemilieu
-twentemilieu==0.2.0
+twentemilieu==0.3.0
# homeassistant.components.twilio
twilio==6.32.0
@@ -735,7 +778,7 @@ watchdog==0.8.3
withings-api==2.1.3
# homeassistant.components.wled
-wled==0.2.1
+wled==0.3.0
# homeassistant.components.bluesound
# homeassistant.components.rest
@@ -752,22 +795,22 @@ ya_ma==0.3.8
yahooweather==0.10
# homeassistant.components.zeroconf
-zeroconf==0.24.5
+zeroconf==0.25.0
# homeassistant.components.zha
-zha-quirks==0.0.37
+zha-quirks==0.0.38
# homeassistant.components.zha
-zigpy-cc==0.1.0
+zigpy-cc==0.3.1
# homeassistant.components.zha
-zigpy-deconz==0.7.0
+zigpy-deconz==0.8.0
# homeassistant.components.zha
-zigpy-homeassistant==0.16.0
+zigpy-homeassistant==0.18.1
# homeassistant.components.zha
-zigpy-xbee-homeassistant==0.10.0
+zigpy-xbee-homeassistant==0.11.0
# homeassistant.components.zha
zigpy-zigate==0.5.1
diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish
index 84b7abcab8b..3afed0ca8d8 100644
--- a/rootfs/etc/services.d/home-assistant/finish
+++ b/rootfs/etc/services.d/home-assistant/finish
@@ -2,4 +2,6 @@
# ==============================================================================
# Take down the S6 supervision tree when Home Assistant fails
# ==============================================================================
+if { s6-test ${1} -ne 100 }
+
s6-svscanctl -t /var/run/s6/services
\ No newline at end of file
diff --git a/rootfs/etc/services.d/home-assistant/run b/rootfs/etc/services.d/home-assistant/run
index a153db56b61..750d00a91ec 100644
--- a/rootfs/etc/services.d/home-assistant/run
+++ b/rootfs/etc/services.d/home-assistant/run
@@ -4,4 +4,7 @@
# ==============================================================================
cd /config || bashio::exit.nok "Can't find config folder!"
+# Enable Jemalloc for Home Assistant Core
+export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
+
exec python3 -m homeassistant --config /config
diff --git a/setup.cfg b/setup.cfg
index f9e9852812c..7df396df528 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -65,13 +65,9 @@ warn_redundant_casts = true
warn_unused_configs = true
[mypy-homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*]
+strict = true
ignore_errors = false
-check_untyped_defs = true
-disallow_incomplete_defs = true
-disallow_untyped_calls = true
-disallow_untyped_defs = true
-no_implicit_optional = true
-strict_equality = true
-warn_return_any = true
warn_unreachable = true
-warn_unused_ignores = true
+# TODO: turn these off, address issues
+allow_any_generics = true
+implicit_reexport = true
diff --git a/setup.py b/setup.py
index 7794a177b1f..e0daacd98bf 100755
--- a/setup.py
+++ b/setup.py
@@ -40,14 +40,14 @@ REQUIRES = [
"certifi>=2019.11.28",
"ciso8601==2.1.3",
"importlib-metadata==1.5.0",
- "jinja2>=2.10.3",
+ "jinja2>=2.11.1",
"PyJWT==1.7.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==2.8",
"pip>=8.0.3",
"python-slugify==4.0.0",
"pytz>=2019.03",
- "pyyaml==5.3",
+ "pyyaml==5.3.1",
"requests==2.23.0",
"ruamel.yaml==0.15.100",
"voluptuous==0.11.7",
diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py
index 82c0c0dbdbd..edcd01d51e1 100644
--- a/tests/auth/test_init.py
+++ b/tests/auth/test_init.py
@@ -899,8 +899,8 @@ async def test_async_remove_user(hass):
assert events[0].data["user_id"] == user.id
-async def test_new_users_admin(mock_hass):
- """Test newly created users are admin."""
+async def test_new_users(mock_hass):
+ """Test newly created users."""
manager = await auth.auth_manager_from_config(
mock_hass,
[
@@ -911,7 +911,17 @@ async def test_new_users_admin(mock_hass):
"username": "test-user",
"password": "test-pass",
"name": "Test Name",
- }
+ },
+ {
+ "username": "test-user-2",
+ "password": "test-pass",
+ "name": "Test Name",
+ },
+ {
+ "username": "test-user-3",
+ "password": "test-pass",
+ "name": "Test Name",
+ },
],
}
],
@@ -920,7 +930,18 @@ async def test_new_users_admin(mock_hass):
ensure_auth_manager_loaded(manager)
user = await manager.async_create_user("Hello")
+ # first user in the system is owner and admin
+ assert user.is_owner
assert user.is_admin
+ assert user.groups == []
+
+ user = await manager.async_create_user("Hello 2")
+ assert not user.is_admin
+ assert user.groups == []
+
+ user = await manager.async_create_user("Hello 3", ["system-admin"])
+ assert user.is_admin
+ assert user.groups[0].id == "system-admin"
user_cred = await manager.async_get_or_create_user(
auth_models.Credentials(
diff --git a/tests/common.py b/tests/common.py
index 8fdcc9b8f86..9790a8a7131 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -14,6 +14,8 @@ import threading
from unittest.mock import MagicMock, Mock, patch
import uuid
+from aiohttp.test_utils import unused_port as get_test_instance_port # noqa
+
from homeassistant import auth, config_entries, core as ha, loader
from homeassistant.auth import (
auth_store,
@@ -37,7 +39,6 @@ from homeassistant.const import (
EVENT_PLATFORM_DISCOVERED,
EVENT_STATE_CHANGED,
EVENT_TIME_CHANGED,
- SERVER_PORT,
STATE_OFF,
STATE_ON,
)
@@ -59,7 +60,6 @@ import homeassistant.util.dt as date_util
from homeassistant.util.unit_system import METRIC_SYSTEM
import homeassistant.util.yaml.loader as yaml_loader
-_TEST_INSTANCE_PORT = SERVER_PORT
_LOGGER = logging.getLogger(__name__)
INSTANCES = []
CLIENT_ID = "https://example.com/app"
@@ -217,18 +217,6 @@ async def async_test_home_assistant(loop):
return hass
-def get_test_instance_port():
- """Return unused port for running test instance.
-
- The socket that holds the default port does not get released when we stop
- HA in a different test case. Until I have figured out what is going on,
- let's run each test on a different port.
- """
- global _TEST_INSTANCE_PORT
- _TEST_INSTANCE_PORT += 1
- return _TEST_INSTANCE_PORT
-
-
def async_mock_service(hass, domain, service, schema=None):
"""Set up a fake service & return a calls log list to this service."""
calls = []
diff --git a/tests/components/abode/common.py b/tests/components/abode/common.py
new file mode 100644
index 00000000000..aabc732daa2
--- /dev/null
+++ b/tests/components/abode/common.py
@@ -0,0 +1,25 @@
+"""Common methods used across tests for Abode."""
+from unittest.mock import patch
+
+from homeassistant.components.abode import DOMAIN as ABODE_DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+async def setup_platform(hass, platform):
+ """Set up the Abode platform."""
+ mock_entry = MockConfigEntry(
+ domain=ABODE_DOMAIN,
+ data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"},
+ )
+ mock_entry.add_to_hass(hass)
+
+ with patch("homeassistant.components.abode.ABODE_PLATFORMS", [platform]), patch(
+ "abodepy.event_controller.sio"
+ ), patch("abodepy.utils.save_cache"):
+ assert await async_setup_component(hass, ABODE_DOMAIN, {})
+ await hass.async_block_till_done()
+
+ return mock_entry
diff --git a/tests/components/abode/conftest.py b/tests/components/abode/conftest.py
new file mode 100644
index 00000000000..681f65ddf93
--- /dev/null
+++ b/tests/components/abode/conftest.py
@@ -0,0 +1,24 @@
+"""Configuration for Abode tests."""
+import abodepy.helpers.constants as CONST
+import pytest
+
+from tests.common import load_fixture
+
+
+@pytest.fixture(autouse=True)
+def requests_mock_fixture(requests_mock):
+ """Fixture to provide a requests mocker."""
+ # Mocks the login response for abodepy.
+ requests_mock.post(CONST.LOGIN_URL, text=load_fixture("abode_login.json"))
+ # Mocks the logout response for abodepy.
+ requests_mock.post(CONST.LOGOUT_URL, text=load_fixture("abode_logout.json"))
+ # Mocks the oauth claims response for abodepy.
+ requests_mock.get(
+ CONST.OAUTH_TOKEN_URL, text=load_fixture("abode_oauth_claims.json")
+ )
+ # Mocks the panel response for abodepy.
+ requests_mock.get(CONST.PANEL_URL, text=load_fixture("abode_panel.json"))
+ # Mocks the automations response for abodepy.
+ requests_mock.get(CONST.AUTOMATION_URL, text=load_fixture("abode_automation.json"))
+ # Mocks the devices response for abodepy.
+ requests_mock.get(CONST.DEVICES_URL, text=load_fixture("abode_devices.json"))
diff --git a/tests/components/abode/test_alarm_control_panel.py b/tests/components/abode/test_alarm_control_panel.py
new file mode 100644
index 00000000000..ca546157c93
--- /dev/null
+++ b/tests/components/abode/test_alarm_control_panel.py
@@ -0,0 +1,140 @@
+"""Tests for the Abode alarm control panel device."""
+from unittest.mock import PropertyMock, patch
+
+import abodepy.helpers.constants as CONST
+
+from homeassistant.components.abode import ATTR_DEVICE_ID
+from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
+ ATTR_SUPPORTED_FEATURES,
+ SERVICE_ALARM_ARM_AWAY,
+ SERVICE_ALARM_ARM_HOME,
+ SERVICE_ALARM_DISARM,
+ STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_DISARMED,
+)
+
+from .common import setup_platform
+
+DEVICE_ID = "alarm_control_panel.abode_alarm"
+
+
+async def test_entity_registry(hass):
+ """Tests that the devices are registered in the entity registry."""
+ await setup_platform(hass, ALARM_DOMAIN)
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = entity_registry.async_get(DEVICE_ID)
+ # Abode alarm device unique_id is the MAC address
+ assert entry.unique_id == "001122334455"
+
+
+async def test_attributes(hass):
+ """Test the alarm control panel attributes are correct."""
+ await setup_platform(hass, ALARM_DOMAIN)
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_ALARM_DISARMED
+ assert state.attributes.get(ATTR_DEVICE_ID) == "area_1"
+ assert not state.attributes.get("battery_backup")
+ assert not state.attributes.get("cellular_backup")
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Abode Alarm"
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3
+
+
+async def test_set_alarm_away(hass):
+ """Test the alarm control panel can be set to away."""
+ with patch("abodepy.AbodeEventController.add_device_callback") as mock_callback:
+ with patch("abodepy.ALARM.AbodeAlarm.set_away") as mock_set_away:
+ await setup_platform(hass, ALARM_DOMAIN)
+
+ await hass.services.async_call(
+ ALARM_DOMAIN,
+ SERVICE_ALARM_ARM_AWAY,
+ {ATTR_ENTITY_ID: DEVICE_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_set_away.assert_called_once()
+
+ with patch(
+ "abodepy.ALARM.AbodeAlarm.mode", new_callable=PropertyMock,
+ ) as mock_mode:
+ mock_mode.return_value = CONST.MODE_AWAY
+
+ update_callback = mock_callback.call_args[0][1]
+ await hass.async_add_executor_job(update_callback, "area_1")
+ await hass.async_block_till_done()
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_ALARM_ARMED_AWAY
+
+
+async def test_set_alarm_home(hass):
+ """Test the alarm control panel can be set to home."""
+ with patch("abodepy.AbodeEventController.add_device_callback") as mock_callback:
+ with patch("abodepy.ALARM.AbodeAlarm.set_home") as mock_set_home:
+ await setup_platform(hass, ALARM_DOMAIN)
+
+ await hass.services.async_call(
+ ALARM_DOMAIN,
+ SERVICE_ALARM_ARM_HOME,
+ {ATTR_ENTITY_ID: DEVICE_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_set_home.assert_called_once()
+
+ with patch(
+ "abodepy.ALARM.AbodeAlarm.mode", new_callable=PropertyMock
+ ) as mock_mode:
+ mock_mode.return_value = CONST.MODE_HOME
+
+ update_callback = mock_callback.call_args[0][1]
+ await hass.async_add_executor_job(update_callback, "area_1")
+ await hass.async_block_till_done()
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_ALARM_ARMED_HOME
+
+
+async def test_set_alarm_standby(hass):
+ """Test the alarm control panel can be set to standby."""
+ with patch("abodepy.AbodeEventController.add_device_callback") as mock_callback:
+ with patch("abodepy.ALARM.AbodeAlarm.set_standby") as mock_set_standby:
+ await setup_platform(hass, ALARM_DOMAIN)
+ await hass.services.async_call(
+ ALARM_DOMAIN,
+ SERVICE_ALARM_DISARM,
+ {ATTR_ENTITY_ID: DEVICE_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_set_standby.assert_called_once()
+
+ with patch(
+ "abodepy.ALARM.AbodeAlarm.mode", new_callable=PropertyMock
+ ) as mock_mode:
+ mock_mode.return_value = CONST.MODE_STANDBY
+
+ update_callback = mock_callback.call_args[0][1]
+ await hass.async_add_executor_job(update_callback, "area_1")
+ await hass.async_block_till_done()
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_ALARM_DISARMED
+
+
+async def test_state_unknown(hass):
+ """Test an unknown alarm control panel state."""
+ with patch("abodepy.ALARM.AbodeAlarm.mode", new_callable=PropertyMock) as mock_mode:
+ await setup_platform(hass, ALARM_DOMAIN)
+ await hass.async_block_till_done()
+
+ mock_mode.return_value = None
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == "unknown"
diff --git a/tests/components/abode/test_binary_sensor.py b/tests/components/abode/test_binary_sensor.py
new file mode 100644
index 00000000000..a826191ccf3
--- /dev/null
+++ b/tests/components/abode/test_binary_sensor.py
@@ -0,0 +1,39 @@
+"""Tests for the Abode binary sensor device."""
+from homeassistant.components.abode import ATTR_DEVICE_ID
+from homeassistant.components.abode.const import ATTRIBUTION
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_WINDOW,
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ ATTR_DEVICE_CLASS,
+ ATTR_FRIENDLY_NAME,
+ STATE_OFF,
+)
+
+from .common import setup_platform
+
+
+async def test_entity_registry(hass):
+ """Tests that the devices are registered in the entity registry."""
+ await setup_platform(hass, BINARY_SENSOR_DOMAIN)
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = entity_registry.async_get("binary_sensor.front_door")
+ assert entry.unique_id == "2834013428b6035fba7d4054aa7b25a3"
+
+
+async def test_attributes(hass):
+ """Test the binary sensor attributes are correct."""
+ await setup_platform(hass, BINARY_SENSOR_DOMAIN)
+
+ state = hass.states.get("binary_sensor.front_door")
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_DEVICE_ID) == "RF:01430030"
+ assert not state.attributes.get("battery_low")
+ assert not state.attributes.get("no_response")
+ assert state.attributes.get("device_type") == "Door Contact"
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Front Door"
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_WINDOW
diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py
new file mode 100644
index 00000000000..8b11671a456
--- /dev/null
+++ b/tests/components/abode/test_camera.py
@@ -0,0 +1,40 @@
+"""Tests for the Abode camera device."""
+from unittest.mock import patch
+
+from homeassistant.components.abode.const import DOMAIN as ABODE_DOMAIN
+from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
+from homeassistant.const import ATTR_ENTITY_ID, STATE_IDLE
+
+from .common import setup_platform
+
+
+async def test_entity_registry(hass):
+ """Tests that the devices are registered in the entity registry."""
+ await setup_platform(hass, CAMERA_DOMAIN)
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = entity_registry.async_get("camera.test_cam")
+ assert entry.unique_id == "d0a3a1c316891ceb00c20118aae2a133"
+
+
+async def test_attributes(hass):
+ """Test the camera attributes are correct."""
+ await setup_platform(hass, CAMERA_DOMAIN)
+
+ state = hass.states.get("camera.test_cam")
+ assert state.state == STATE_IDLE
+
+
+async def test_capture_image(hass):
+ """Test the camera capture image service."""
+ await setup_platform(hass, CAMERA_DOMAIN)
+
+ with patch("abodepy.AbodeCamera.capture") as mock_capture:
+ await hass.services.async_call(
+ ABODE_DOMAIN,
+ "capture_image",
+ {ATTR_ENTITY_ID: "camera.test_cam"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_capture.assert_called_once()
diff --git a/tests/components/abode/test_cover.py b/tests/components/abode/test_cover.py
new file mode 100644
index 00000000000..bb1b8fceffb
--- /dev/null
+++ b/tests/components/abode/test_cover.py
@@ -0,0 +1,65 @@
+"""Tests for the Abode cover device."""
+from unittest.mock import patch
+
+from homeassistant.components.abode import ATTR_DEVICE_ID
+from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
+ SERVICE_CLOSE_COVER,
+ SERVICE_OPEN_COVER,
+ STATE_CLOSED,
+)
+
+from .common import setup_platform
+
+DEVICE_ID = "cover.garage_door"
+
+
+async def test_entity_registry(hass):
+ """Tests that the devices are registered in the entity registry."""
+ await setup_platform(hass, COVER_DOMAIN)
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = entity_registry.async_get(DEVICE_ID)
+ assert entry.unique_id == "61cbz3b542d2o33ed2fz02721bda3324"
+
+
+async def test_attributes(hass):
+ """Test the cover attributes are correct."""
+ await setup_platform(hass, COVER_DOMAIN)
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_CLOSED
+ assert state.attributes.get(ATTR_DEVICE_ID) == "ZW:00000007"
+ assert not state.attributes.get("battery_low")
+ assert not state.attributes.get("no_response")
+ assert state.attributes.get("device_type") == "Secure Barrier"
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Garage Door"
+
+
+async def test_open(hass):
+ """Test the cover can be opened."""
+ await setup_platform(hass, COVER_DOMAIN)
+
+ with patch("abodepy.AbodeCover.open_cover") as mock_open:
+ await hass.services.async_call(
+ COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_open.assert_called_once()
+
+
+async def test_close(hass):
+ """Test the cover can be closed."""
+ await setup_platform(hass, COVER_DOMAIN)
+
+ with patch("abodepy.AbodeCover.close_cover") as mock_close:
+ await hass.services.async_call(
+ COVER_DOMAIN,
+ SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: DEVICE_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_close.assert_called_once()
diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py
new file mode 100644
index 00000000000..3f73ccd77ce
--- /dev/null
+++ b/tests/components/abode/test_init.py
@@ -0,0 +1,43 @@
+"""Tests for the Abode module."""
+from unittest.mock import patch
+
+from homeassistant.components.abode import (
+ DOMAIN as ABODE_DOMAIN,
+ SERVICE_CAPTURE_IMAGE,
+ SERVICE_SETTINGS,
+ SERVICE_TRIGGER_AUTOMATION,
+)
+from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
+
+from .common import setup_platform
+
+
+async def test_change_settings(hass):
+ """Test change_setting service."""
+ await setup_platform(hass, ALARM_DOMAIN)
+
+ with patch("abodepy.Abode.set_setting") as mock_set_setting:
+ await hass.services.async_call(
+ ABODE_DOMAIN,
+ SERVICE_SETTINGS,
+ {"setting": "confirm_snd", "value": "loud"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_set_setting.assert_called_once()
+
+
+async def test_unload_entry(hass):
+ """Test unloading the Abode entry."""
+ mock_entry = await setup_platform(hass, ALARM_DOMAIN)
+
+ with patch("abodepy.Abode.logout") as mock_logout, patch(
+ "abodepy.event_controller.AbodeEventController.stop"
+ ) as mock_events_stop:
+ assert await hass.config_entries.async_unload(mock_entry.entry_id)
+ mock_logout.assert_called_once()
+ mock_events_stop.assert_called_once()
+
+ assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_SETTINGS)
+ assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_CAPTURE_IMAGE)
+ assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_TRIGGER_AUTOMATION)
diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py
new file mode 100644
index 00000000000..f0eee4b209b
--- /dev/null
+++ b/tests/components/abode/test_light.py
@@ -0,0 +1,119 @@
+"""Tests for the Abode light device."""
+from unittest.mock import patch
+
+from homeassistant.components.abode import ATTR_DEVICE_ID
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP,
+ ATTR_RGB_COLOR,
+ DOMAIN as LIGHT_DOMAIN,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
+ ATTR_SUPPORTED_FEATURES,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_ON,
+)
+
+from .common import setup_platform
+
+DEVICE_ID = "light.living_room_lamp"
+
+
+async def test_entity_registry(hass):
+ """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()
+
+ entry = entity_registry.async_get(DEVICE_ID)
+ assert entry.unique_id == "741385f4388b2637df4c6b398fe50581"
+
+
+async def test_attributes(hass):
+ """Test the light attributes are correct."""
+ await setup_platform(hass, LIGHT_DOMAIN)
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BRIGHTNESS) == 204
+ assert state.attributes.get(ATTR_RGB_COLOR) == (0, 63, 255)
+ assert state.attributes.get(ATTR_COLOR_TEMP) == 280
+ assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a"
+ assert not state.attributes.get("battery_low")
+ assert not state.attributes.get("no_response")
+ assert state.attributes.get("device_type") == "RGB Dimmer"
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Living Room Lamp"
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 19
+
+
+async def test_switch_off(hass):
+ """Test the light can be turned off."""
+ await setup_platform(hass, LIGHT_DOMAIN)
+
+ with patch("abodepy.AbodeLight.switch_off") as mock_switch_off:
+ assert await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_switch_off.assert_called_once()
+
+
+async def test_switch_on(hass):
+ """Test the light can be turned on."""
+ await setup_platform(hass, LIGHT_DOMAIN)
+
+ with patch("abodepy.AbodeLight.switch_on") as mock_switch_on:
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_switch_on.assert_called_once()
+
+
+async def test_set_brightness(hass):
+ """Test the brightness can be set."""
+ await setup_platform(hass, LIGHT_DOMAIN)
+
+ with patch("abodepy.AbodeLight.set_level") as mock_set_level:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DEVICE_ID, "brightness": 100},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ # Brightness is converted in abode.light.AbodeLight.turn_on
+ mock_set_level.assert_called_once_with(39)
+
+
+async def test_set_color(hass):
+ """Test the color can be set."""
+ await setup_platform(hass, LIGHT_DOMAIN)
+
+ with patch("abodepy.AbodeLight.set_color") as mock_set_color:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DEVICE_ID, "hs_color": [240, 100]},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ mock_set_color.assert_called_once_with((240.0, 100.0))
+
+
+async def test_set_color_temp(hass):
+ """Test the color temp can be set."""
+ await setup_platform(hass, LIGHT_DOMAIN)
+
+ with patch("abodepy.AbodeLight.set_color_temp") as mock_set_color_temp:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DEVICE_ID, "color_temp": 309},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ # Color temp is converted in abode.light.AbodeLight.turn_on
+ mock_set_color_temp.assert_called_once_with(3236)
diff --git a/tests/components/abode/test_lock.py b/tests/components/abode/test_lock.py
new file mode 100644
index 00000000000..45e17861d33
--- /dev/null
+++ b/tests/components/abode/test_lock.py
@@ -0,0 +1,62 @@
+"""Tests for the Abode lock device."""
+from unittest.mock import patch
+
+from homeassistant.components.abode import ATTR_DEVICE_ID
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_LOCKED,
+)
+
+from .common import setup_platform
+
+DEVICE_ID = "lock.test_lock"
+
+
+async def test_entity_registry(hass):
+ """Tests that the devices are registered in the entity registry."""
+ await setup_platform(hass, LOCK_DOMAIN)
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = entity_registry.async_get(DEVICE_ID)
+ assert entry.unique_id == "51cab3b545d2o34ed7fz02731bda5324"
+
+
+async def test_attributes(hass):
+ """Test the lock attributes are correct."""
+ await setup_platform(hass, LOCK_DOMAIN)
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_LOCKED
+ assert state.attributes.get(ATTR_DEVICE_ID) == "ZW:00000004"
+ assert not state.attributes.get("battery_low")
+ assert not state.attributes.get("no_response")
+ assert state.attributes.get("device_type") == "Door Lock"
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Test Lock"
+
+
+async def test_lock(hass):
+ """Test the lock can be locked."""
+ await setup_platform(hass, LOCK_DOMAIN)
+
+ with patch("abodepy.AbodeLock.lock") as mock_lock:
+ await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_lock.assert_called_once()
+
+
+async def test_unlock(hass):
+ """Test the lock can be unlocked."""
+ await setup_platform(hass, LOCK_DOMAIN)
+
+ with patch("abodepy.AbodeLock.unlock") as mock_unlock:
+ await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+ mock_unlock.assert_called_once()
diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py
new file mode 100644
index 00000000000..bfe20be0b8c
--- /dev/null
+++ b/tests/components/abode/test_sensor.py
@@ -0,0 +1,44 @@
+"""Tests for the Abode sensor device."""
+from homeassistant.components.abode import ATTR_DEVICE_ID
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
+ ATTR_FRIENDLY_NAME,
+ ATTR_UNIT_OF_MEASUREMENT,
+ DEVICE_CLASS_HUMIDITY,
+)
+
+from .common import setup_platform
+
+
+async def test_entity_registry(hass):
+ """Tests that the devices are registered in the entity registry."""
+ await setup_platform(hass, SENSOR_DOMAIN)
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = entity_registry.async_get("sensor.environment_sensor_humidity")
+ assert entry.unique_id == "13545b21f4bdcd33d9abd461f8443e65-humidity"
+
+
+async def test_attributes(hass):
+ """Test the sensor attributes are correct."""
+ await setup_platform(hass, SENSOR_DOMAIN)
+
+ state = hass.states.get("sensor.environment_sensor_humidity")
+ assert state.state == "32.0"
+ assert state.attributes.get(ATTR_DEVICE_ID) == "RF:02148e70"
+ assert not state.attributes.get("battery_low")
+ assert not state.attributes.get("no_response")
+ assert state.attributes.get("device_type") == "LM"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "%"
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Environment Sensor Humidity"
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY
+
+ state = hass.states.get("sensor.environment_sensor_lux")
+ assert state.state == "1.0"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "lux"
+
+ state = hass.states.get("sensor.environment_sensor_temperature")
+ # Abodepy device JSON reports 19.5, but Home Assistant shows 19.4
+ assert state.state == "19.4"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C"
diff --git a/tests/components/abode/test_switch.py b/tests/components/abode/test_switch.py
new file mode 100644
index 00000000000..3ec9648d87d
--- /dev/null
+++ b/tests/components/abode/test_switch.py
@@ -0,0 +1,125 @@
+"""Tests for the Abode switch device."""
+from unittest.mock import patch
+
+from homeassistant.components.abode import (
+ DOMAIN as ABODE_DOMAIN,
+ SERVICE_TRIGGER_AUTOMATION,
+)
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_OFF,
+ STATE_ON,
+)
+
+from .common import setup_platform
+
+AUTOMATION_ID = "switch.test_automation"
+AUTOMATION_UID = "47fae27488f74f55b964a81a066c3a01"
+DEVICE_ID = "switch.test_switch"
+DEVICE_UID = "0012a4d3614cb7e2b8c9abea31d2fb2a"
+
+
+async def test_entity_registry(hass):
+ """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()
+
+ entry = entity_registry.async_get(AUTOMATION_ID)
+ assert entry.unique_id == AUTOMATION_UID
+
+ entry = entity_registry.async_get(DEVICE_ID)
+ assert entry.unique_id == DEVICE_UID
+
+
+async def test_attributes(hass):
+ """Test the switch attributes are correct."""
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ state = hass.states.get(DEVICE_ID)
+ assert state.state == STATE_OFF
+
+
+async def test_switch_on(hass):
+ """Test the switch can be turned on."""
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ with patch("abodepy.AbodeSwitch.switch_on") as mock_switch_on:
+ assert await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+
+ mock_switch_on.assert_called_once()
+
+
+async def test_switch_off(hass):
+ """Test the switch can be turned off."""
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ with patch("abodepy.AbodeSwitch.switch_off") as mock_switch_off:
+ assert await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True
+ )
+ await hass.async_block_till_done()
+
+ mock_switch_off.assert_called_once()
+
+
+async def test_automation_attributes(hass):
+ """Test the automation attributes are correct."""
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ state = hass.states.get(AUTOMATION_ID)
+ # State is set based on "enabled" key in automation JSON.
+ assert state.state == STATE_ON
+
+
+async def test_turn_automation_off(hass):
+ """Test the automation can be turned off."""
+ with patch("abodepy.AbodeAutomation.enable") as mock_trigger:
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: AUTOMATION_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ mock_trigger.assert_called_once_with(False)
+
+
+async def test_turn_automation_on(hass):
+ """Test the automation can be turned on."""
+ with patch("abodepy.AbodeAutomation.enable") as mock_trigger:
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: AUTOMATION_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ mock_trigger.assert_called_once_with(True)
+
+
+async def test_trigger_automation(hass, requests_mock):
+ """Test the trigger automation service."""
+ await setup_platform(hass, SWITCH_DOMAIN)
+
+ with patch("abodepy.AbodeAutomation.trigger") as mock:
+ await hass.services.async_call(
+ ABODE_DOMAIN,
+ SERVICE_TRIGGER_AUTOMATION,
+ {ATTR_ENTITY_ID: AUTOMATION_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ mock.assert_called_once()
diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py
index fb32a86a01a..d21aec14fa0 100644
--- a/tests/components/airvisual/test_config_flow.py
+++ b/tests/components/airvisual/test_config_flow.py
@@ -11,15 +11,22 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_SHOW_ON_MAP,
)
+from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
- conf = {CONF_API_KEY: "abcde12345"}
+ conf = {
+ CONF_API_KEY: "abcde12345",
+ CONF_LATITUDE: 51.528308,
+ CONF_LONGITUDE: -0.3817765,
+ }
- MockConfigEntry(domain=DOMAIN, unique_id="abcde12345", data=conf).add_to_hass(hass)
+ MockConfigEntry(
+ domain=DOMAIN, unique_id="51.528308, -0.3817765", data=conf
+ ).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
@@ -31,7 +38,11 @@ async def test_duplicate_error(hass):
async def test_invalid_api_key(hass):
"""Test that invalid credentials throws an error."""
- conf = {CONF_API_KEY: "abcde12345"}
+ conf = {
+ CONF_API_KEY: "abcde12345",
+ CONF_LATITUDE: 51.528308,
+ CONF_LONGITUDE: -0.3817765,
+ }
with patch(
"pyairvisual.api.API.nearest_city", side_effect=InvalidKeyError,
@@ -42,6 +53,47 @@ async def test_invalid_api_key(hass):
assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
+async def test_migration_1_2(hass):
+ """Test migrating from version 1 to version 2."""
+ conf = {
+ CONF_API_KEY: "abcde12345",
+ CONF_GEOGRAPHIES: [
+ {CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765},
+ {CONF_LATITUDE: 35.48847, CONF_LONGITUDE: 137.5263065},
+ ],
+ }
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, version=1, unique_id="abcde12345", data=conf
+ )
+ config_entry.add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ with patch("pyairvisual.api.API.nearest_city"):
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: conf})
+
+ config_entries = hass.config_entries.async_entries(DOMAIN)
+
+ assert len(config_entries) == 2
+
+ assert config_entries[0].unique_id == "51.528308, -0.3817765"
+ assert config_entries[0].title == "Cloud API (51.528308, -0.3817765)"
+ assert config_entries[0].data == {
+ CONF_API_KEY: "abcde12345",
+ CONF_LATITUDE: 51.528308,
+ CONF_LONGITUDE: -0.3817765,
+ }
+
+ assert config_entries[1].unique_id == "35.48847, 137.5263065"
+ assert config_entries[1].title == "Cloud API (35.48847, 137.5263065)"
+ assert config_entries[1].data == {
+ CONF_API_KEY: "abcde12345",
+ CONF_LATITUDE: 35.48847,
+ CONF_LONGITUDE: 137.5263065,
+ }
+
+
async def test_options_flow(hass):
"""Test config flow options."""
conf = {CONF_API_KEY: "abcde12345"}
@@ -84,7 +136,8 @@ async def test_step_import(hass):
"""Test that the import step works."""
conf = {
CONF_API_KEY: "abcde12345",
- CONF_GEOGRAPHIES: [{CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}],
+ CONF_LATITUDE: 51.528308,
+ CONF_LONGITUDE: -0.3817765,
}
with patch(
@@ -95,10 +148,11 @@ async def test_step_import(hass):
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == "Cloud API (API key: abcd...)"
+ assert result["title"] == "Cloud API (51.528308, -0.3817765)"
assert result["data"] == {
CONF_API_KEY: "abcde12345",
- CONF_GEOGRAPHIES: [{CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}],
+ CONF_LATITUDE: 51.528308,
+ CONF_LONGITUDE: -0.3817765,
}
@@ -117,8 +171,9 @@ async def test_step_user(hass):
DOMAIN, context={"source": SOURCE_USER}, data=conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == "Cloud API (API key: abcd...)"
+ assert result["title"] == "Cloud API (32.87336, -117.22743)"
assert result["data"] == {
CONF_API_KEY: "abcde12345",
- CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}],
+ CONF_LATITUDE: 32.87336,
+ CONF_LONGITUDE: -117.22743,
}
diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py
new file mode 100644
index 00000000000..fcb2ba5a09b
--- /dev/null
+++ b/tests/components/alarm_control_panel/test_device_condition.py
@@ -0,0 +1,334 @@
+"""The tests for Alarm control panel device conditions."""
+import pytest
+
+from homeassistant.components.alarm_control_panel import DOMAIN
+import homeassistant.components.automation as automation
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_CUSTOM_BYPASS,
+ STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED,
+)
+from homeassistant.helpers import device_registry
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry,
+ assert_lists_same,
+ async_get_device_automations,
+ async_mock_service,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@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_no_conditions(hass, device_reg, entity_reg):
+ """Test we get the expected conditions from a alarm_control_panel."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ conditions = await async_get_device_automations(hass, "condition", device_entry.id)
+ assert_lists_same(conditions, [])
+
+
+async def test_get_minimum_conditions(hass, device_reg, entity_reg):
+ """Test we get the expected conditions from a alarm_control_panel."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ hass.states.async_set(
+ "alarm_control_panel.test_5678", "attributes", {"supported_features": 0}
+ )
+ expected_conditions = [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_disarmed",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_triggered",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ ]
+
+ conditions = await async_get_device_automations(hass, "condition", device_entry.id)
+ assert_lists_same(conditions, expected_conditions)
+
+
+async def test_get_maximum_conditions(hass, device_reg, entity_reg):
+ """Test we get the expected conditions from a alarm_control_panel."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ hass.states.async_set(
+ "alarm_control_panel.test_5678", "attributes", {"supported_features": 31}
+ )
+ expected_conditions = [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_disarmed",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_triggered",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_armed_home",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_armed_away",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_armed_night",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_armed_custom_bypass",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ ]
+
+ conditions = await async_get_device_automations(hass, "condition", device_entry.id)
+ assert_lists_same(conditions, expected_conditions)
+
+
+async def test_if_state(hass, calls):
+ """Test for all conditions."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": "event", "event_type": "test_event1"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": "alarm_control_panel.entity",
+ "type": "is_triggered",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_triggered - {{ trigger.platform }} - {{ trigger.event.event_type }}"
+ },
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event2"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": "alarm_control_panel.entity",
+ "type": "is_disarmed",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_disarmed - {{ trigger.platform }} - {{ trigger.event.event_type }}"
+ },
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event3"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": "alarm_control_panel.entity",
+ "type": "is_armed_home",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_armed_home - {{ trigger.platform }} - {{ trigger.event.event_type }}"
+ },
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event4"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": "alarm_control_panel.entity",
+ "type": "is_armed_away",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_armed_away - {{ trigger.platform }} - {{ trigger.event.event_type }}"
+ },
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event5"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": "alarm_control_panel.entity",
+ "type": "is_armed_night",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_armed_night - {{ trigger.platform }} - {{ trigger.event.event_type }}"
+ },
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event6"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": "alarm_control_panel.entity",
+ "type": "is_armed_custom_bypass",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_armed_custom_bypass - {{ trigger.platform }} - {{ trigger.event.event_type }}"
+ },
+ },
+ },
+ ]
+ },
+ )
+ hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_TRIGGERED)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_event4")
+ hass.bus.async_fire("test_event5")
+ hass.bus.async_fire("test_event6")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "is_triggered - event - test_event1"
+
+ hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_DISARMED)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_event4")
+ hass.bus.async_fire("test_event5")
+ hass.bus.async_fire("test_event6")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data["some"] == "is_disarmed - event - test_event2"
+
+ hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_event4")
+ hass.bus.async_fire("test_event5")
+ hass.bus.async_fire("test_event6")
+ await hass.async_block_till_done()
+ assert len(calls) == 3
+ assert calls[2].data["some"] == "is_armed_home - event - test_event3"
+
+ hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_event4")
+ hass.bus.async_fire("test_event5")
+ hass.bus.async_fire("test_event6")
+ await hass.async_block_till_done()
+ assert len(calls) == 4
+ assert calls[3].data["some"] == "is_armed_away - event - test_event4"
+
+ hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_event4")
+ hass.bus.async_fire("test_event5")
+ hass.bus.async_fire("test_event6")
+ await hass.async_block_till_done()
+ assert len(calls) == 5
+ assert calls[4].data["some"] == "is_armed_night - event - test_event5"
+
+ hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_CUSTOM_BYPASS)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_event4")
+ hass.bus.async_fire("test_event5")
+ hass.bus.async_fire("test_event6")
+ await hass.async_block_till_done()
+ assert len(calls) == 6
+ assert calls[5].data["some"] == "is_armed_custom_bypass - event - test_event6"
diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py
index fa2917edcad..04f90476c57 100644
--- a/tests/components/alexa/__init__.py
+++ b/tests/components/alexa/__init__.py
@@ -19,6 +19,7 @@ class MockConfig(config.AbstractConfig):
"binary_sensor.test_contact_forced": {"display_categories": "CONTACT_SENSOR"},
"binary_sensor.test_motion_forced": {"display_categories": "MOTION_SENSOR"},
"binary_sensor.test_motion_camera_event": {"display_categories": "CAMERA"},
+ "camera.test": {"display_categories": "CAMERA"},
}
@property
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index fa8f7fbdc9a..a0d40460373 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -1,7 +1,10 @@
"""Test for smart home alexa support."""
+from unittest.mock import patch
+
import pytest
from homeassistant.components.alexa import messages, smart_home
+import homeassistant.components.camera as camera
from homeassistant.components.media_player.const import (
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
@@ -22,6 +25,7 @@ import homeassistant.components.vacuum as vacuum
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import Context, callback
from homeassistant.helpers import entityfilter
+from homeassistant.setup import async_setup_component
from . import (
DEFAULT_CONFIG,
@@ -35,7 +39,7 @@ from . import (
reported_properties,
)
-from tests.common import async_mock_service
+from tests.common import async_mock_service, mock_coro
@pytest.fixture
@@ -48,6 +52,22 @@ def events(hass):
yield events
+@pytest.fixture
+def mock_camera(hass):
+ """Initialize a demo camera platform."""
+ assert hass.loop.run_until_complete(
+ async_setup_component(hass, "camera", {camera.DOMAIN: {"platform": "demo"}})
+ )
+
+
+@pytest.fixture
+def mock_stream(hass):
+ """Initialize a demo camera platform with streaming."""
+ assert hass.loop.run_until_complete(
+ async_setup_component(hass, "stream", {"stream": {}})
+ )
+
+
def test_create_api_message_defaults(hass):
"""Create a API message response of a request with defaults."""
request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy")
@@ -3445,11 +3465,11 @@ async def test_vacuum_discovery(hass):
properties.assert_equal("Alexa.PowerController", "powerState", "OFF")
await assert_request_calls_service(
- "Alexa.PowerController", "TurnOn", "vacuum#test_1", "vacuum.turn_on", hass,
+ "Alexa.PowerController", "TurnOn", "vacuum#test_1", "vacuum.turn_on", hass
)
await assert_request_calls_service(
- "Alexa.PowerController", "TurnOff", "vacuum#test_1", "vacuum.turn_off", hass,
+ "Alexa.PowerController", "TurnOff", "vacuum#test_1", "vacuum.turn_off", hass
)
@@ -3663,18 +3683,18 @@ async def test_vacuum_discovery_no_turn_on(hass):
appliance = await discovery_test(device, hass)
assert_endpoint_capabilities(
- appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa",
+ appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"
)
properties = await reported_properties(hass, "vacuum#test_5")
properties.assert_equal("Alexa.PowerController", "powerState", "ON")
await assert_request_calls_service(
- "Alexa.PowerController", "TurnOn", "vacuum#test_5", "vacuum.start", hass,
+ "Alexa.PowerController", "TurnOn", "vacuum#test_5", "vacuum.start", hass
)
await assert_request_calls_service(
- "Alexa.PowerController", "TurnOff", "vacuum#test_5", "vacuum.turn_off", hass,
+ "Alexa.PowerController", "TurnOff", "vacuum#test_5", "vacuum.turn_off", hass
)
@@ -3693,11 +3713,11 @@ async def test_vacuum_discovery_no_turn_off(hass):
appliance = await discovery_test(device, hass)
assert_endpoint_capabilities(
- appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa",
+ appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"
)
await assert_request_calls_service(
- "Alexa.PowerController", "TurnOn", "vacuum#test_6", "vacuum.turn_on", hass,
+ "Alexa.PowerController", "TurnOn", "vacuum#test_6", "vacuum.turn_on", hass
)
await assert_request_calls_service(
@@ -3722,11 +3742,11 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass):
appliance = await discovery_test(device, hass)
assert_endpoint_capabilities(
- appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa",
+ appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"
)
await assert_request_calls_service(
- "Alexa.PowerController", "TurnOn", "vacuum#test_7", "vacuum.start", hass,
+ "Alexa.PowerController", "TurnOn", "vacuum#test_7", "vacuum.start", hass
)
await assert_request_calls_service(
@@ -3736,3 +3756,106 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass):
"vacuum.return_to_base",
hass,
)
+
+
+async def test_camera_discovery(hass, mock_stream):
+ """Test camera discovery."""
+ device = (
+ "camera.test",
+ "idle",
+ {"friendly_name": "Test camera", "supported_features": 3},
+ )
+ with patch(
+ "homeassistant.helpers.network.async_get_external_url",
+ return_value="https://example.nabu.casa",
+ ):
+ appliance = await discovery_test(device, hass)
+
+ capabilities = assert_endpoint_capabilities(
+ appliance, "Alexa.CameraStreamController", "Alexa.EndpointHealth", "Alexa"
+ )
+
+ camera_stream_capability = get_capability(
+ capabilities, "Alexa.CameraStreamController"
+ )
+ configuration = camera_stream_capability["cameraStreamConfigurations"][0]
+ assert "HLS" in configuration["protocols"]
+ assert {"width": 1280, "height": 720} in configuration["resolutions"]
+ assert "NONE" in configuration["authorizationTypes"]
+ assert "H264" in configuration["videoCodecs"]
+ assert "AAC" in configuration["audioCodecs"]
+
+
+async def test_camera_discovery_without_stream(hass):
+ """Test camera discovery without stream integration."""
+ device = (
+ "camera.test",
+ "idle",
+ {"friendly_name": "Test camera", "supported_features": 3},
+ )
+ with patch(
+ "homeassistant.helpers.network.async_get_external_url",
+ return_value="https://example.nabu.casa",
+ ):
+ appliance = await discovery_test(device, hass)
+ # assert Alexa.CameraStreamController is not yielded.
+ assert_endpoint_capabilities(appliance, "Alexa.EndpointHealth", "Alexa")
+
+
+@pytest.mark.parametrize(
+ "url,result",
+ [
+ ("http://nohttpswrongport.org:8123", 2),
+ ("https://httpswrongport.org:8123", 2),
+ ("http://nohttpsport443.org:443", 2),
+ ("tls://nohttpsport443.org:443", 2),
+ ("https://correctschemaandport.org:443", 3),
+ ("https://correctschemaandport.org", 3),
+ ],
+)
+async def test_camera_hass_urls(hass, mock_stream, url, result):
+ """Test camera discovery with unsupported urls."""
+ device = (
+ "camera.test",
+ "idle",
+ {"friendly_name": "Test camera", "supported_features": 3},
+ )
+ with patch(
+ "homeassistant.helpers.network.async_get_external_url", return_value=url
+ ):
+ appliance = await discovery_test(device, hass)
+ assert len(appliance["capabilities"]) == result
+
+
+async def test_initialize_camera_stream(hass, mock_camera, mock_stream):
+ """Test InitializeCameraStreams handler."""
+ request = get_new_request(
+ "Alexa.CameraStreamController", "InitializeCameraStreams", "camera#demo_camera"
+ )
+
+ with patch(
+ "homeassistant.components.demo.camera.DemoCamera.stream_source",
+ return_value=mock_coro("rtsp://example.local"),
+ ), patch(
+ "homeassistant.helpers.network.async_get_external_url",
+ return_value="https://mycamerastream.test",
+ ):
+ msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert "event" in msg
+ response = msg["event"]
+ assert response["header"]["namespace"] == "Alexa.CameraStreamController"
+ assert response["header"]["name"] == "Response"
+ camera_streams = response["payload"]["cameraStreams"]
+ assert "https://mycamerastream.test/api/hls/" in camera_streams[0]["uri"]
+ assert camera_streams[0]["protocol"] == "HLS"
+ assert camera_streams[0]["resolution"]["width"] == 1280
+ assert camera_streams[0]["resolution"]["height"] == 720
+ assert camera_streams[0]["authorizationType"] == "NONE"
+ assert camera_streams[0]["videoCodec"] == "H264"
+ assert camera_streams[0]["audioCodec"] == "AAC"
+ assert (
+ "https://mycamerastream.test/api/camera_proxy/camera.demo_camera?token="
+ in response["payload"]["imageUri"]
+ )
diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py
index 095b7b76d60..62e5ed891ff 100644
--- a/tests/components/asuswrt/test_device_tracker.py
+++ b/tests/components/asuswrt/test_device_tracker.py
@@ -19,13 +19,23 @@ 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
+async def test_network_unreachable(hass):
+ """Test creating an AsusWRT scanner without a pass or pubkey."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func(exception=OSError)
+ AsusWrt().is_connected = False
+ result = await async_setup_component(
+ hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}}
+ )
+ assert result
+ assert hass.data.get(DATA_ASUSWRT, None) is None
+
+
async def test_get_scanner_with_password_no_pubkey(hass):
"""Test creating an AsusWRT scanner with a password and no pubkey."""
with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
@@ -62,7 +72,7 @@ async def test_specify_non_directory_path_for_dnsmasq(hass):
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_user",
CONF_PASSWORD: "4321",
- CONF_DNSMASQ: "?non_directory?",
+ CONF_DNSMASQ: 1234,
}
},
)
diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py
index 9d4fa9a1100..949851f5470 100644
--- a/tests/components/automation/test_state.py
+++ b/tests/components/automation/test_state.py
@@ -519,6 +519,30 @@ async def test_if_fires_on_entity_change_with_for(hass, calls):
assert 1 == len(calls)
+async def test_if_fires_on_entity_removal(hass, calls):
+ """Test for firing on entity removal, when new_state is None."""
+ context = Context()
+ hass.states.async_set("test.entity", "hello")
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {"platform": "state", "entity_id": "test.entity"},
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.async_remove("test.entity", context=context)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+
+
async def test_if_fires_on_for_condition(hass, calls):
"""Test for firing if condition is on."""
point1 = dt_util.utcnow()
diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py
index fb9bc7d5e5c..495c8a63a0b 100644
--- a/tests/components/bayesian/test_binary_sensor.py
+++ b/tests/components/bayesian/test_binary_sensor.py
@@ -2,6 +2,7 @@
import unittest
from homeassistant.components.bayesian import binary_sensor as bayesian
+from homeassistant.const import STATE_UNKNOWN
from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant
@@ -18,6 +19,65 @@ class TestBayesianBinarySensor(unittest.TestCase):
"""Stop everything that was started."""
self.hass.stop()
+ def test_load_values_when_added_to_hass(self):
+ """Test that sensor initializes with observations of relevant 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,
+ }
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ self.hass.states.set("sensor.test_monitored", "off")
+ self.hass.block_till_done()
+
+ assert setup_component(self.hass, "binary_sensor", config)
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8
+ assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4
+
+ def test_unknown_state_does_not_influence_probability(self):
+ """Test that an unknown state does not change the output probability."""
+
+ 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,
+ }
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ self.hass.states.set("sensor.test_monitored", STATE_UNKNOWN)
+ self.hass.block_till_done()
+
+ assert setup_component(self.hass, "binary_sensor", config)
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert state.attributes.get("observations") == [None]
+
def test_sensor_numeric_state(self):
"""Test sensor on numeric state platform observations."""
config = {
@@ -52,7 +112,7 @@ class TestBayesianBinarySensor(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test_binary")
- assert [] == state.attributes.get("observations")
+ assert [None, None] == state.attributes.get("observations")
assert 0.2 == state.attributes.get("probability")
assert state.state == "off"
@@ -66,10 +126,9 @@ class TestBayesianBinarySensor(unittest.TestCase):
self.hass.block_till_done()
state = self.hass.states.get("binary_sensor.test_binary")
- assert [
- {"prob_false": 0.4, "prob_true": 0.6},
- {"prob_false": 0.1, "prob_true": 0.9},
- ] == state.attributes.get("observations")
+ assert state.attributes.get("observations")[0]["prob_given_true"] == 0.6
+ assert state.attributes.get("observations")[1]["prob_given_true"] == 0.9
+ assert state.attributes.get("observations")[1]["prob_given_false"] == 0.1
assert round(abs(0.77 - state.attributes.get("probability")), 7) == 0
assert state.state == "on"
@@ -118,7 +177,7 @@ class TestBayesianBinarySensor(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test_binary")
- assert [] == state.attributes.get("observations")
+ assert [None] == state.attributes.get("observations")
assert 0.2 == state.attributes.get("probability")
assert state.state == "off"
@@ -131,9 +190,62 @@ class TestBayesianBinarySensor(unittest.TestCase):
self.hass.block_till_done()
state = self.hass.states.get("binary_sensor.test_binary")
- assert [{"prob_true": 0.8, "prob_false": 0.4}] == state.attributes.get(
- "observations"
- )
+ assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8
+ assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4
+ assert round(abs(0.33 - state.attributes.get("probability")), 7) == 0
+
+ assert state.state == "on"
+
+ self.hass.states.set("sensor.test_monitored", "off")
+ self.hass.block_till_done()
+ self.hass.states.set("sensor.test_monitored", "on")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert round(abs(0.2 - state.attributes.get("probability")), 7) == 0
+
+ assert state.state == "off"
+
+ def test_sensor_value_template(self):
+ """Test sensor on template platform observations."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "template",
+ "value_template": "{{states('sensor.test_monitored') == 'off'}}",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ }
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ assert setup_component(self.hass, "binary_sensor", config)
+
+ self.hass.states.set("sensor.test_monitored", "on")
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+
+ assert [None] == state.attributes.get("observations")
+ assert 0.2 == state.attributes.get("probability")
+
+ assert state.state == "off"
+
+ self.hass.states.set("sensor.test_monitored", "off")
+ self.hass.block_till_done()
+ self.hass.states.set("sensor.test_monitored", "on")
+ self.hass.block_till_done()
+ self.hass.states.set("sensor.test_monitored", "off")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8
+ assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4
assert round(abs(0.33 - state.attributes.get("probability")), 7) == 0
assert state.state == "on"
@@ -210,7 +322,7 @@ class TestBayesianBinarySensor(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test_binary")
- assert [] == state.attributes.get("observations")
+ assert [None, None] == state.attributes.get("observations")
assert 0.2 == state.attributes.get("probability")
assert state.state == "off"
@@ -223,9 +335,9 @@ class TestBayesianBinarySensor(unittest.TestCase):
self.hass.block_till_done()
state = self.hass.states.get("binary_sensor.test_binary")
- assert [{"prob_true": 0.8, "prob_false": 0.4}] == state.attributes.get(
- "observations"
- )
+
+ assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8
+ assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4
assert round(abs(0.33 - state.attributes.get("probability")), 7) == 0
assert state.state == "on"
@@ -242,20 +354,20 @@ class TestBayesianBinarySensor(unittest.TestCase):
def test_probability_updates(self):
"""Test probability update function."""
- prob_true = [0.3, 0.6, 0.8]
- prob_false = [0.7, 0.4, 0.2]
+ prob_given_true = [0.3, 0.6, 0.8]
+ prob_given_false = [0.7, 0.4, 0.2]
prior = 0.5
- for pt, pf in zip(prob_true, prob_false):
+ for pt, pf in zip(prob_given_true, prob_given_false):
prior = bayesian.update_probability(prior, pt, pf)
assert round(abs(0.720000 - prior), 7) == 0
- prob_true = [0.8, 0.3, 0.9]
- prob_false = [0.6, 0.4, 0.2]
+ prob_given_true = [0.8, 0.3, 0.9]
+ prob_given_false = [0.6, 0.4, 0.2]
prior = 0.7
- for pt, pf in zip(prob_true, prob_false):
+ for pt, pf in zip(prob_given_true, prob_given_false):
prior = bayesian.update_probability(prior, pt, pf)
assert round(abs(0.9130434782608695 - prior), 7) == 0
@@ -271,7 +383,7 @@ class TestBayesianBinarySensor(unittest.TestCase):
"platform": "state",
"entity_id": "sensor.test_monitored",
"to_state": "off",
- "prob_given_true": 0.8,
+ "prob_given_true": 0.9,
"prob_given_false": 0.4,
},
{
diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py
index 91a7b7e92d4..d6c1fedd31d 100644
--- a/tests/components/brother/__init__.py
+++ b/tests/components/brother/__init__.py
@@ -1 +1,28 @@
-"""Tests for Brother Printer."""
+"""Tests for Brother Printer integration."""
+import json
+
+from asynctest import patch
+
+from homeassistant.components.brother.const import DOMAIN
+from homeassistant.const import CONF_HOST, CONF_TYPE
+
+from tests.common import MockConfigEntry, load_fixture
+
+
+async def init_integration(hass) -> MockConfigEntry:
+ """Set up the Brother integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="HL-L2340DW 0123456789",
+ unique_id="0123456789",
+ data={CONF_HOST: "localhost", CONF_TYPE: "laser"},
+ )
+ with patch(
+ "brother.Brother._get_data",
+ return_value=json.loads(load_fixture("brother_printer_data.json")),
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/brother/test_init.py b/tests/components/brother/test_init.py
new file mode 100644
index 00000000000..13378e9dbb9
--- /dev/null
+++ b/tests/components/brother/test_init.py
@@ -0,0 +1,52 @@
+"""Test init of Brother integration."""
+from asynctest import patch
+
+from homeassistant.components.brother.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import CONF_HOST, CONF_TYPE, STATE_UNAVAILABLE
+
+from tests.common import MockConfigEntry
+from tests.components.brother import init_integration
+
+
+async def test_async_setup_entry(hass):
+ """Test a successful setup entry."""
+ await init_integration(hass)
+
+ state = hass.states.get("sensor.hl_l2340dw_status")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "waiting"
+
+
+async def test_config_not_ready(hass):
+ """Test for setup failure if connection to broker is missing."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="HL-L2340DW 0123456789",
+ unique_id="0123456789",
+ data={CONF_HOST: "localhost", CONF_TYPE: "laser"},
+ )
+
+ with patch("brother.Brother._get_data", side_effect=ConnectionError()):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py
new file mode 100644
index 00000000000..e88c22f3f40
--- /dev/null
+++ b/tests/components/brother/test_sensor.py
@@ -0,0 +1,266 @@
+"""Test sensor of Brother integration."""
+from datetime import timedelta
+import json
+
+from asynctest import patch
+
+from homeassistant.components.brother.const import UNIT_PAGES
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_ICON,
+ ATTR_UNIT_OF_MEASUREMENT,
+ STATE_UNAVAILABLE,
+ TIME_DAYS,
+ UNIT_PERCENTAGE,
+)
+from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed, load_fixture
+from tests.components.brother import init_integration
+
+ATTR_REMAINING_PAGES = "remaining_pages"
+ATTR_COUNTER = "counter"
+
+
+async def test_sensors(hass):
+ """Test states of the sensors."""
+ await init_integration(hass)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("sensor.hl_l2340dw_status")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer"
+ assert state.state == "waiting"
+
+ entry = registry.async_get("sensor.hl_l2340dw_status")
+ assert entry
+ assert entry.unique_id == "0123456789_status"
+
+ state = hass.states.get("sensor.hl_l2340dw_black_toner_remaining")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "75"
+
+ entry = registry.async_get("sensor.hl_l2340dw_black_toner_remaining")
+ assert entry
+ assert entry.unique_id == "0123456789_black_toner_remaining"
+
+ state = hass.states.get("sensor.hl_l2340dw_cyan_toner_remaining")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "10"
+
+ entry = registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining")
+ assert entry
+ assert entry.unique_id == "0123456789_cyan_toner_remaining"
+
+ state = hass.states.get("sensor.hl_l2340dw_magenta_toner_remaining")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "8"
+
+ entry = registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining")
+ assert entry
+ assert entry.unique_id == "0123456789_magenta_toner_remaining"
+
+ state = hass.states.get("sensor.hl_l2340dw_yellow_toner_remaining")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "2"
+
+ entry = registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining")
+ assert entry
+ assert entry.unique_id == "0123456789_yellow_toner_remaining"
+
+ state = hass.states.get("sensor.hl_l2340dw_drum_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
+ assert state.attributes.get(ATTR_REMAINING_PAGES) == 11014
+ assert state.attributes.get(ATTR_COUNTER) == 986
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "92"
+
+ entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_drum_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
+ assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
+ assert state.attributes.get(ATTR_COUNTER) == 1611
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "92"
+
+ entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_black_drum_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
+ assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
+ assert state.attributes.get(ATTR_COUNTER) == 1611
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "92"
+
+ entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_cyan_drum_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
+ assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
+ assert state.attributes.get(ATTR_COUNTER) == 1611
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "92"
+
+ entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_magenta_drum_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
+ assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
+ assert state.attributes.get(ATTR_COUNTER) == 1611
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "92"
+
+ entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_yellow_drum_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_fuser_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:water-outline"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "97"
+
+ entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_fuser_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_belt_unit_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:current-ac"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "97"
+
+ entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_belt_unit_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_pf_kit_1_remaining_life")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "98"
+
+ entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_life")
+ assert entry
+ assert entry.unique_id == "0123456789_pf_kit_1_remaining_life"
+
+ state = hass.states.get("sensor.hl_l2340dw_page_counter")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES
+ assert state.state == "986"
+
+ entry = registry.async_get("sensor.hl_l2340dw_page_counter")
+ assert entry
+ assert entry.unique_id == "0123456789_page_counter"
+
+ state = hass.states.get("sensor.hl_l2340dw_duplex_unit_pages_counter")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES
+ assert state.state == "538"
+
+ entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_pages_counter")
+ assert entry
+ assert entry.unique_id == "0123456789_duplex_unit_pages_counter"
+
+ state = hass.states.get("sensor.hl_l2340dw_b_w_counter")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES
+ assert state.state == "709"
+
+ entry = registry.async_get("sensor.hl_l2340dw_b_w_counter")
+ assert entry
+ assert entry.unique_id == "0123456789_b/w_counter"
+
+ state = hass.states.get("sensor.hl_l2340dw_color_counter")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES
+ assert state.state == "902"
+
+ entry = registry.async_get("sensor.hl_l2340dw_color_counter")
+ assert entry
+ assert entry.unique_id == "0123456789_color_counter"
+
+ state = hass.states.get("sensor.hl_l2340dw_uptime")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:timer"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TIME_DAYS
+ assert state.state == "48"
+
+ entry = registry.async_get("sensor.hl_l2340dw_uptime")
+ assert entry
+ assert entry.unique_id == "0123456789_uptime"
+
+
+async def test_availability(hass):
+ """Ensure that we mark the entities unavailable correctly when device is offline."""
+ await init_integration(hass)
+
+ state = hass.states.get("sensor.hl_l2340dw_status")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "waiting"
+
+ future = utcnow() + timedelta(minutes=5)
+ with patch("brother.Brother._get_data", side_effect=ConnectionError()):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.hl_l2340dw_status")
+ assert state
+ assert state.state == STATE_UNAVAILABLE
+
+ future = utcnow() + timedelta(minutes=10)
+ with patch(
+ "brother.Brother._get_data",
+ return_value=json.loads(load_fixture("brother_printer_data.json")),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.hl_l2340dw_status")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "waiting"
+
+
+async def test_manual_update_entity(hass):
+ """Test manual update entity via service homeasasistant/update_entity."""
+ await init_integration(hass)
+
+ await async_setup_component(hass, "homeassistant", {})
+ with patch("homeassistant.components.brother.Brother.async_update") as mock_update:
+ await hass.services.async_call(
+ "homeassistant",
+ "update_entity",
+ {ATTR_ENTITY_ID: ["sensor.hl_l2340dw_status"]},
+ blocking=True,
+ )
+
+ assert len(mock_update.mock_calls) == 1
diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py
index 10dd253704e..2ec02da7669 100644
--- a/tests/components/cast/test_home_assistant_cast.py
+++ b/tests/components/cast/test_home_assistant_cast.py
@@ -20,13 +20,38 @@ async def test_service_show_view(hass):
)
assert len(calls) == 1
- controller, entity_id, view_path = calls[0]
+ controller, entity_id, view_path, url_path = calls[0]
assert controller.hass_url == "http://example.com"
assert controller.client_id is None
# Verify user did not accidentally submit their dev app id
assert controller.supporting_app_id == "B12CE3CA"
assert entity_id == "media_player.kitchen"
assert view_path == "mock_path"
+ assert url_path is None
+
+
+async def test_service_show_view_dashboard(hass):
+ """Test casting a specific dashboard."""
+ hass.config.api = Mock(base_url="http://example.com")
+ await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry())
+ calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW)
+
+ await hass.services.async_call(
+ "cast",
+ "show_lovelace_view",
+ {
+ "entity_id": "media_player.kitchen",
+ "view_path": "mock_path",
+ "dashboard_path": "mock-dashboard",
+ },
+ blocking=True,
+ )
+
+ assert len(calls) == 1
+ _controller, entity_id, view_path, url_path = calls[0]
+ assert entity_id == "media_player.kitchen"
+ assert view_path == "mock_path"
+ assert url_path == "mock-dashboard"
async def test_use_cloud_url(hass):
diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py
index b07df39a8fe..53defb4cd6e 100644
--- a/tests/components/config/test_auth.py
+++ b/tests/components/config/test_auth.py
@@ -156,8 +156,35 @@ async def test_create(hass, hass_ws_client, hass_access_token):
assert len(await hass.auth.async_get_users()) == 1
+ await client.send_json({"id": 5, "type": "config/auth/create", "name": "Paulus"})
+
+ result = await client.receive_json()
+ assert result["success"], result
+ assert len(await hass.auth.async_get_users()) == 2
+ data_user = result["result"]["user"]
+ user = await hass.auth.async_get_user(data_user["id"])
+ assert user is not None
+ assert user.name == data_user["name"]
+ assert user.is_active
+ assert user.groups == []
+ assert not user.is_admin
+ assert not user.is_owner
+ assert not user.system_generated
+
+
+async def test_create_user_group(hass, hass_ws_client, hass_access_token):
+ """Test create user with a group."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ assert len(await hass.auth.async_get_users()) == 1
+
await client.send_json(
- {"id": 5, "type": auth_config.WS_TYPE_CREATE, "name": "Paulus"}
+ {
+ "id": 5,
+ "type": "config/auth/create",
+ "name": "Paulus",
+ "group_ids": ["system-admin"],
+ }
)
result = await client.receive_json()
@@ -168,6 +195,8 @@ async def test_create(hass, hass_ws_client, hass_access_token):
assert user is not None
assert user.name == data_user["name"]
assert user.is_active
+ assert user.groups[0].id == "system-admin"
+ assert user.is_admin
assert not user.is_owner
assert not user.system_generated
@@ -176,7 +205,7 @@ async def test_create_requires_admin(hass, hass_ws_client, hass_read_only_access
"""Test create command requires an admin."""
client = await hass_ws_client(hass, hass_read_only_access_token)
- await client.send_json({"id": 5, "type": auth_config.WS_TYPE_CREATE, "name": "YO"})
+ await client.send_json({"id": 5, "type": "config/auth/create", "name": "YO"})
result = await client.receive_json()
assert not result["success"], result
diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py
index 49d168e2796..d00e0317e9e 100644
--- a/tests/components/config/test_group.py
+++ b/tests/components/config/test_group.py
@@ -1,6 +1,8 @@
"""Test Group config panel."""
import json
-from unittest.mock import MagicMock, patch
+from unittest.mock import patch
+
+from asynctest import CoroutineMock
from homeassistant.bootstrap import async_setup_component
from homeassistant.components import config
@@ -50,7 +52,7 @@ async def test_update_device_config(hass, hass_client):
"""Mock writing data."""
written.append(data)
- mock_call = MagicMock()
+ mock_call = CoroutineMock()
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py
index 4bf0ec86f4a..43bb165b1a6 100644
--- a/tests/components/deconz/test_cover.py
+++ b/tests/components/deconz/test_cover.py
@@ -14,7 +14,7 @@ COVERS = {
"id": "Level controllable cover id",
"name": "Level controllable cover",
"type": "Level controllable output",
- "state": {"bri": 255, "on": False, "reachable": True},
+ "state": {"bri": 254, "on": False, "reachable": True},
"modelid": "Not zigbee spec",
"uniqueid": "00:00:00:00:00:00:00:00-00",
},
@@ -22,7 +22,7 @@ COVERS = {
"id": "Window covering device id",
"name": "Window covering device",
"type": "Window covering device",
- "state": {"bri": 255, "on": True, "reachable": True},
+ "state": {"bri": 254, "on": True, "reachable": True},
"modelid": "lumi.curtain",
"uniqueid": "00:00:00:00:00:00:00:01-00",
},
@@ -33,6 +33,14 @@ COVERS = {
"state": {"reachable": True},
"uniqueid": "00:00:00:00:00:00:00:02-00",
},
+ "4": {
+ "id": "deconz old brightness cover id",
+ "name": "deconz old brightness cover",
+ "type": "Level controllable output",
+ "state": {"bri": 255, "on": False, "reachable": True},
+ "modelid": "Not zigbee spec",
+ "uniqueid": "00:00:00:00:00:00:00:03-00",
+ },
}
@@ -62,7 +70,8 @@ async def test_cover(hass):
assert "cover.level_controllable_cover" in gateway.deconz_ids
assert "cover.window_covering_device" in gateway.deconz_ids
assert "cover.unsupported_cover" not in gateway.deconz_ids
- assert len(hass.states.async_all()) == 3
+ assert "cover.deconz_old_brightness_cover" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 4
level_controllable_cover = hass.states.get("cover.level_controllable_cover")
assert level_controllable_cover.state == "open"
@@ -105,7 +114,7 @@ async def test_cover(hass):
)
await hass.async_block_till_done()
set_callback.assert_called_with(
- "put", "/lights/1/state", json={"on": True, "bri": 255}
+ "put", "/lights/1/state", json={"on": True, "bri": 254}
)
with patch.object(
@@ -120,6 +129,23 @@ async def test_cover(hass):
await hass.async_block_till_done()
set_callback.assert_called_with("put", "/lights/1/state", json={"bri_inc": 0})
+ """Test that a reported cover position of 255 (deconz-rest-api < 2.05.73) is interpreted correctly."""
+ deconz_old_brightness_cover = hass.states.get("cover.deconz_old_brightness_cover")
+ assert deconz_old_brightness_cover.state == "open"
+
+ state_changed_event = {
+ "t": "event",
+ "e": "changed",
+ "r": "lights",
+ "id": "4",
+ "state": {"on": True},
+ }
+ gateway.api.event_handler(state_changed_event)
+ await hass.async_block_till_done()
+
+ deconz_old_brightness_cover = hass.states.get("cover.deconz_old_brightness_cover")
+ assert deconz_old_brightness_cover.attributes["current_position"] == 0
+
await gateway.async_reset()
assert len(hass.states.async_all()) == 0
diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py
index 6b9004595bb..638130a0ab6 100644
--- a/tests/components/default_config/test_init.py
+++ b/tests/components/default_config/test_init.py
@@ -34,4 +34,4 @@ def recorder_url_mock():
async def test_setup(hass):
"""Test setup."""
- assert await async_setup_component(hass, "default_config", {})
+ assert await async_setup_component(hass, "default_config", {"foo": "bar"})
diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py
index 876b1e311ab..cd0f72307d8 100644
--- a/tests/components/directv/__init__.py
+++ b/tests/components/directv/__init__.py
@@ -1,183 +1,94 @@
"""Tests for the DirecTV component."""
-from DirectPy import DIRECTV
-
-from homeassistant.components.directv.const import DOMAIN
+from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN
+from homeassistant.components.ssdp import ATTR_SSDP_LOCATION
from homeassistant.const import CONF_HOST
from homeassistant.helpers.typing import HomeAssistantType
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, load_fixture
+from tests.test_util.aiohttp import AiohttpClientMocker
-CLIENT_NAME = "Bedroom Client"
-CLIENT_ADDRESS = "2CA17D1CD30X"
-DEFAULT_DEVICE = "0"
HOST = "127.0.0.1"
-MAIN_NAME = "Main DVR"
RECEIVER_ID = "028877455858"
SSDP_LOCATION = "http://127.0.0.1/"
UPNP_SERIAL = "RID-028877455858"
-LIVE = {
- "callsign": "HASSTV",
- "date": "20181110",
- "duration": 3600,
- "isOffAir": False,
- "isPclocked": 1,
- "isPpv": False,
- "isRecording": False,
- "isVod": False,
- "major": 202,
- "minor": 65535,
- "offset": 1,
- "programId": "102454523",
- "rating": "No Rating",
- "startTime": 1541876400,
- "stationId": 3900947,
- "title": "Using Home Assistant to automate your home",
-}
-
-RECORDING = {
- "callsign": "HASSTV",
- "date": "20181110",
- "duration": 3600,
- "isOffAir": False,
- "isPclocked": 1,
- "isPpv": False,
- "isRecording": True,
- "isVod": False,
- "major": 202,
- "minor": 65535,
- "offset": 1,
- "programId": "102454523",
- "rating": "No Rating",
- "startTime": 1541876400,
- "stationId": 3900947,
- "title": "Using Home Assistant to automate your home",
- "uniqueId": "12345",
- "episodeTitle": "Configure DirecTV platform.",
-}
-
MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]}
-
-MOCK_GET_LOCATIONS = {
- "locations": [{"locationName": MAIN_NAME, "clientAddr": DEFAULT_DEVICE}],
- "status": {
- "code": 200,
- "commandResult": 0,
- "msg": "OK.",
- "query": "/info/getLocations",
- },
-}
-
-MOCK_GET_LOCATIONS_MULTIPLE = {
- "locations": [
- {"locationName": MAIN_NAME, "clientAddr": DEFAULT_DEVICE},
- {"locationName": CLIENT_NAME, "clientAddr": CLIENT_ADDRESS},
- ],
- "status": {
- "code": 200,
- "commandResult": 0,
- "msg": "OK.",
- "query": "/info/getLocations",
- },
-}
-
-MOCK_GET_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",
-}
+MOCK_SSDP_DISCOVERY_INFO = {ATTR_SSDP_LOCATION: SSDP_LOCATION}
+MOCK_USER_INPUT = {CONF_HOST: HOST}
-class MockDirectvClass(DIRECTV):
- """A fake DirecTV DVR device."""
+def mock_connection(aioclient_mock: AiohttpClientMocker) -> None:
+ """Mock the DirecTV connection for Home Assistant."""
+ aioclient_mock.get(
+ f"http://{HOST}:8080/info/getVersion",
+ text=load_fixture("directv/info-get-version.json"),
+ headers={"Content-Type": "application/json"},
+ )
- def __init__(self, ip, port=8080, clientAddr="0", determine_state=False):
- """Initialize the fake DirecTV device."""
- super().__init__(
- ip=ip, port=port, clientAddr=clientAddr, determine_state=determine_state,
- )
+ aioclient_mock.get(
+ f"http://{HOST}:8080/info/getLocations",
+ text=load_fixture("directv/info-get-locations.json"),
+ headers={"Content-Type": "application/json"},
+ )
- self._play = False
- self._standby = True
+ aioclient_mock.get(
+ f"http://{HOST}:8080/info/mode",
+ params={"clientAddr": "9XXXXXXXXXX9"},
+ status=500,
+ text=load_fixture("directv/info-mode-error.json"),
+ headers={"Content-Type": "application/json"},
+ )
- if self.clientAddr == CLIENT_ADDRESS:
- self.attributes = RECORDING
- self._standby = False
- else:
- self.attributes = LIVE
+ aioclient_mock.get(
+ f"http://{HOST}:8080/info/mode",
+ text=load_fixture("directv/info-mode.json"),
+ headers={"Content-Type": "application/json"},
+ )
- def get_locations(self):
- """Mock for get_locations method."""
- return MOCK_GET_LOCATIONS
+ aioclient_mock.get(
+ f"http://{HOST}:8080/remote/processKey",
+ text=load_fixture("directv/remote-process-key.json"),
+ headers={"Content-Type": "application/json"},
+ )
- 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",
- },
- }
+ aioclient_mock.get(
+ f"http://{HOST}:8080/tv/tune",
+ text=load_fixture("directv/tv-tune.json"),
+ headers={"Content-Type": "application/json"},
+ )
- return test_serial_num
+ aioclient_mock.get(
+ f"http://{HOST}:8080/tv/getTuned",
+ params={"clientAddr": "2CA17D1CD30X"},
+ text=load_fixture("directv/tv-get-tuned.json"),
+ headers={"Content-Type": "application/json"},
+ )
- def get_standby(self):
- """Mock for get_standby method."""
- return self._standby
-
- def get_tuned(self):
- """Mock for get_tuned method."""
- if self._play:
- self.attributes["offset"] = self.attributes["offset"] + 1
-
- test_attributes = self.attributes
- test_attributes["status"] = {
- "code": 200,
- "commandResult": 0,
- "msg": "OK.",
- "query": "/tv/getTuned",
- }
- return test_attributes
-
- def get_version(self):
- """Mock for get_version method."""
- return MOCK_GET_VERSION
-
- def key_press(self, keypress):
- """Mock for key_press method."""
- if keypress == "poweron":
- self._standby = False
- self._play = True
- elif keypress == "poweroff":
- self._standby = True
- self._play = False
- elif keypress == "play":
- self._play = True
- elif keypress == "pause" or keypress == "stop":
- self._play = False
-
- def tune_channel(self, source):
- """Mock for tune_channel method."""
- self.attributes["major"] = int(source)
+ aioclient_mock.get(
+ f"http://{HOST}:8080/tv/getTuned",
+ text=load_fixture("directv/tv-get-tuned-movie.json"),
+ headers={"Content-Type": "application/json"},
+ )
async def setup_integration(
- hass: HomeAssistantType, skip_entry_setup: bool = False
+ hass: HomeAssistantType,
+ aioclient_mock: AiohttpClientMocker,
+ skip_entry_setup: bool = False,
+ setup_error: bool = False,
) -> MockConfigEntry:
"""Set up the DirecTV integration in Home Assistant."""
+ if setup_error:
+ aioclient_mock.get(
+ f"http://{HOST}:8080/info/getVersion", status=500,
+ )
+ else:
+ mock_connection(aioclient_mock)
+
entry = MockConfigEntry(
- domain=DOMAIN, unique_id=RECEIVER_ID, data={CONF_HOST: HOST}
+ domain=DOMAIN,
+ unique_id=RECEIVER_ID,
+ data={CONF_HOST: HOST, CONF_RECEIVER_ID: RECEIVER_ID},
)
entry.add_to_hass(hass)
diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py
index bd5d8b83419..c5cfec50637 100644
--- a/tests/components/directv/test_config_flow.py
+++ b/tests/components/directv/test_config_flow.py
@@ -1,11 +1,9 @@
"""Test the DirecTV config flow."""
-from typing import Any, Dict, Optional
-
+from aiohttp import ClientError as HTTPClientError
from asynctest import patch
-from requests.exceptions import RequestException
-from homeassistant.components.directv.const import DOMAIN
-from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL
+from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN
+from homeassistant.components.ssdp import ATTR_UPNP_SERIAL
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
from homeassistant.data_entry_flow import (
@@ -14,219 +12,259 @@ from homeassistant.data_entry_flow import (
RESULT_TYPE_FORM,
)
from homeassistant.helpers.typing import HomeAssistantType
-from homeassistant.setup import async_setup_component
-from tests.common import MockConfigEntry
from tests.components.directv import (
HOST,
+ MOCK_SSDP_DISCOVERY_INFO,
+ MOCK_USER_INPUT,
RECEIVER_ID,
- SSDP_LOCATION,
UPNP_SERIAL,
- MockDirectvClass,
+ mock_connection,
+ setup_integration,
)
+from tests.test_util.aiohttp import AiohttpClientMocker
-async def async_configure_flow(
- hass: HomeAssistantType, flow_id: str, user_input: Optional[Dict] = None
-) -> Any:
- """Set up mock DirecTV integration flow."""
- with patch(
- "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass,
- ):
- return await hass.config_entries.flow.async_configure(
- flow_id=flow_id, user_input=user_input
- )
-
-
-async def async_init_flow(
- hass: HomeAssistantType,
- handler: str = DOMAIN,
- context: Optional[Dict] = None,
- data: Any = None,
-) -> Any:
- """Set up mock DirecTV integration flow."""
- with patch(
- "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass,
- ):
- return await hass.config_entries.flow.async_init(
- handler=handler, context=context, data=data
- )
-
-
-async def test_duplicate_error(hass: HomeAssistantType) -> None:
- """Test that errors are shown when duplicates are added."""
- MockConfigEntry(
- domain=DOMAIN, unique_id=RECEIVER_ID, data={CONF_HOST: HOST}
- ).add_to_hass(hass)
-
- result = await async_init_flow(
- hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST}
- )
-
- assert result["type"] == RESULT_TYPE_ABORT
- assert result["reason"] == "already_configured"
-
- result = await async_init_flow(
- hass, context={CONF_SOURCE: SOURCE_USER}, data={CONF_HOST: HOST}
- )
-
- assert result["type"] == RESULT_TYPE_ABORT
- assert result["reason"] == "already_configured"
-
- result = await async_init_flow(
- hass,
- context={CONF_SOURCE: SOURCE_SSDP},
- data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL},
- )
-
- assert result["type"] == RESULT_TYPE_ABORT
- assert result["reason"] == "already_configured"
-
-
-async def test_form(hass: HomeAssistantType) -> None:
- """Test we get the form."""
- await async_setup_component(hass, "persistent_notification", {})
+async def test_show_user_form(hass: HomeAssistantType) -> None:
+ """Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER},
)
+
+ assert result["step_id"] == "user"
assert result["type"] == RESULT_TYPE_FORM
- assert result["errors"] == {}
-
- with patch(
- "homeassistant.components.directv.async_setup", return_value=True
- ) as mock_setup, patch(
- "homeassistant.components.directv.async_setup_entry", return_value=True,
- ) as mock_setup_entry:
- result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST})
-
- assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == HOST
- assert result["data"] == {CONF_HOST: HOST}
- 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_cannot_connect(hass: HomeAssistantType) -> None:
- """Test we handle cannot connect error."""
+async def test_show_ssdp_form(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test that the ssdp confirmation form is served."""
+ mock_connection(aioclient_mock)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}
- )
-
- with patch(
- "tests.components.directv.test_config_flow.MockDirectvClass.get_version",
- side_effect=RequestException,
- ) as mock_validate_input:
- result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},)
-
- assert result["type"] == RESULT_TYPE_FORM
- assert result["errors"] == {"base": "cannot_connect"}
-
- await hass.async_block_till_done()
- assert len(mock_validate_input.mock_calls) == 1
-
-
-async def test_form_unknown_error(hass: HomeAssistantType) -> None:
- """Test we handle unknown error."""
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}
- )
-
- with patch(
- "tests.components.directv.test_config_flow.MockDirectvClass.get_version",
- side_effect=Exception,
- ) as mock_validate_input:
- result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},)
-
- assert result["type"] == RESULT_TYPE_ABORT
- assert result["reason"] == "unknown"
-
- await hass.async_block_till_done()
- assert len(mock_validate_input.mock_calls) == 1
-
-
-async def test_import(hass: HomeAssistantType) -> None:
- """Test the import step."""
- with patch(
- "homeassistant.components.directv.async_setup", return_value=True
- ) as mock_setup, patch(
- "homeassistant.components.directv.async_setup_entry", return_value=True,
- ) as mock_setup_entry:
- result = await async_init_flow(
- hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST},
- )
-
- assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == HOST
- assert result["data"] == {CONF_HOST: HOST}
-
- await hass.async_block_till_done()
- assert len(mock_setup.mock_calls) == 1
- assert len(mock_setup_entry.mock_calls) == 1
-
-
-async def test_ssdp_discovery(hass: HomeAssistantType) -> None:
- """Test the ssdp discovery step."""
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={CONF_SOURCE: SOURCE_SSDP},
- data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL},
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "ssdp_confirm"
assert result["description_placeholders"] == {CONF_NAME: HOST}
+
+async def test_cannot_connect(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we show user form on connection error."""
+ aioclient_mock.get("http://127.0.0.1:8080/info/getVersion", exc=HTTPClientError)
+
+ user_input = MOCK_USER_INPUT.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_ssdp_cannot_connect(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort SSDP flow on connection error."""
+ aioclient_mock.get("http://127.0.0.1:8080/info/getVersion", exc=HTTPClientError)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_ssdp_confirm_cannot_connect(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort SSDP flow on connection error."""
+ aioclient_mock.get("http://127.0.0.1:8080/info/getVersion", exc=HTTPClientError)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP, CONF_HOST: HOST, CONF_NAME: HOST},
+ data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_user_device_exists_abort(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort user flow if DirecTV receiver already configured."""
+ await setup_integration(hass, aioclient_mock)
+
+ user_input = MOCK_USER_INPUT.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_ssdp_device_exists_abort(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort SSDP flow if DirecTV receiver already configured."""
+ await setup_integration(hass, aioclient_mock)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_ssdp_with_receiver_id_device_exists_abort(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort SSDP flow if DirecTV receiver already configured."""
+ await setup_integration(hass, aioclient_mock)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ discovery_info[ATTR_UPNP_SERIAL] = UPNP_SERIAL
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_unknown_error(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we show user form on unknown error."""
+ user_input = MOCK_USER_INPUT.copy()
with patch(
- "homeassistant.components.directv.async_setup", return_value=True
- ) as mock_setup, patch(
- "homeassistant.components.directv.async_setup_entry", return_value=True,
- ) as mock_setup_entry:
- result = await async_configure_flow(hass, result["flow_id"], {})
+ "homeassistant.components.directv.config_flow.DIRECTV.update",
+ side_effect=Exception,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "unknown"
+
+
+async def test_ssdp_unknown_error(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort SSDP flow on unknown error."""
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ with patch(
+ "homeassistant.components.directv.config_flow.DIRECTV.update",
+ side_effect=Exception,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "unknown"
+
+
+async def test_ssdp_confirm_unknown_error(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort SSDP flow on unknown error."""
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ with patch(
+ "homeassistant.components.directv.config_flow.DIRECTV.update",
+ side_effect=Exception,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP, CONF_HOST: HOST, CONF_NAME: HOST},
+ data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "unknown"
+
+
+async def test_full_import_flow_implementation(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the full manual user flow from start to finish."""
+ mock_connection(aioclient_mock)
+
+ user_input = MOCK_USER_INPUT.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=user_input,
+ )
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
- assert result["data"] == {CONF_HOST: HOST}
- await hass.async_block_till_done()
- assert len(mock_setup.mock_calls) == 1
- assert len(mock_setup_entry.mock_calls) == 1
+
+ assert result["data"]
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_RECEIVER_ID] == RECEIVER_ID
-async def test_ssdp_discovery_confirm_abort(hass: HomeAssistantType) -> None:
- """Test we handle SSDP confirm cannot connect error."""
+async def test_full_user_flow_implementation(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the full manual user flow from start to finish."""
+ mock_connection(aioclient_mock)
+
result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={CONF_SOURCE: SOURCE_SSDP},
- data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL},
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER},
)
- with patch(
- "tests.components.directv.test_config_flow.MockDirectvClass.get_version",
- side_effect=RequestException,
- ) as mock_validate_input:
- result = await async_configure_flow(hass, result["flow_id"], {})
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
- assert result["type"] == RESULT_TYPE_ABORT
-
- await hass.async_block_till_done()
- assert len(mock_validate_input.mock_calls) == 1
-
-
-async def test_ssdp_discovery_confirm_unknown_error(hass: HomeAssistantType) -> None:
- """Test we handle SSDP confirm unknown error."""
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={CONF_SOURCE: SOURCE_SSDP},
- data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL},
+ user_input = MOCK_USER_INPUT.copy()
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=user_input,
)
- with patch(
- "tests.components.directv.test_config_flow.MockDirectvClass.get_version",
- side_effect=Exception,
- ) as mock_validate_input:
- result = await async_configure_flow(hass, result["flow_id"], {})
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
- assert result["type"] == RESULT_TYPE_ABORT
+ assert result["data"]
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_RECEIVER_ID] == RECEIVER_ID
- await hass.async_block_till_done()
- assert len(mock_validate_input.mock_calls) == 1
+
+async def test_full_ssdp_flow_implementation(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the full SSDP flow from start to finish."""
+ mock_connection(aioclient_mock)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "ssdp_confirm"
+ assert result["description_placeholders"] == {CONF_NAME: HOST}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
+
+ assert result["data"]
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_RECEIVER_ID] == RECEIVER_ID
diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py
index 02e97b9b015..0d806d668a0 100644
--- a/tests/components/directv/test_init.py
+++ b/tests/components/directv/test_init.py
@@ -1,7 +1,4 @@
-"""Tests for the Roku integration."""
-from asynctest import patch
-from requests.exceptions import RequestException
-
+"""Tests for the DirecTV integration."""
from homeassistant.components.directv.const import DOMAIN
from homeassistant.config_entries import (
ENTRY_STATE_LOADED,
@@ -9,34 +6,36 @@ from homeassistant.config_entries import (
ENTRY_STATE_SETUP_RETRY,
)
from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.setup import async_setup_component
-from tests.components.directv import MockDirectvClass, setup_integration
+from tests.components.directv import MOCK_CONFIG, mock_connection, setup_integration
+from tests.test_util.aiohttp import AiohttpClientMocker
# pylint: disable=redefined-outer-name
-async def test_config_entry_not_ready(hass: HomeAssistantType) -> None:
+async def test_setup(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the DirecTV setup from configuration."""
+ mock_connection(aioclient_mock)
+ assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG)
+
+
+async def test_config_entry_not_ready(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
"""Test the DirecTV configuration entry not ready."""
- with patch(
- "homeassistant.components.directv.DIRECTV", new=MockDirectvClass,
- ), patch(
- "homeassistant.components.directv.DIRECTV.get_locations",
- side_effect=RequestException,
- ):
- entry = await setup_integration(hass)
+ entry = await setup_integration(hass, aioclient_mock, setup_error=True)
assert entry.state == ENTRY_STATE_SETUP_RETRY
-async def test_unload_config_entry(hass: HomeAssistantType) -> None:
+async def test_unload_config_entry(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
"""Test the DirecTV configuration entry unloading."""
- with patch(
- "homeassistant.components.directv.DIRECTV", new=MockDirectvClass,
- ), patch(
- "homeassistant.components.directv.media_player.async_setup_entry",
- return_value=True,
- ):
- entry = await setup_integration(hass)
+ entry = await setup_integration(hass, aioclient_mock)
assert entry.entry_id in hass.data[DOMAIN]
assert entry.state == ENTRY_STATE_LOADED
diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py
index f7cf63355a8..698e6ddac31 100644
--- a/tests/components/directv/test_media_player.py
+++ b/tests/components/directv/test_media_player.py
@@ -4,7 +4,6 @@ from typing import Optional
from asynctest import patch
from pytest import fixture
-from requests import RequestException
from homeassistant.components.directv.media_player import (
ATTR_MEDIA_CURRENTLY_RECORDING,
@@ -24,6 +23,7 @@ from homeassistant.components.media_player.const import (
ATTR_MEDIA_SERIES_TITLE,
ATTR_MEDIA_TITLE,
DOMAIN as MP_DOMAIN,
+ MEDIA_TYPE_MOVIE,
MEDIA_TYPE_TVSHOW,
SERVICE_PLAY_MEDIA,
SUPPORT_NEXT_TRACK,
@@ -44,7 +44,6 @@ from homeassistant.const import (
SERVICE_MEDIA_STOP,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
- STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
@@ -52,18 +51,13 @@ from homeassistant.const import (
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import dt as dt_util
-from tests.common import MockConfigEntry, async_fire_time_changed
-from tests.components.directv import (
- DOMAIN,
- MOCK_GET_LOCATIONS_MULTIPLE,
- RECORDING,
- MockDirectvClass,
- setup_integration,
-)
+from tests.components.directv import setup_integration
+from tests.test_util.aiohttp import AiohttpClientMocker
ATTR_UNIQUE_ID = "unique_id"
-CLIENT_ENTITY_ID = f"{MP_DOMAIN}.bedroom_client"
-MAIN_ENTITY_ID = f"{MP_DOMAIN}.main_dvr"
+CLIENT_ENTITY_ID = f"{MP_DOMAIN}.client"
+MAIN_ENTITY_ID = f"{MP_DOMAIN}.host"
+UNAVAILABLE_ENTITY_ID = f"{MP_DOMAIN}.unavailable_client"
# pylint: disable=redefined-outer-name
@@ -74,29 +68,6 @@ def mock_now() -> datetime:
return dt_util.utcnow()
-async def setup_directv(hass: HomeAssistantType) -> MockConfigEntry:
- """Set up mock DirecTV integration."""
- with patch(
- "homeassistant.components.directv.DIRECTV", new=MockDirectvClass,
- ):
- return await setup_integration(hass)
-
-
-async def setup_directv_with_locations(hass: HomeAssistantType) -> MockConfigEntry:
- """Set up mock DirecTV integration."""
- with patch(
- "tests.components.directv.test_media_player.MockDirectvClass.get_locations",
- return_value=MOCK_GET_LOCATIONS_MULTIPLE,
- ):
- with patch(
- "homeassistant.components.directv.DIRECTV", new=MockDirectvClass,
- ), patch(
- "homeassistant.components.directv.media_player.DIRECTV",
- new=MockDirectvClass,
- ):
- return await setup_integration(hass)
-
-
async def async_turn_on(
hass: HomeAssistantType, entity_id: Optional[str] = None
) -> None:
@@ -172,23 +143,21 @@ async def async_play_media(
await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data)
-async def test_setup(hass: HomeAssistantType) -> None:
+async def test_setup(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
"""Test setup with basic config."""
- await setup_directv(hass)
- assert hass.states.get(MAIN_ENTITY_ID)
-
-
-async def test_setup_with_multiple_locations(hass: HomeAssistantType) -> None:
- """Test setup with basic config with client location."""
- await setup_directv_with_locations(hass)
-
+ await setup_integration(hass, aioclient_mock)
assert hass.states.get(MAIN_ENTITY_ID)
assert hass.states.get(CLIENT_ENTITY_ID)
+ assert hass.states.get(UNAVAILABLE_ENTITY_ID)
-async def test_unique_id(hass: HomeAssistantType) -> None:
+async def test_unique_id(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
"""Test unique id."""
- await setup_directv_with_locations(hass)
+ await setup_integration(hass, aioclient_mock)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
@@ -198,10 +167,15 @@ async def test_unique_id(hass: HomeAssistantType) -> None:
client = entity_registry.async_get(CLIENT_ENTITY_ID)
assert client.unique_id == "2CA17D1CD30X"
+ unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID)
+ assert unavailable_client.unique_id == "9XXXXXXXXXX9"
-async def test_supported_features(hass: HomeAssistantType) -> None:
+
+async def test_supported_features(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
"""Test supported features."""
- await setup_directv_with_locations(hass)
+ await setup_integration(hass, aioclient_mock)
# Features supported for main DVR
state = hass.states.get(MAIN_ENTITY_ID)
@@ -231,168 +205,123 @@ async def test_supported_features(hass: HomeAssistantType) -> None:
async def test_check_attributes(
- hass: HomeAssistantType, mock_now: dt_util.dt.datetime
+ hass: HomeAssistantType,
+ mock_now: dt_util.dt.datetime,
+ aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test attributes."""
- await setup_directv_with_locations(hass)
+ await setup_integration(hass, aioclient_mock)
- 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()
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_PLAYING
- # Start playing TV
- with patch("homeassistant.util.dt.utcnow", return_value=next_update):
- await async_media_play(hass, CLIENT_ENTITY_ID)
- await hass.async_block_till_done()
+ assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "17016356"
+ assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_MOVIE
+ assert state.attributes.get(ATTR_MEDIA_DURATION) == 7200
+ assert state.attributes.get(ATTR_MEDIA_POSITION) == 4437
+ assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT)
+ assert state.attributes.get(ATTR_MEDIA_TITLE) == "Snow Bride"
+ assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None
+ assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "{} ({})".format("HALLHD", "312")
+ assert state.attributes.get(ATTR_INPUT_SOURCE) == "312"
+ assert not state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING)
+ assert state.attributes.get(ATTR_MEDIA_RATING) == "TV-G"
+ assert not state.attributes.get(ATTR_MEDIA_RECORDED)
+ assert state.attributes.get(ATTR_MEDIA_START_TIME) == datetime(
+ 2020, 3, 21, 13, 0, tzinfo=dt_util.UTC
+ )
state = hass.states.get(CLIENT_ENTITY_ID)
assert state.state == STATE_PLAYING
- assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == RECORDING["programId"]
+ assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "4405732"
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_TVSHOW
- assert state.attributes.get(ATTR_MEDIA_DURATION) == RECORDING["duration"]
- assert state.attributes.get(ATTR_MEDIA_POSITION) == 2
- assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) == next_update
- assert state.attributes.get(ATTR_MEDIA_TITLE) == RECORDING["title"]
- assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) == RECORDING["episodeTitle"]
- assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "{} ({})".format(
- RECORDING["callsign"], RECORDING["major"]
- )
- assert state.attributes.get(ATTR_INPUT_SOURCE) == RECORDING["major"]
- assert (
- state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) == RECORDING["isRecording"]
- )
- assert state.attributes.get(ATTR_MEDIA_RATING) == RECORDING["rating"]
+ assert state.attributes.get(ATTR_MEDIA_DURATION) == 1791
+ assert state.attributes.get(ATTR_MEDIA_POSITION) == 263
+ assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT)
+ assert state.attributes.get(ATTR_MEDIA_TITLE) == "Tyler's Ultimate"
+ assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) == "Spaghetti and Clam Sauce"
+ assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "{} ({})".format("FOODHD", "231")
+ assert state.attributes.get(ATTR_INPUT_SOURCE) == "231"
+ assert not state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING)
+ assert state.attributes.get(ATTR_MEDIA_RATING) == "No Rating"
assert state.attributes.get(ATTR_MEDIA_RECORDED)
assert state.attributes.get(ATTR_MEDIA_START_TIME) == datetime(
- 2018, 11, 10, 19, 0, tzinfo=dt_util.UTC
+ 2010, 7, 5, 15, 0, 8, tzinfo=dt_util.UTC
)
+ state = hass.states.get(UNAVAILABLE_ENTITY_ID)
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_attributes_paused(
+ hass: HomeAssistantType,
+ mock_now: dt_util.dt.datetime,
+ aioclient_mock: AiohttpClientMocker,
+):
+ """Test attributes while paused."""
+ await setup_integration(hass, aioclient_mock)
+
+ state = hass.states.get(CLIENT_ENTITY_ID)
+ last_updated = state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT)
+
# Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not
# updated if TV is paused.
with patch(
- "homeassistant.util.dt.utcnow", return_value=next_update + timedelta(minutes=5)
+ "homeassistant.util.dt.utcnow", return_value=mock_now + timedelta(minutes=5)
):
await async_media_pause(hass, CLIENT_ENTITY_ID)
await hass.async_block_till_done()
state = hass.states.get(CLIENT_ENTITY_ID)
assert state.state == STATE_PAUSED
- assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) == next_update
+ assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) == last_updated
async def test_main_services(
- hass: HomeAssistantType, mock_now: dt_util.dt.datetime
+ hass: HomeAssistantType,
+ mock_now: dt_util.dt.datetime,
+ aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test the different services."""
- await setup_directv(hass)
+ await setup_integration(hass, aioclient_mock)
- next_update = mock_now + timedelta(minutes=5)
- with patch("homeassistant.util.dt.utcnow", return_value=next_update):
- async_fire_time_changed(hass, next_update)
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_turn_off(hass, MAIN_ENTITY_ID)
await hass.async_block_till_done()
- # DVR starts in off state.
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_OFF
+ remote_mock.assert_called_once_with("poweroff", "0")
- # Turn main DVR on. When turning on DVR is playing.
- await async_turn_on(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_PLAYING
-
- # Pause live TV.
- await async_media_pause(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_PAUSED
-
- # Start play again for live TV.
- await async_media_play(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_PLAYING
-
- # Change channel, currently it should be 202
- assert state.attributes.get("source") == 202
- await async_play_media(hass, "channel", 7, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.attributes.get("source") == 7
-
- # Stop live TV.
- await async_media_stop(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_PAUSED
-
- # Turn main DVR off.
- await async_turn_off(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_OFF
-
-
-async def test_available(
- hass: HomeAssistantType, mock_now: dt_util.dt.datetime
-) -> None:
- """Test available status."""
- entry = await setup_directv(hass)
-
- next_update = mock_now + timedelta(minutes=5)
- with patch("homeassistant.util.dt.utcnow", return_value=next_update):
- async_fire_time_changed(hass, next_update)
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_turn_on(hass, MAIN_ENTITY_ID)
await hass.async_block_till_done()
+ remote_mock.assert_called_once_with("poweron", "0")
- # Confirm service is currently set to available.
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state != STATE_UNAVAILABLE
-
- assert hass.data[DOMAIN]
- assert hass.data[DOMAIN][entry.entry_id]
- assert hass.data[DOMAIN][entry.entry_id]["client"]
-
- main_dtv = hass.data[DOMAIN][entry.entry_id]["client"]
-
- # Make update fail 1st time
- next_update = next_update + timedelta(minutes=5)
- with patch.object(main_dtv, "get_standby", side_effect=RequestException), patch(
- "homeassistant.util.dt.utcnow", return_value=next_update
- ):
- async_fire_time_changed(hass, next_update)
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_media_pause(hass, MAIN_ENTITY_ID)
await hass.async_block_till_done()
+ remote_mock.assert_called_once_with("pause", "0")
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state != STATE_UNAVAILABLE
-
- # Make update fail 2nd time within 1 minute
- next_update = next_update + timedelta(seconds=30)
- with patch.object(main_dtv, "get_standby", side_effect=RequestException), patch(
- "homeassistant.util.dt.utcnow", return_value=next_update
- ):
- async_fire_time_changed(hass, next_update)
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_media_play(hass, MAIN_ENTITY_ID)
await hass.async_block_till_done()
+ remote_mock.assert_called_once_with("play", "0")
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state != STATE_UNAVAILABLE
-
- # Make update fail 3rd time more then a minute after 1st failure
- next_update = next_update + timedelta(minutes=1)
- with patch.object(main_dtv, "get_standby", side_effect=RequestException), patch(
- "homeassistant.util.dt.utcnow", return_value=next_update
- ):
- async_fire_time_changed(hass, next_update)
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_media_next_track(hass, MAIN_ENTITY_ID)
await hass.async_block_till_done()
+ remote_mock.assert_called_once_with("ffwd", "0")
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_UNAVAILABLE
-
- # Recheck state, update should work again.
- next_update = next_update + timedelta(minutes=5)
- with patch("homeassistant.util.dt.utcnow", return_value=next_update):
- async_fire_time_changed(hass, next_update)
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_media_previous_track(hass, MAIN_ENTITY_ID)
await hass.async_block_till_done()
+ remote_mock.assert_called_once_with("rew", "0")
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state != STATE_UNAVAILABLE
+ with patch("directv.DIRECTV.remote") as remote_mock:
+ await async_media_stop(hass, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ remote_mock.assert_called_once_with("stop", "0")
+
+ with patch("directv.DIRECTV.tune") as tune_mock:
+ await async_play_media(hass, "channel", 312, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ tune_mock.assert_called_once_with("312", "0")
diff --git a/tests/components/doorbird/__init__.py b/tests/components/doorbird/__init__.py
new file mode 100644
index 00000000000..57bf4c04e39
--- /dev/null
+++ b/tests/components/doorbird/__init__.py
@@ -0,0 +1 @@
+"""Tests for the DoorBird integration."""
diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py
new file mode 100644
index 00000000000..f911787c1c3
--- /dev/null
+++ b/tests/components/doorbird/test_config_flow.py
@@ -0,0 +1,293 @@
+"""Test the DoorBird config flow."""
+import urllib
+
+from asynctest import MagicMock, patch
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.doorbird import CONF_CUSTOM_URL, CONF_TOKEN
+from homeassistant.components.doorbird.const import CONF_EVENTS, DOMAIN
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+
+from tests.common import MockConfigEntry, init_recorder_component
+
+VALID_CONFIG = {
+ CONF_HOST: "1.2.3.4",
+ CONF_USERNAME: "friend",
+ CONF_PASSWORD: "password",
+ CONF_NAME: "mydoorbird",
+}
+
+
+def _get_mock_doorbirdapi_return_values(ready=None, info=None):
+ doorbirdapi_mock = MagicMock()
+ type(doorbirdapi_mock).ready = MagicMock(return_value=ready)
+ type(doorbirdapi_mock).info = MagicMock(return_value=info)
+
+ return doorbirdapi_mock
+
+
+def _get_mock_doorbirdapi_side_effects(ready=None, info=None):
+ doorbirdapi_mock = MagicMock()
+ type(doorbirdapi_mock).ready = MagicMock(side_effect=ready)
+ type(doorbirdapi_mock).info = MagicMock(side_effect=info)
+
+ return doorbirdapi_mock
+
+
+async def test_user_form(hass):
+ """Test we get the user form."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ 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"] == {}
+
+ doorbirdapi = _get_mock_doorbirdapi_return_values(
+ ready=[True], info={"WIFI_MAC_ADDR": "macaddr"}
+ )
+ with patch(
+ "homeassistant.components.doorbird.config_flow.DoorBird",
+ return_value=doorbirdapi,
+ ), patch(
+ "homeassistant.components.doorbird.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.doorbird.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], VALID_CONFIG,
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "1.2.3.4"
+ assert result2["data"] == {
+ "host": "1.2.3.4",
+ "name": "mydoorbird",
+ "password": "password",
+ "username": "friend",
+ }
+ 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_import(hass):
+ """Test we get the form with import source."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ import_config = VALID_CONFIG.copy()
+ import_config[CONF_EVENTS] = ["event1", "event2", "event3"]
+ import_config[CONF_TOKEN] = "imported_token"
+ import_config[
+ CONF_CUSTOM_URL
+ ] = "http://legacy.custom.url/should/only/come/in/from/yaml"
+
+ doorbirdapi = _get_mock_doorbirdapi_return_values(
+ ready=[True], info={"WIFI_MAC_ADDR": "macaddr"}
+ )
+ with patch(
+ "homeassistant.components.doorbird.config_flow.DoorBird",
+ return_value=doorbirdapi,
+ ), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
+ "homeassistant.components.doorbird.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.doorbird.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=import_config,
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "1.2.3.4"
+ assert result["data"] == {
+ "host": "1.2.3.4",
+ "name": "mydoorbird",
+ "password": "password",
+ "username": "friend",
+ "events": ["event1", "event2", "event3"],
+ "token": "imported_token",
+ # This will go away once we convert to cloud hooks
+ "hass_url_override": "http://legacy.custom.url/should/only/come/in/from/yaml",
+ }
+ # It is not possible to import options at this time
+ # so they end up in the config entry data and are
+ # used a fallback when they are not in options
+ 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_zeroconf_wrong_oui(hass):
+ """Test we abort when we get the wrong OUI via zeroconf."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data={
+ "properties": {"macaddress": "notdoorbirdoui"},
+ "host": "192.168.1.8",
+ "name": "Doorstation - abc123._axis-video._tcp.local.",
+ },
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_doorbird_device"
+
+
+async def test_form_zeroconf_link_local_ignored(hass):
+ """Test we abort when we get a link local address via zeroconf."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data={
+ "properties": {"macaddress": "1CCAE3DOORBIRD"},
+ "host": "169.254.103.61",
+ "name": "Doorstation - abc123._axis-video._tcp.local.",
+ },
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "link_local_address"
+
+
+async def test_form_zeroconf_correct_oui(hass):
+ """Test we can setup from zeroconf with the correct OUI source."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data={
+ "properties": {"macaddress": "1CCAE3DOORBIRD"},
+ "name": "Doorstation - abc123._axis-video._tcp.local.",
+ "host": "192.168.1.5",
+ },
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {}
+ doorbirdapi = _get_mock_doorbirdapi_return_values(
+ ready=[True], info={"WIFI_MAC_ADDR": "macaddr"}
+ )
+ with patch(
+ "homeassistant.components.doorbird.config_flow.DoorBird",
+ return_value=doorbirdapi,
+ ), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
+ "homeassistant.components.doorbird.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.doorbird.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], VALID_CONFIG
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "1.2.3.4"
+ assert result2["data"] == {
+ "host": "1.2.3.4",
+ "name": "mydoorbird",
+ "password": "password",
+ "username": "friend",
+ }
+ 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_user_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=OSError)
+ with patch(
+ "homeassistant.components.doorbird.config_flow.DoorBird",
+ return_value=doorbirdapi,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], VALID_CONFIG,
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_user_invalid_auth(hass):
+ """Test we handle cannot invalid auth error."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ mock_urllib_error = urllib.error.HTTPError(
+ "http://xyz.tld", 401, "login failed", {}, None
+ )
+ doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=mock_urllib_error)
+ with patch(
+ "homeassistant.components.doorbird.config_flow.DoorBird",
+ return_value=doorbirdapi,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], VALID_CONFIG,
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_options_flow(hass):
+ """Test config flow options."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="abcde12345",
+ data=VALID_CONFIG,
+ options={CONF_EVENTS: ["event1", "event2", "event3"]},
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.doorbird.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_EVENTS: "eventa, eventc, eventq"}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options == {CONF_EVENTS: ["eventa", "eventc", "eventq"]}
diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py
index ee6baaa7561..97759e96b69 100755
--- a/tests/components/dynalite/test_bridge.py
+++ b/tests/components/dynalite/test_bridge.py
@@ -55,8 +55,11 @@ async def test_add_devices_then_register(hass):
device1 = Mock()
device1.category = "light"
device1.name = "NAME"
+ device1.unique_id = "unique1"
device2 = Mock()
device2.category = "switch"
+ device2.name = "NAME2"
+ device2.unique_id = "unique2"
new_device_func([device1, device2])
await hass.async_block_till_done()
assert hass.states.get("light.name")
@@ -78,8 +81,11 @@ async def test_register_then_add_devices(hass):
device1 = Mock()
device1.category = "light"
device1.name = "NAME"
+ device1.unique_id = "unique1"
device2 = Mock()
device2.category = "switch"
+ device2.name = "NAME2"
+ device2.unique_id = "unique2"
new_device_func([device1, device2])
await hass.async_block_till_done()
assert hass.states.get("light.name")
diff --git a/tests/components/elkm1/__init__.py b/tests/components/elkm1/__init__.py
new file mode 100644
index 00000000000..8ae7f6d7b49
--- /dev/null
+++ b/tests/components/elkm1/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Elk-M1 Control integration."""
diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py
new file mode 100644
index 00000000000..02e3fd7fce9
--- /dev/null
+++ b/tests/components/elkm1/test_config_flow.py
@@ -0,0 +1,269 @@
+"""Test the Elk-M1 Control config flow."""
+
+from asynctest import CoroutineMock, MagicMock, PropertyMock, patch
+
+from homeassistant import config_entries, setup
+from homeassistant.components.elkm1.const import DOMAIN
+
+
+def mock_elk(invalid_auth=None, sync_complete=None):
+ """Mock m1lib Elk."""
+ mocked_elk = MagicMock()
+ type(mocked_elk).invalid_auth = PropertyMock(return_value=invalid_auth)
+ type(mocked_elk).sync_complete = CoroutineMock()
+ return mocked_elk
+
+
+async def test_form_user_with_secure_elk(hass):
+ """Test we can setup a secure elk."""
+ 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"] == {}
+
+ mocked_elk = mock_elk(invalid_auth=False)
+
+ with patch(
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ ), patch(
+ "homeassistant.components.elkm1.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "protocol": "secure",
+ "address": "1.2.3.4",
+ "username": "test-username",
+ "password": "test-password",
+ "temperature_unit": "F",
+ "prefix": "",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "ElkM1"
+ assert result2["data"] == {
+ "auto_configure": True,
+ "host": "elks://1.2.3.4",
+ "password": "test-password",
+ "prefix": "",
+ "temperature_unit": "F",
+ "username": "test-username",
+ }
+ 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_user_with_non_secure_elk(hass):
+ """Test we can setup a non-secure elk."""
+ 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"] == {}
+
+ mocked_elk = mock_elk(invalid_auth=False)
+
+ with patch(
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ ), patch(
+ "homeassistant.components.elkm1.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "protocol": "non-secure",
+ "address": "1.2.3.4",
+ "temperature_unit": "F",
+ "prefix": "guest_house",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "guest_house"
+ assert result2["data"] == {
+ "auto_configure": True,
+ "host": "elk://1.2.3.4",
+ "prefix": "guest_house",
+ "username": "",
+ "password": "",
+ "temperature_unit": "F",
+ }
+ 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_user_with_serial_elk(hass):
+ """Test we can setup a serial elk."""
+ 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"] == {}
+
+ mocked_elk = mock_elk(invalid_auth=False)
+
+ with patch(
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ ), patch(
+ "homeassistant.components.elkm1.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "protocol": "serial",
+ "address": "/dev/ttyS0:115200",
+ "temperature_unit": "F",
+ "prefix": "",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "ElkM1"
+ assert result2["data"] == {
+ "auto_configure": True,
+ "host": "serial:///dev/ttyS0:115200",
+ "prefix": "",
+ "username": "",
+ "password": "",
+ "temperature_unit": "F",
+ }
+ 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_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ mocked_elk = mock_elk(invalid_auth=False)
+
+ with patch(
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ ), patch(
+ "homeassistant.components.elkm1.config_flow.async_wait_for_elk_to_sync",
+ return_value=False,
+ ): # async_wait_for_elk_to_sync is being patched to avoid making the test wait 45s
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "protocol": "secure",
+ "address": "1.2.3.4",
+ "username": "test-username",
+ "password": "test-password",
+ "temperature_unit": "F",
+ "prefix": "",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ mocked_elk = mock_elk(invalid_auth=True)
+
+ with patch(
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "protocol": "secure",
+ "address": "1.2.3.4",
+ "username": "test-username",
+ "password": "test-password",
+ "temperature_unit": "F",
+ "prefix": "",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_import(hass):
+ """Test we get the form with import source."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ mocked_elk = mock_elk(invalid_auth=False)
+ with patch(
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ ), patch(
+ "homeassistant.components.elkm1.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={
+ "host": "elks://1.2.3.4",
+ "username": "friend",
+ "password": "love",
+ "temperature_unit": "C",
+ "auto_configure": False,
+ "keypad": {
+ "enabled": True,
+ "exclude": [],
+ "include": [[1, 1], [2, 2], [3, 3]],
+ },
+ "output": {"enabled": False, "exclude": [], "include": []},
+ "counter": {"enabled": False, "exclude": [], "include": []},
+ "plc": {"enabled": False, "exclude": [], "include": []},
+ "prefix": "ohana",
+ "setting": {"enabled": False, "exclude": [], "include": []},
+ "area": {"enabled": False, "exclude": [], "include": []},
+ "task": {"enabled": False, "exclude": [], "include": []},
+ "thermostat": {"enabled": False, "exclude": [], "include": []},
+ "zone": {
+ "enabled": True,
+ "exclude": [[15, 15], [28, 208]],
+ "include": [],
+ },
+ },
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "ohana"
+
+ assert result["data"] == {
+ "auto_configure": False,
+ "host": "elks://1.2.3.4",
+ "keypad": {"enabled": True, "exclude": [], "include": [[1, 1], [2, 2], [3, 3]]},
+ "output": {"enabled": False, "exclude": [], "include": []},
+ "password": "love",
+ "plc": {"enabled": False, "exclude": [], "include": []},
+ "prefix": "ohana",
+ "setting": {"enabled": False, "exclude": [], "include": []},
+ "area": {"enabled": False, "exclude": [], "include": []},
+ "counter": {"enabled": False, "exclude": [], "include": []},
+ "task": {"enabled": False, "exclude": [], "include": []},
+ "temperature_unit": "C",
+ "thermostat": {"enabled": False, "exclude": [], "include": []},
+ "username": "friend",
+ "zone": {"enabled": True, "exclude": [[15, 15], [28, 208]], "include": []},
+ }
+ 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/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py
index 30b715c136b..76f1a224c1f 100644
--- a/tests/components/emulated_hue/test_hue_api.py
+++ b/tests/components/emulated_hue/test_hue_api.py
@@ -130,51 +130,9 @@ def hass_hue(loop, hass):
)
)
- # Kitchen light is explicitly excluded from being exposed
- kitchen_light_entity = hass.states.get("light.kitchen_lights")
- attrs = dict(kitchen_light_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE] = False
- hass.states.async_set(
- kitchen_light_entity.entity_id, kitchen_light_entity.state, attributes=attrs
- )
-
# create a lamp without brightness support
hass.states.async_set("light.no_brightness", "on", {})
- # Ceiling Fan is explicitly excluded from being exposed
- ceiling_fan_entity = hass.states.get("fan.ceiling_fan")
- attrs = dict(ceiling_fan_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = True
- hass.states.async_set(
- ceiling_fan_entity.entity_id, ceiling_fan_entity.state, attributes=attrs
- )
-
- # Expose the script
- script_entity = hass.states.get("script.set_kitchen_light")
- attrs = dict(script_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE] = True
- hass.states.async_set(
- script_entity.entity_id, script_entity.state, attributes=attrs
- )
-
- # Expose cover
- cover_entity = hass.states.get("cover.living_room_window")
- attrs = dict(cover_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
- hass.states.async_set(cover_entity.entity_id, cover_entity.state, attributes=attrs)
-
- # Expose Hvac
- hvac_entity = hass.states.get("climate.hvac")
- attrs = dict(hvac_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
- hass.states.async_set(hvac_entity.entity_id, hvac_entity.state, attributes=attrs)
-
- # Expose HeatPump
- hp_entity = hass.states.get("climate.heatpump")
- attrs = dict(hp_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
- hass.states.async_set(hp_entity.entity_id, hp_entity.state, attributes=attrs)
-
return hass
@@ -188,7 +146,18 @@ def hue_client(loop, hass_hue, aiohttp_client):
emulated_hue.CONF_TYPE: emulated_hue.TYPE_ALEXA,
emulated_hue.CONF_ENTITIES: {
"light.bed_light": {emulated_hue.CONF_ENTITY_HIDDEN: True},
+ # Kitchen light is explicitly excluded from being exposed
+ "light.kitchen_lights": {emulated_hue.CONF_ENTITY_HIDDEN: True},
+ # Ceiling Fan is explicitly excluded from being exposed
+ "fan.ceiling_fan": {emulated_hue.CONF_ENTITY_HIDDEN: True},
+ # Expose the script
+ "script.set_kitchen_light": {emulated_hue.CONF_ENTITY_HIDDEN: False},
+ # Expose cover
"cover.living_room_window": {emulated_hue.CONF_ENTITY_HIDDEN: False},
+ # Expose Hvac
+ "climate.hvac": {emulated_hue.CONF_ENTITY_HIDDEN: False},
+ # Expose HeatPump
+ "climate.heatpump": {emulated_hue.CONF_ENTITY_HIDDEN: False},
},
},
)
diff --git a/tests/components/freebox/__init__.py b/tests/components/freebox/__init__.py
new file mode 100644
index 00000000000..727b60ae78a
--- /dev/null
+++ b/tests/components/freebox/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Freebox component."""
diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py
new file mode 100644
index 00000000000..e813469cbbf
--- /dev/null
+++ b/tests/components/freebox/conftest.py
@@ -0,0 +1,11 @@
+"""Test helpers for Freebox."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def mock_path():
+ """Mock path lib."""
+ with patch("homeassistant.components.freebox.router.Path"):
+ yield
diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py
new file mode 100644
index 00000000000..68e787e1ba0
--- /dev/null
+++ b/tests/components/freebox/test_config_flow.py
@@ -0,0 +1,144 @@
+"""Tests for the Freebox config flow."""
+from aiofreepybox.exceptions import (
+ AuthorizationError,
+ HttpRequestError,
+ InvalidTokenError,
+)
+from asynctest import CoroutineMock, patch
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.freebox.const import DOMAIN
+from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import CONF_HOST, CONF_PORT
+
+from tests.common import MockConfigEntry
+
+HOST = "myrouter.freeboxos.fr"
+PORT = 1234
+
+
+@pytest.fixture(name="connect")
+def mock_controller_connect():
+ """Mock a successful connection."""
+ with patch("homeassistant.components.freebox.router.Freepybox") as service_mock:
+ service_mock.return_value.open = CoroutineMock()
+ service_mock.return_value.system.get_config = CoroutineMock()
+ service_mock.return_value.lan.get_hosts_list = CoroutineMock()
+ service_mock.return_value.connection.get_status = CoroutineMock()
+ service_mock.return_value.close = CoroutineMock()
+ yield service_mock
+
+
+async def test_user(hass):
+ """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_HOST: HOST, CONF_PORT: PORT},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "link"
+
+
+async def test_import(hass):
+ """Test import step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "link"
+
+
+async def test_discovery(hass):
+ """Test discovery step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_DISCOVERY},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "link"
+
+
+async def test_link(hass, connect):
+ """Test linking."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["result"].unique_id == HOST
+ assert result["title"] == HOST
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_PORT] == PORT
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if component is already setup."""
+ MockConfigEntry(
+ domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, unique_id=HOST
+ ).add_to_hass(hass)
+
+ # Should fail, same HOST (import)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+ # Should fail, same HOST (flow)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_on_link_failed(hass):
+ """Test when we have errors during linking the router."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+
+ with patch(
+ "homeassistant.components.freebox.router.Freepybox.open",
+ side_effect=AuthorizationError(),
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "register_failed"}
+
+ with patch(
+ "homeassistant.components.freebox.router.Freepybox.open",
+ side_effect=HttpRequestError(),
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "connection_failed"}
+
+ with patch(
+ "homeassistant.components.freebox.router.Freepybox.open",
+ side_effect=InvalidTokenError(),
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "unknown"}
diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py
index c426b081e21..255a2c50946 100644
--- a/tests/components/gdacs/test_geo_location.py
+++ b/tests/components/gdacs/test_geo_location.py
@@ -29,6 +29,7 @@ from homeassistant.const import (
CONF_RADIUS,
EVENT_HOMEASSISTANT_START,
)
+from homeassistant.helpers.entity_registry import async_get_registry
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
@@ -97,6 +98,8 @@ async def test_setup(hass):
all_states = hass.states.async_all()
# 3 geolocation and 1 sensor entities
assert len(all_states) == 4
+ entity_registry = await async_get_registry(hass)
+ assert len(entity_registry.entities) == 4
state = hass.states.get("geo_location.drought_name_1")
assert state is not None
@@ -184,6 +187,7 @@ async def test_setup(hass):
all_states = hass.states.async_all()
assert len(all_states) == 1
+ assert len(entity_registry.entities) == 1
async def test_setup_imperial(hass):
diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py
index 776d8f39f69..264146a6fda 100644
--- a/tests/components/generic_thermostat/test_climate.py
+++ b/tests/components/generic_thermostat/test_climate.py
@@ -22,6 +22,8 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
@@ -271,6 +273,44 @@ async def test_sensor_bad_value(hass, setup_comp_2):
assert temp == state.attributes.get("current_temperature")
+async def test_sensor_unknown(hass):
+ """Test when target sensor is Unknown."""
+ hass.states.async_set("sensor.unknown", STATE_UNKNOWN)
+ assert await async_setup_component(
+ hass,
+ "climate",
+ {
+ "climate": {
+ "platform": "generic_thermostat",
+ "name": "unknown",
+ "heater": ENT_SWITCH,
+ "target_sensor": "sensor.unknown",
+ }
+ },
+ )
+ state = hass.states.get("climate.unknown")
+ assert state.attributes.get("current_temperature") is None
+
+
+async def test_sensor_unavailable(hass):
+ """Test when target sensor is Unavailable."""
+ hass.states.async_set("sensor.unavailable", STATE_UNAVAILABLE)
+ assert await async_setup_component(
+ hass,
+ "climate",
+ {
+ "climate": {
+ "platform": "generic_thermostat",
+ "name": "unavailable",
+ "heater": ENT_SWITCH,
+ "target_sensor": "sensor.unavailable",
+ }
+ },
+ )
+ state = hass.states.get("climate.unavailable")
+ assert state.attributes.get("current_temperature") is None
+
+
async def test_set_target_temp_heater_on(hass, setup_comp_2):
"""Test if target temperature turn heater on."""
calls = _setup_switch(hass, False)
diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py
index c08c15a02f4..42002d62906 100644
--- a/tests/components/google_assistant/test_smart_home.py
+++ b/tests/components/google_assistant/test_smart_home.py
@@ -442,6 +442,9 @@ async def test_execute(hass):
"source": "cloud",
}
+ service_events = sorted(
+ service_events, key=lambda ev: ev.data["service_data"]["entity_id"]
+ )
assert len(service_events) == 4
assert service_events[0].data == {
"domain": "light",
diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py
index 232da039ea7..d0ed9a9d33c 100644
--- a/tests/components/google_assistant/test_trait.py
+++ b/tests/components/google_assistant/test_trait.py
@@ -557,6 +557,32 @@ async def test_temperature_setting_climate_onoff(hass):
assert len(calls) == 1
+async def test_temperature_setting_climate_no_modes(hass):
+ """Test TemperatureSetting trait support for climate domain not supporting any modes."""
+ assert helpers.get_google_type(climate.DOMAIN, None) is not None
+ assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
+
+ hass.config.units.temperature_unit = TEMP_CELSIUS
+
+ trt = trait.TemperatureSettingTrait(
+ hass,
+ State(
+ "climate.bla",
+ climate.HVAC_MODE_AUTO,
+ {
+ climate.ATTR_HVAC_MODES: [],
+ climate.ATTR_MIN_TEMP: None,
+ climate.ATTR_MAX_TEMP: None,
+ },
+ ),
+ BASIC_CONFIG,
+ )
+ assert trt.sync_attributes() == {
+ "availableThermostatModes": "heat",
+ "thermostatTemperatureUnit": "C",
+ }
+
+
async def test_temperature_setting_climate_range(hass):
"""Test TemperatureSetting trait support for climate domain - range."""
assert helpers.get_google_type(climate.DOMAIN, None) is not None
@@ -1506,7 +1532,8 @@ async def test_openclose_cover_no_position(hass):
@pytest.mark.parametrize(
- "device_class", (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE)
+ "device_class",
+ (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_GATE),
)
async def test_openclose_cover_secure(hass, device_class):
"""Test OpenClose trait support for cover domain."""
diff --git a/tests/components/harmony/__init__.py b/tests/components/harmony/__init__.py
new file mode 100644
index 00000000000..f427677b40a
--- /dev/null
+++ b/tests/components/harmony/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Logitech Harmony Hub integration."""
diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py
new file mode 100644
index 00000000000..30421756d22
--- /dev/null
+++ b/tests/components/harmony/test_config_flow.py
@@ -0,0 +1,155 @@
+"""Test the Logitech Harmony Hub config flow."""
+from asynctest import CoroutineMock, MagicMock, patch
+
+from homeassistant import config_entries, setup
+from homeassistant.components.harmony.config_flow import CannotConnect
+from homeassistant.components.harmony.const import DOMAIN
+
+
+def _get_mock_harmonyapi(connect=None, close=None):
+ harmonyapi_mock = MagicMock()
+ type(harmonyapi_mock).connect = CoroutineMock(return_value=connect)
+ type(harmonyapi_mock).close = CoroutineMock(return_value=close)
+
+ return harmonyapi_mock
+
+
+async def test_user_form(hass):
+ """Test we get the user 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"] == {}
+
+ harmonyapi = _get_mock_harmonyapi(connect=True)
+ with patch(
+ "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ ), patch(
+ "homeassistant.components.harmony.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.harmony.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4", "name": "friend"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "friend"
+ assert result2["data"] == {
+ "host": "1.2.3.4",
+ "name": "friend",
+ }
+ 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_import(hass):
+ """Test we get the form with import source."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ harmonyapi = _get_mock_harmonyapi(connect=True)
+ with patch(
+ "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ ), patch(
+ "homeassistant.components.harmony.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.harmony.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={
+ "host": "1.2.3.4",
+ "name": "friend",
+ "activity": "Watch TV",
+ "delay_secs": 0.9,
+ "unique_id": "555234534543",
+ },
+ )
+
+ assert result["result"].unique_id == "555234534543"
+ assert result["type"] == "create_entry"
+ assert result["title"] == "friend"
+ assert result["data"] == {
+ "host": "1.2.3.4",
+ "name": "friend",
+ "activity": "Watch TV",
+ "delay_secs": 0.9,
+ }
+ # It is not possible to import options at this time
+ # so they end up in the config entry data and are
+ # used a fallback when they are not in options
+ 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_ssdp(hass):
+ """Test we get the form with ssdp source."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ harmonyapi = _get_mock_harmonyapi(connect=True)
+
+ with patch(
+ "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_SSDP},
+ data={
+ "friendlyName": "Harmony Hub",
+ "ssdp_location": "http://192.168.1.12:8088/description",
+ },
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "link"
+ assert result["errors"] == {}
+ assert result["description_placeholders"] == {
+ "host": "Harmony Hub",
+ "name": "192.168.1.12",
+ }
+
+ with patch(
+ "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ ), patch(
+ "homeassistant.components.harmony.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.harmony.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Harmony Hub"
+ assert result2["data"] == {
+ "host": "192.168.1.12",
+ "name": "Harmony Hub",
+ }
+ 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_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.harmony.util.HarmonyAPI", side_effect=CannotConnect,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.2.3.4",
+ "name": "friend",
+ "activity": "Watch TV",
+ "delay_secs": 0.2,
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py
index fcae8bd1f8c..642b774f1e5 100644
--- a/tests/components/here_travel_time/test_sensor.py
+++ b/tests/components/here_travel_time/test_sensor.py
@@ -37,6 +37,7 @@ from homeassistant.components.here_travel_time.sensor import (
TRAVEL_MODE_PUBLIC,
TRAVEL_MODE_PUBLIC_TIME_TABLE,
TRAVEL_MODE_TRUCK,
+ convert_time_to_isodate,
)
from homeassistant.const import ATTR_ICON, EVENT_HOMEASSISTANT_START
from homeassistant.setup import async_setup_component
@@ -66,7 +67,7 @@ CAR_DESTINATION_LATITUDE = "39.0"
CAR_DESTINATION_LONGITUDE = "-77.1"
-def _build_mock_url(origin, destination, modes, api_key, departure):
+def _build_mock_url(origin, destination, modes, api_key, departure=None, arrival=None):
"""Construct a url for HERE."""
base_url = "https://route.ls.hereapi.com/routing/7.2/calculateroute.json?"
parameters = {
@@ -74,9 +75,13 @@ def _build_mock_url(origin, destination, modes, api_key, departure):
"waypoint1": f"geo!{destination}",
"mode": ";".join(str(herepy.RouteMode[mode]) for mode in modes),
"apikey": api_key,
- "departure": departure,
}
+ if arrival is not None:
+ parameters["arrival"] = arrival
+ if departure is not None:
+ parameters["departure"] = departure
url = base_url + urllib.parse.urlencode(parameters)
+ print(url)
return url
@@ -117,7 +122,6 @@ def requests_mock_credentials_check(requests_mock):
",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]),
modes,
API_KEY,
- "now",
)
requests_mock.get(
response_url, text=load_fixture("here_travel_time/car_response.json")
@@ -134,7 +138,6 @@ def requests_mock_truck_response(requests_mock_credentials_check):
",".join([TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE]),
modes,
API_KEY,
- "now",
)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/truck_response.json")
@@ -150,7 +153,6 @@ def requests_mock_car_disabled_response(requests_mock_credentials_check):
",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]),
modes,
API_KEY,
- "now",
)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/car_response.json")
@@ -214,7 +216,6 @@ async def test_traffic_mode_enabled(hass, requests_mock_credentials_check):
",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]),
modes,
API_KEY,
- "now",
)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/car_enabled_response.json")
@@ -272,7 +273,7 @@ async def test_route_mode_shortest(hass, requests_mock_credentials_check):
origin = "38.902981,-77.048338"
destination = "39.042158,-77.119116"
modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/car_shortest_response.json")
)
@@ -303,7 +304,7 @@ async def test_route_mode_fastest(hass, requests_mock_credentials_check):
origin = "38.902981,-77.048338"
destination = "39.042158,-77.119116"
modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_ENABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/car_enabled_response.json")
)
@@ -357,7 +358,7 @@ async def test_public_transport(hass, requests_mock_credentials_check):
origin = "41.9798,-87.8801"
destination = "41.9043,-87.9216"
modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC, TRAFFIC_MODE_DISABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/public_response.json")
)
@@ -406,7 +407,7 @@ async def test_public_transport_time_table(hass, requests_mock_credentials_check
origin = "41.9798,-87.8801"
destination = "41.9043,-87.9216"
modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url,
text=load_fixture("here_travel_time/public_time_table_response.json"),
@@ -456,7 +457,7 @@ async def test_pedestrian(hass, requests_mock_credentials_check):
origin = "41.9798,-87.8801"
destination = "41.9043,-87.9216"
modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PEDESTRIAN, TRAFFIC_MODE_DISABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/pedestrian_response.json")
)
@@ -508,7 +509,7 @@ async def test_bicycle(hass, requests_mock_credentials_check):
origin = "41.9798,-87.8801"
destination = "41.9043,-87.9216"
modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, TRAFFIC_MODE_DISABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/bike_response.json")
)
@@ -841,7 +842,7 @@ async def test_route_not_found(hass, requests_mock_credentials_check, caplog):
origin = "52.516,13.3779"
destination = "47.013399,-10.171986"
modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url,
text=load_fixture("here_travel_time/routing_error_no_route_found.json"),
@@ -914,7 +915,6 @@ async def test_invalid_credentials(hass, requests_mock, caplog):
",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]),
modes,
API_KEY,
- "now",
)
requests_mock.get(
response_url,
@@ -942,7 +942,7 @@ async def test_attribution(hass, requests_mock_credentials_check):
origin = "50.037751372637686,14.39233448220898"
destination = "50.07993838201255,14.42582157361062"
modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_ENABLED]
- response_url = _build_mock_url(origin, destination, modes, API_KEY, "now")
+ response_url = _build_mock_url(origin, destination, modes, API_KEY)
requests_mock_credentials_check.get(
response_url, text=load_fixture("here_travel_time/attribution_response.json")
)
@@ -1051,3 +1051,123 @@ async def test_delayed_update(hass, requests_mock_truck_response, caplog):
await hass.async_block_till_done()
assert "Unable to find entity" not in caplog.text
+
+
+async def test_arrival(hass, requests_mock_credentials_check):
+ """Test that arrival works."""
+ origin = "41.9798,-87.8801"
+ destination = "41.9043,-87.9216"
+ arrival = "01:00:00"
+ arrival_isodate = convert_time_to_isodate(arrival)
+ modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED]
+ response_url = _build_mock_url(
+ origin, destination, modes, API_KEY, arrival=arrival_isodate
+ )
+ requests_mock_credentials_check.get(
+ response_url,
+ text=load_fixture("here_travel_time/public_time_table_response.json"),
+ )
+
+ config = {
+ DOMAIN: {
+ "platform": PLATFORM,
+ "name": "test",
+ "origin_latitude": origin.split(",")[0],
+ "origin_longitude": origin.split(",")[1],
+ "destination_latitude": destination.split(",")[0],
+ "destination_longitude": destination.split(",")[1],
+ "api_key": API_KEY,
+ "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE,
+ "arrival": arrival,
+ }
+ }
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ sensor = hass.states.get("sensor.test")
+ assert sensor.state == "80"
+
+
+async def test_departure(hass, requests_mock_credentials_check):
+ """Test that arrival works."""
+ origin = "41.9798,-87.8801"
+ destination = "41.9043,-87.9216"
+ departure = "23:00:00"
+ departure_isodate = convert_time_to_isodate(departure)
+ modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED]
+ response_url = _build_mock_url(
+ origin, destination, modes, API_KEY, departure=departure_isodate
+ )
+ requests_mock_credentials_check.get(
+ response_url,
+ text=load_fixture("here_travel_time/public_time_table_response.json"),
+ )
+
+ config = {
+ DOMAIN: {
+ "platform": PLATFORM,
+ "name": "test",
+ "origin_latitude": origin.split(",")[0],
+ "origin_longitude": origin.split(",")[1],
+ "destination_latitude": destination.split(",")[0],
+ "destination_longitude": destination.split(",")[1],
+ "api_key": API_KEY,
+ "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE,
+ "departure": departure,
+ }
+ }
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ sensor = hass.states.get("sensor.test")
+ assert sensor.state == "80"
+
+
+async def test_arrival_only_allowed_for_timetable(hass, caplog):
+ """Test that arrival is only allowed when mode is publicTransportTimeTable."""
+ caplog.set_level(logging.ERROR)
+ origin = "41.9798,-87.8801"
+ destination = "41.9043,-87.9216"
+ config = {
+ DOMAIN: {
+ "platform": PLATFORM,
+ "name": "test",
+ "origin_latitude": origin.split(",")[0],
+ "origin_longitude": origin.split(",")[1],
+ "destination_latitude": destination.split(",")[0],
+ "destination_longitude": destination.split(",")[1],
+ "api_key": API_KEY,
+ "arrival": "01:00:00",
+ }
+ }
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert len(caplog.records) == 1
+ assert "[arrival] is an invalid option" in caplog.text
+
+
+async def test_exclusive_arrival_and_departure(hass, caplog):
+ """Test that arrival and departure are exclusive."""
+ caplog.set_level(logging.ERROR)
+ origin = "41.9798,-87.8801"
+ destination = "41.9043,-87.9216"
+ config = {
+ DOMAIN: {
+ "platform": PLATFORM,
+ "name": "test",
+ "origin_latitude": origin.split(",")[0],
+ "origin_longitude": origin.split(",")[1],
+ "destination_latitude": destination.split(",")[0],
+ "destination_longitude": destination.split(",")[1],
+ "api_key": API_KEY,
+ "arrival": "01:00:00",
+ "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE,
+ "departure": "01:00:00",
+ }
+ }
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert len(caplog.records) == 1
+ assert "two or more values in the same group of exclusion" in caplog.text
diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py
index 65c0a717bee..64b438a29fc 100644
--- a/tests/components/history/test_init.py
+++ b/tests/components/history/test_init.py
@@ -522,6 +522,62 @@ class TestComponentHistory(unittest.TestCase):
)
assert list(hist.keys()) == entity_ids
+ def test_get_significant_states_only(self):
+ """Test significant states when significant_states_only is set."""
+ self.init_recorder()
+ entity_id = "sensor.test"
+
+ def set_state(state, **kwargs):
+ """Set the state."""
+ self.hass.states.set(entity_id, state, **kwargs)
+ wait_recording_done(self.hass)
+ return self.hass.states.get(entity_id)
+
+ start = dt_util.utcnow() - timedelta(minutes=4)
+ points = []
+ for i in range(1, 4):
+ points.append(start + timedelta(minutes=i))
+
+ states = []
+ with patch(
+ "homeassistant.components.recorder.dt_util.utcnow", return_value=start
+ ):
+ set_state("123", attributes={"attribute": 10.64})
+
+ with patch(
+ "homeassistant.components.recorder.dt_util.utcnow", return_value=points[0]
+ ):
+ # Attributes are different, state not
+ states.append(set_state("123", attributes={"attribute": 21.42}))
+
+ with patch(
+ "homeassistant.components.recorder.dt_util.utcnow", return_value=points[1]
+ ):
+ # state is different, attributes not
+ states.append(set_state("32", attributes={"attribute": 21.42}))
+
+ with patch(
+ "homeassistant.components.recorder.dt_util.utcnow", return_value=points[2]
+ ):
+ # everything is different
+ states.append(set_state("412", attributes={"attribute": 54.23}))
+
+ hist = history.get_significant_states(
+ self.hass, start, significant_changes_only=True
+ )
+
+ assert len(hist[entity_id]) == 2
+ assert states[0] not in hist[entity_id]
+ assert states[1] in hist[entity_id]
+ assert states[2] in hist[entity_id]
+
+ hist = history.get_significant_states(
+ self.hass, start, significant_changes_only=False
+ )
+
+ assert len(hist[entity_id]) == 3
+ assert states == hist[entity_id]
+
def check_significant_states(self, zero, four, states, config):
"""Check if significant states are retrieved."""
filters = history.Filters()
@@ -617,7 +673,7 @@ class TestComponentHistory(unittest.TestCase):
async def test_fetch_period_api(hass, hass_client):
"""Test the fetch period view for history."""
- await hass.async_add_job(init_recorder_component, hass)
+ await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "history", {})
await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
client = await hass_client()
@@ -629,7 +685,7 @@ async def test_fetch_period_api(hass, hass_client):
async def test_fetch_period_api_with_include_order(hass, hass_client):
"""Test the fetch period view for history."""
- await hass.async_add_job(init_recorder_component, hass)
+ await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(
hass,
"history",
diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py
index 87d4fbdcc2b..eb7429aa47e 100644
--- a/tests/components/homekit/test_type_covers.py
+++ b/tests/components/homekit/test_type_covers.py
@@ -5,8 +5,11 @@ import pytest
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
+ ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
+ ATTR_TILT_POSITION,
DOMAIN,
+ SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
)
from homeassistant.components.homekit.const import ATTR_VALUE
@@ -14,6 +17,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
EVENT_HOMEASSISTANT_START,
+ SERVICE_SET_COVER_TILT_POSITION,
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
@@ -193,6 +197,72 @@ async def test_window_set_cover_position(hass, hk_driver, cls, events):
assert events[-1].data[ATTR_VALUE] == 75
+async def test_window_cover_set_tilt(hass, hk_driver, cls, events):
+ """Test if accessory and HA update slat tilt accordingly."""
+ entity_id = "cover.window"
+
+ hass.states.async_set(
+ entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_TILT_POSITION}
+ )
+ await hass.async_block_till_done()
+ acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 14 # CATEGORY_WINDOW_COVERING
+
+ assert acc.char_current_tilt.value == 0
+ assert acc.char_target_tilt.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: None})
+ await hass.async_block_till_done()
+ assert acc.char_current_tilt.value == 0
+ assert acc.char_target_tilt.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 100})
+ await hass.async_block_till_done()
+ assert acc.char_current_tilt.value == 90
+ assert acc.char_target_tilt.value == 90
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 50})
+ await hass.async_block_till_done()
+ assert acc.char_current_tilt.value == 0
+ assert acc.char_target_tilt.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 0})
+ await hass.async_block_till_done()
+ assert acc.char_current_tilt.value == -90
+ assert acc.char_target_tilt.value == -90
+
+ # set from HomeKit
+ call_set_tilt_position = async_mock_service(
+ hass, DOMAIN, SERVICE_SET_COVER_TILT_POSITION
+ )
+
+ # HomeKit sets tilts between -90 and 90 (degrees), whereas
+ # Homeassistant expects a % between 0 and 100. Keep that in mind
+ # when comparing
+ await hass.async_add_job(acc.char_target_tilt.client_update_value, 90)
+ await hass.async_block_till_done()
+ assert call_set_tilt_position[0]
+ assert call_set_tilt_position[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_tilt_position[0].data[ATTR_TILT_POSITION] == 100
+ assert acc.char_current_tilt.value == -90
+ assert acc.char_target_tilt.value == 90
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 100
+
+ await hass.async_add_job(acc.char_target_tilt.client_update_value, 45)
+ await hass.async_block_till_done()
+ assert call_set_tilt_position[1]
+ assert call_set_tilt_position[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_tilt_position[1].data[ATTR_TILT_POSITION] == 75
+ assert acc.char_current_tilt.value == -90
+ assert acc.char_target_tilt.value == 45
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == 75
+
+
async def test_window_open_close(hass, hk_driver, cls, events):
"""Test if accessory and HA are updated accordingly."""
entity_id = "cover.window"
diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py
index 8834f730bce..3ee2e61cc72 100644
--- a/tests/components/homekit/test_type_lights.py
+++ b/tests/components/homekit/test_type_lights.py
@@ -1,6 +1,9 @@
"""Test different accessory types: Lights."""
from collections import namedtuple
+from asynctest import patch
+from pyhap.accessory_driver import AccessoryDriver
+from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE
import pytest
from homeassistant.components.homekit.const import ATTR_VALUE
@@ -30,6 +33,15 @@ from tests.common import async_mock_service
from tests.components.homekit.common import patch_debounce
+@pytest.fixture
+def driver():
+ """Patch AccessoryDriver without zeroconf or HAPServer."""
+ with patch("pyhap.accessory_driver.HAPServer"), patch(
+ "pyhap.accessory_driver.Zeroconf"
+ ), patch("pyhap.accessory_driver.AccessoryDriver.persist"):
+ yield AccessoryDriver()
+
+
@pytest.fixture(scope="module")
def cls():
"""Patch debounce decorator during import of type_lights."""
@@ -43,15 +55,16 @@ def cls():
patcher.stop()
-async def test_light_basic(hass, hk_driver, cls, events):
+async def test_light_basic(hass, hk_driver, cls, events, driver):
"""Test light with char state."""
entity_id = "light.demo"
hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0})
await hass.async_block_till_done()
- acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None)
+ acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None)
+ driver.add_accessory(acc)
- assert acc.aid == 2
+ assert acc.aid == 1
assert acc.category == 5 # Lightbulb
assert acc.char_on.value == 0
@@ -75,25 +88,43 @@ async def test_light_basic(hass, hk_driver, cls, events):
call_turn_on = async_mock_service(hass, DOMAIN, "turn_on")
call_turn_off = async_mock_service(hass, DOMAIN, "turn_off")
+ char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID]
+
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}
+ ]
+ },
+ "mock_addr",
+ )
+
await hass.async_add_job(acc.char_on.client_update_value, 1)
await hass.async_block_till_done()
assert call_turn_on
assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
assert len(events) == 1
- assert events[-1].data[ATTR_VALUE] is None
+ assert events[-1].data[ATTR_VALUE] == "Set state to 1"
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
- await hass.async_add_job(acc.char_on.client_update_value, 0)
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 0}
+ ]
+ },
+ "mock_addr",
+ )
await hass.async_block_till_done()
assert call_turn_off
assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
assert len(events) == 2
- assert events[-1].data[ATTR_VALUE] is None
+ assert events[-1].data[ATTR_VALUE] == "Set state to 0"
-async def test_light_brightness(hass, hk_driver, cls, events):
+async def test_light_brightness(hass, hk_driver, cls, events, driver):
"""Test light with brightness."""
entity_id = "light.demo"
@@ -103,11 +134,14 @@ async def test_light_brightness(hass, hk_driver, cls, events):
{ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255},
)
await hass.async_block_till_done()
- acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None)
+ acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None)
+ driver.add_accessory(acc)
# Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the
# brightness to 100 when turning on a light on a freshly booted up server.
assert acc.char_brightness.value != 0
+ char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID]
+ char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID]
await hass.async_add_job(acc.run)
await hass.async_block_till_done()
@@ -121,34 +155,99 @@ async def test_light_brightness(hass, hk_driver, cls, events):
call_turn_on = async_mock_service(hass, DOMAIN, "turn_on")
call_turn_off = async_mock_service(hass, DOMAIN, "turn_off")
- await hass.async_add_job(acc.char_brightness.client_update_value, 20)
- await hass.async_add_job(acc.char_on.client_update_value, 1)
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1},
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_brightness_iid,
+ HAP_REPR_VALUE: 20,
+ },
+ ]
+ },
+ "mock_addr",
+ )
await hass.async_block_till_done()
assert call_turn_on[0]
assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20
assert len(events) == 1
- assert events[-1].data[ATTR_VALUE] == f"brightness at 20{UNIT_PERCENTAGE}"
+ assert (
+ events[-1].data[ATTR_VALUE]
+ == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}"
+ )
- await hass.async_add_job(acc.char_on.client_update_value, 1)
- await hass.async_add_job(acc.char_brightness.client_update_value, 40)
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1},
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_brightness_iid,
+ HAP_REPR_VALUE: 40,
+ },
+ ]
+ },
+ "mock_addr",
+ )
await hass.async_block_till_done()
assert call_turn_on[1]
assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id
assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40
assert len(events) == 2
- assert events[-1].data[ATTR_VALUE] == f"brightness at 40{UNIT_PERCENTAGE}"
+ assert (
+ events[-1].data[ATTR_VALUE]
+ == f"Set state to 1, brightness at 40{UNIT_PERCENTAGE}"
+ )
- await hass.async_add_job(acc.char_on.client_update_value, 1)
- await hass.async_add_job(acc.char_brightness.client_update_value, 0)
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1},
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_brightness_iid,
+ HAP_REPR_VALUE: 0,
+ },
+ ]
+ },
+ "mock_addr",
+ )
await hass.async_block_till_done()
assert call_turn_off
assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
assert len(events) == 3
- assert events[-1].data[ATTR_VALUE] is None
+ assert (
+ events[-1].data[ATTR_VALUE]
+ == f"Set state to 0, brightness at 0{UNIT_PERCENTAGE}"
+ )
+
+ # 0 is a special case for homekit, see "Handle Brightness"
+ # in update_state
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 1
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 100
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 1
+
+ # Ensure floats are handled
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 22
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 43
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 1
-async def test_light_color_temperature(hass, hk_driver, cls, events):
+async def test_light_color_temperature(hass, hk_driver, cls, events, driver):
"""Test light with color temperature."""
entity_id = "light.demo"
@@ -158,7 +257,8 @@ async def test_light_color_temperature(hass, hk_driver, cls, events):
{ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP: 190},
)
await hass.async_block_till_done()
- acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None)
+ acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None)
+ driver.add_accessory(acc)
assert acc.char_color_temperature.value == 153
@@ -169,6 +269,20 @@ async def test_light_color_temperature(hass, hk_driver, cls, events):
# Set from HomeKit
call_turn_on = async_mock_service(hass, DOMAIN, "turn_on")
+ char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID]
+
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_color_temperature_iid,
+ HAP_REPR_VALUE: 250,
+ }
+ ]
+ },
+ "mock_addr",
+ )
await hass.async_add_job(acc.char_color_temperature.client_update_value, 250)
await hass.async_block_till_done()
assert call_turn_on
@@ -197,7 +311,7 @@ async def test_light_color_temperature_and_rgb_color(hass, hk_driver, cls, event
assert not hasattr(acc, "char_color_temperature")
-async def test_light_rgb_color(hass, hk_driver, cls, events):
+async def test_light_rgb_color(hass, hk_driver, cls, events, driver):
"""Test light with rgb_color."""
entity_id = "light.demo"
@@ -207,7 +321,8 @@ async def test_light_rgb_color(hass, hk_driver, cls, events):
{ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, ATTR_HS_COLOR: (260, 90)},
)
await hass.async_block_till_done()
- acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None)
+ acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None)
+ driver.add_accessory(acc)
assert acc.char_hue.value == 0
assert acc.char_saturation.value == 75
@@ -220,8 +335,26 @@ async def test_light_rgb_color(hass, hk_driver, cls, events):
# Set from HomeKit
call_turn_on = async_mock_service(hass, DOMAIN, "turn_on")
- await hass.async_add_job(acc.char_hue.client_update_value, 145)
- await hass.async_add_job(acc.char_saturation.client_update_value, 75)
+ char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID]
+ char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID]
+
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_hue_iid,
+ HAP_REPR_VALUE: 145,
+ },
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_saturation_iid,
+ HAP_REPR_VALUE: 75,
+ },
+ ]
+ },
+ "mock_addr",
+ )
await hass.async_block_till_done()
assert call_turn_on
assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
@@ -230,7 +363,7 @@ async def test_light_rgb_color(hass, hk_driver, cls, events):
assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)"
-async def test_light_restore(hass, hk_driver, cls, events):
+async def test_light_restore(hass, hk_driver, cls, events, driver):
"""Test setting up an entity from state in the event registry."""
hass.state = CoreState.not_running
@@ -250,7 +383,9 @@ async def test_light_restore(hass, hk_driver, cls, events):
hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {})
await hass.async_block_till_done()
- acc = cls.light(hass, hk_driver, "Light", "light.simple", 2, None)
+ acc = cls.light(hass, hk_driver, "Light", "light.simple", 1, None)
+ driver.add_accessory(acc)
+
assert acc.category == 5 # Lightbulb
assert acc.chars == []
assert acc.char_on.value == 0
@@ -259,3 +394,150 @@ async def test_light_restore(hass, hk_driver, cls, events):
assert acc.category == 5 # Lightbulb
assert acc.chars == ["Brightness"]
assert acc.char_on.value == 0
+
+
+async def test_light_set_brightness_and_color(hass, hk_driver, cls, events, driver):
+ """Test light with all chars in one go."""
+ entity_id = "light.demo"
+
+ hass.states.async_set(
+ entity_id,
+ STATE_ON,
+ {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS | SUPPORT_COLOR,
+ ATTR_BRIGHTNESS: 255,
+ },
+ )
+ await hass.async_block_till_done()
+ acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None)
+ driver.add_accessory(acc)
+
+ # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the
+ # brightness to 100 when turning on a light on a freshly booted up server.
+ assert acc.char_brightness.value != 0
+ char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID]
+ char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID]
+ char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID]
+ char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID]
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 100
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 40
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)})
+ await hass.async_block_till_done()
+ assert acc.char_hue.value == 4
+ assert acc.char_saturation.value == 9
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, "turn_on")
+
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1},
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_brightness_iid,
+ HAP_REPR_VALUE: 20,
+ },
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_hue_iid,
+ HAP_REPR_VALUE: 145,
+ },
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_saturation_iid,
+ HAP_REPR_VALUE: 75,
+ },
+ ]
+ },
+ "mock_addr",
+ )
+ await hass.async_block_till_done()
+ assert call_turn_on[0]
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20
+ assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75)
+
+ assert len(events) == 1
+ assert (
+ events[-1].data[ATTR_VALUE]
+ == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}, set color at (145, 75)"
+ )
+
+
+async def test_light_set_brightness_and_color_temp(
+ hass, hk_driver, cls, events, driver
+):
+ """Test light with all chars in one go."""
+ entity_id = "light.demo"
+
+ hass.states.async_set(
+ entity_id,
+ STATE_ON,
+ {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP,
+ ATTR_BRIGHTNESS: 255,
+ },
+ )
+ await hass.async_block_till_done()
+ acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None)
+ driver.add_accessory(acc)
+
+ # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the
+ # brightness to 100 when turning on a light on a freshly booted up server.
+ assert acc.char_brightness.value != 0
+ char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID]
+ char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID]
+ char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID]
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 100
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 40
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: (224.14)})
+ await hass.async_block_till_done()
+ assert acc.char_color_temperature.value == 224
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, "turn_on")
+
+ driver.set_characteristics(
+ {
+ HAP_REPR_CHARS: [
+ {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1},
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_brightness_iid,
+ HAP_REPR_VALUE: 20,
+ },
+ {
+ HAP_REPR_AID: acc.aid,
+ HAP_REPR_IID: char_color_temperature_iid,
+ HAP_REPR_VALUE: 250,
+ },
+ ]
+ },
+ "mock_addr",
+ )
+ await hass.async_block_till_done()
+ assert call_turn_on[0]
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20
+ assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250
+
+ assert len(events) == 1
+ assert (
+ events[-1].data[ATTR_VALUE]
+ == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}, color temperature at 250"
+ )
diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py
index c96cfdae602..756b20456fe 100644
--- a/tests/components/homekit/test_type_thermostats.py
+++ b/tests/components/homekit/test_type_thermostats.py
@@ -5,7 +5,9 @@ from unittest.mock import patch
import pytest
from homeassistant.components.climate.const import (
+ ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
+ ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
@@ -18,6 +20,7 @@ from homeassistant.components.climate.const import (
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
DEFAULT_MAX_TEMP,
+ DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
DOMAIN as DOMAIN_CLIMATE,
HVAC_MODE_AUTO,
@@ -99,10 +102,12 @@ async def test_thermostat(hass, hk_driver, cls, events):
assert acc.char_display_units.value == 0
assert acc.char_cooling_thresh_temp is None
assert acc.char_heating_thresh_temp is None
+ assert acc.char_target_humidity is None
+ assert acc.char_current_humidity is None
assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP
assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP
- assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5
+ assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1
hass.states.async_set(
entity_id,
@@ -276,10 +281,10 @@ async def test_thermostat_auto(hass, hk_driver, cls, events):
assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP
- assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.5
+ assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP
assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP
- assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.5
+ assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
hass.states.async_set(
entity_id,
@@ -357,6 +362,49 @@ async def test_thermostat_auto(hass, hk_driver, cls, events):
assert events[-1].data[ATTR_VALUE] == "cooling threshold 25.0°C"
+async def test_thermostat_humidity(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly with humidity."""
+ entity_id = "climate.test"
+
+ # support_auto = True
+ hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_SUPPORTED_FEATURES: 4})
+ await hass.async_block_till_done()
+ acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.char_target_humidity.value == 50
+ assert acc.char_current_humidity.value == 50
+
+ assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY
+
+ hass.states.async_set(
+ entity_id, HVAC_MODE_HEAT_COOL, {ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40},
+ )
+ await hass.async_block_till_done()
+ assert acc.char_current_humidity.value == 40
+ assert acc.char_target_humidity.value == 65
+
+ hass.states.async_set(
+ entity_id, HVAC_MODE_COOL, {ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70},
+ )
+ await hass.async_block_till_done()
+ assert acc.char_current_humidity.value == 70
+ assert acc.char_target_humidity.value == 35
+
+ # Set from HomeKit
+ call_set_humidity = async_mock_service(hass, DOMAIN_CLIMATE, "set_humidity")
+
+ await hass.async_add_job(acc.char_target_humidity.client_update_value, 35)
+ await hass.async_block_till_done()
+ assert call_set_humidity[0]
+ assert call_set_humidity[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_humidity[0].data[ATTR_HUMIDITY] == 35
+ assert acc.char_target_humidity.value == 35
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == "35%"
+
+
async def test_thermostat_power_state(hass, hk_driver, cls, events):
"""Test if accessory and HA are updated accordingly."""
entity_id = "climate.test"
@@ -517,7 +565,7 @@ async def test_thermostat_temperature_step_whole(hass, hk_driver, cls):
await hass.async_add_job(acc.run)
await hass.async_block_till_done()
- assert acc.char_target_temp.properties[PROP_MIN_STEP] == 1.0
+ assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1
async def test_thermostat_restore(hass, hk_driver, cls, events):
@@ -618,7 +666,7 @@ async def test_water_heater(hass, hk_driver, cls, events):
assert (
acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP_WATER_HEATER
)
- assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5
+ assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1
hass.states.async_set(
entity_id,
diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py
new file mode 100644
index 00000000000..fd95ef98c09
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py
@@ -0,0 +1,65 @@
+"""
+Make sure that existing RainMachine support isn't broken.
+
+https://github.com/home-assistant/core/issues/31745
+"""
+
+from tests.components.homekit_controller.common import (
+ Helper,
+ setup_accessories_from_file,
+ setup_test_accessories,
+)
+
+
+async def test_rainmachine_pro_8_setup(hass):
+ """Test that a RainMachine can be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, "rainmachine-pro-8.json")
+ config_entry, pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # Assert that the entity is correctly added to the entity registry
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a")
+ assert entry.unique_id == "homekit-00aa0000aa0a-512"
+
+ helper = Helper(
+ hass, "switch.rainmachine_00ce4a", pairing, accessories[0], config_entry
+ )
+ state = await helper.poll_and_get_state()
+
+ # Assert that the friendly name is detected correctly
+ assert state.attributes["friendly_name"] == "RainMachine-00ce4a"
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ device = device_registry.async_get(entry.device_id)
+ assert device.manufacturer == "Green Electronics LLC"
+ assert device.name == "RainMachine-00ce4a"
+ assert device.model == "SPK5 Pro"
+ assert device.sw_version == "1.0.4"
+ assert device.via_device_id is None
+
+ # The device is made up of multiple valves - make sure we have enumerated them all
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_2")
+ assert entry.unique_id == "homekit-00aa0000aa0a-768"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_3")
+ assert entry.unique_id == "homekit-00aa0000aa0a-1024"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_4")
+ assert entry.unique_id == "homekit-00aa0000aa0a-1280"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_5")
+ assert entry.unique_id == "homekit-00aa0000aa0a-1536"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_6")
+ assert entry.unique_id == "homekit-00aa0000aa0a-1792"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_7")
+ assert entry.unique_id == "homekit-00aa0000aa0a-2048"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_8")
+ assert entry.unique_id == "homekit-00aa0000aa0a-2304"
+
+ entry = entity_registry.async_get("switch.rainmachine_00ce4a_9")
+ assert entry is None
diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py
index 8817ed5c22d..460d14d0d48 100644
--- a/tests/components/homekit_controller/test_binary_sensor.py
+++ b/tests/components/homekit_controller/test_binary_sensor.py
@@ -3,6 +3,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes
from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_MOTION,
DEVICE_CLASS_OCCUPANCY,
DEVICE_CLASS_OPENING,
@@ -15,6 +16,7 @@ MOTION_DETECTED = ("motion", "motion-detected")
CONTACT_STATE = ("contact", "contact-state")
SMOKE_DETECTED = ("smoke", "smoke-detected")
OCCUPANCY_DETECTED = ("occupancy", "occupancy-detected")
+LEAK_DETECTED = ("leak", "leak-detected")
def create_motion_sensor_service(accessory):
@@ -107,3 +109,26 @@ async def test_occupancy_sensor_read_state(hass, utcnow):
assert state.state == "on"
assert state.attributes["device_class"] == DEVICE_CLASS_OCCUPANCY
+
+
+def create_leak_sensor_service(accessory):
+ """Define leak characteristics."""
+ service = accessory.add_service(ServicesTypes.LEAK_SENSOR)
+
+ cur_state = service.add_char(CharacteristicsTypes.LEAK_DETECTED)
+ cur_state.value = 0
+
+
+async def test_leak_sensor_read_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit leak sensor accessory."""
+ helper = await setup_test_component(hass, create_leak_sensor_service)
+
+ helper.characteristics[LEAK_DETECTED].value = 0
+ state = await helper.poll_and_get_state()
+ assert state.state == "off"
+
+ helper.characteristics[LEAK_DETECTED].value = 1
+ state = await helper.poll_and_get_state()
+ assert state.state == "on"
+
+ assert state.attributes["device_class"] == DEVICE_CLASS_MOISTURE
diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py
index eb10d42e208..c53d20891b1 100644
--- a/tests/components/homekit_controller/test_switch.py
+++ b/tests/components/homekit_controller/test_switch.py
@@ -1,6 +1,10 @@
"""Basic checks for HomeKitSwitch."""
-from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import (
+ CharacteristicsTypes,
+ InUseValues,
+ IsConfiguredValues,
+)
from aiohomekit.model.services import ServicesTypes
from tests.components.homekit_controller.common import setup_test_component
@@ -17,6 +21,23 @@ def create_switch_service(accessory):
outlet_in_use.value = False
+def create_valve_service(accessory):
+ """Define valve characteristics."""
+ service = accessory.add_service(ServicesTypes.VALVE)
+
+ on_char = service.add_char(CharacteristicsTypes.ACTIVE)
+ on_char.value = False
+
+ in_use = service.add_char(CharacteristicsTypes.IN_USE)
+ in_use.value = InUseValues.IN_USE
+
+ configured = service.add_char(CharacteristicsTypes.IS_CONFIGURED)
+ configured.value = IsConfiguredValues.CONFIGURED
+
+ remaining = service.add_char(CharacteristicsTypes.REMAINING_DURATION)
+ remaining.value = 99
+
+
async def test_switch_change_outlet_state(hass, utcnow):
"""Test that we can turn a HomeKit outlet on and off again."""
helper = await setup_test_component(hass, create_switch_service)
@@ -57,3 +78,47 @@ async def test_switch_read_outlet_state(hass, utcnow):
switch_1 = await helper.poll_and_get_state()
assert switch_1.state == "off"
assert switch_1.attributes["outlet_in_use"] is True
+
+
+async def test_valve_change_active_state(hass, utcnow):
+ """Test that we can turn a valve on and off again."""
+ helper = await setup_test_component(hass, create_valve_service)
+
+ await hass.services.async_call(
+ "switch", "turn_on", {"entity_id": "switch.testdevice"}, blocking=True
+ )
+ assert helper.characteristics[("valve", "active")].value == 1
+
+ await hass.services.async_call(
+ "switch", "turn_off", {"entity_id": "switch.testdevice"}, blocking=True
+ )
+ assert helper.characteristics[("valve", "active")].value == 0
+
+
+async def test_valve_read_state(hass, utcnow):
+ """Test that we can read the state of a valve accessory."""
+ helper = await setup_test_component(hass, create_valve_service)
+
+ # Initial state is that the switch is off and the outlet isn't in use
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.state == "off"
+ assert switch_1.attributes["in_use"] is True
+ assert switch_1.attributes["is_configured"] is True
+ assert switch_1.attributes["remaining_duration"] == 99
+
+ # Simulate that someone switched on the device in the real world not via HA
+ helper.characteristics[("valve", "active")].set_value(True)
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.state == "on"
+
+ # Simulate that someone configured the device in the real world not via HA
+ helper.characteristics[
+ ("valve", "is-configured")
+ ].value = IsConfiguredValues.NOT_CONFIGURED
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.attributes["is_configured"] is False
+
+ # Simulate that someone using the device in the real world not via HA
+ helper.characteristics[("valve", "in-use")].value = InUseValues.NOT_IN_USE
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.attributes["in_use"] is False
diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py
index 927690d881f..b1933604fbe 100644
--- a/tests/components/homematicip_cloud/conftest.py
+++ b/tests/components/homematicip_cloud/conftest.py
@@ -3,6 +3,7 @@ from asynctest import CoroutineMock, MagicMock, Mock, patch
from homematicip.aio.auth import AsyncAuth
from homematicip.aio.connection import AsyncConnection
from homematicip.aio.home import AsyncHome
+from homematicip.base.enums import WeatherCondition, WeatherDayTime
import pytest
from homeassistant import config_entries
@@ -115,10 +116,21 @@ def simple_mock_home_fixture():
devices=[],
groups=[],
location=Mock(),
- weather=Mock(create=True),
+ weather=Mock(
+ temperature=0.0,
+ weatherCondition=WeatherCondition.UNKNOWN,
+ weatherDayTime=WeatherDayTime.DAY,
+ minTemperature=0.0,
+ maxTemperature=0.0,
+ humidity=0,
+ windSpeed=0.0,
+ windDirection=0,
+ vaporAmount=0.0,
+ ),
id=42,
dutyCycle=88,
connected=True,
+ currentAPVersion="2.0.36",
)
with patch(
diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py
index 6436433a147..ec13fc79536 100644
--- a/tests/components/homematicip_cloud/test_config_flow.py
+++ b/tests/components/homematicip_cloud/test_config_flow.py
@@ -52,6 +52,8 @@ async def test_flow_works(hass, simple_mock_home):
), patch(
"homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_register",
return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.async_connect",
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
@@ -151,6 +153,8 @@ async def test_import_config(hass, simple_mock_home):
), patch(
"homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_register",
return_value=True,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.async_connect",
):
result = await hass.config_entries.flow.async_init(
HMIPC_DOMAIN, context={"source": "import"}, data=IMPORT_CONFIG
diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py
index 3cb45182399..71efac3a7c9 100644
--- a/tests/components/homematicip_cloud/test_device.py
+++ b/tests/components/homematicip_cloud/test_device.py
@@ -26,11 +26,11 @@ async def test_hmip_load_all_supported_devices(hass, default_mock_hap_factory):
async def test_hmip_remove_device(hass, default_mock_hap_factory):
"""Test Remove of hmip device."""
- entity_id = "light.treppe"
- entity_name = "Treppe"
+ entity_id = "light.treppe_ch"
+ entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
- test_devices=[entity_name]
+ test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -58,11 +58,11 @@ async def test_hmip_remove_device(hass, default_mock_hap_factory):
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"
+ entity_id = "light.treppe_ch"
+ entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
- test_devices=[entity_name]
+ test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -137,11 +137,11 @@ 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"
+ entity_id = "light.treppe_ch"
+ entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
- test_devices=[entity_name]
+ test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -161,11 +161,11 @@ async def test_all_devices_unavailable_when_hap_not_connected(
async def test_hap_reconnected(hass, default_mock_hap_factory):
"""Test reconnect hap."""
- entity_id = "light.treppe"
- entity_name = "Treppe"
+ entity_id = "light.treppe_ch"
+ entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
- test_devices=[entity_name]
+ test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -192,8 +192,8 @@ async def test_hap_reconnected(hass, default_mock_hap_factory):
async def test_hap_with_name(hass, mock_connection, hmip_config_entry):
"""Test hap with name."""
home_name = "TestName"
- entity_id = f"light.{home_name.lower()}_treppe"
- entity_name = f"{home_name} Treppe"
+ entity_id = f"light.{home_name.lower()}_treppe_ch"
+ entity_name = f"{home_name} Treppe CH"
device_model = "HmIP-BSL"
hmip_config_entry.data = {**hmip_config_entry.data, "name": home_name}
diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py
index f97e7114b94..8f2753bc499 100644
--- a/tests/components/homematicip_cloud/test_init.py
+++ b/tests/components/homematicip_cloud/test_init.py
@@ -39,7 +39,12 @@ async def test_config_with_accesspoint_passed_to_config_entry(
# no acccesspoint exists
assert not hass.data.get(HMIPC_DOMAIN)
- assert await async_setup_component(hass, HMIPC_DOMAIN, {HMIPC_DOMAIN: entry_config})
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.async_connect",
+ ):
+ 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)
@@ -77,7 +82,13 @@ async def test_config_already_registered_not_passed_to_config_entry(
CONF_AUTHTOKEN: "123",
CONF_NAME: "name",
}
- assert await async_setup_component(hass, HMIPC_DOMAIN, {HMIPC_DOMAIN: entry_config})
+
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.async_connect",
+ ):
+ 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)
@@ -107,16 +118,14 @@ async def test_load_entry_fails_due_to_connection_error(
assert hmip_config_entry.state == ENTRY_STATE_SETUP_RETRY
-async def test_load_entry_fails_due_to_generic_exception(
- hass, hmip_config_entry, simple_mock_home
-):
+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,
- ):
+ ), patch("homematicip.aio.connection.AsyncConnection.init",):
assert await async_setup_component(hass, HMIPC_DOMAIN, {})
assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id]
diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py
index 8909e469ee9..8ab62019c3d 100644
--- a/tests/components/homematicip_cloud/test_light.py
+++ b/tests/components/homematicip_cloud/test_light.py
@@ -27,11 +27,11 @@ async def test_manually_configured_platform(hass):
async def test_hmip_light(hass, default_mock_hap_factory):
"""Test HomematicipLight."""
- entity_id = "light.treppe"
- entity_name = "Treppe"
+ entity_id = "light.treppe_ch"
+ entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
- test_devices=[entity_name]
+ test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -66,8 +66,8 @@ async def test_hmip_light(hass, default_mock_hap_factory):
async def test_hmip_notification_light(hass, default_mock_hap_factory):
"""Test HomematicipNotificationLight."""
- entity_id = "light.treppe_top_notification"
- entity_name = "Treppe Top Notification"
+ entity_id = "light.alarm_status"
+ entity_name = "Alarm Status"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["Treppe"]
diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py
index 49cd953a697..fa7c4ac473d 100644
--- a/tests/components/hue/conftest.py
+++ b/tests/components/hue/conftest.py
@@ -1,11 +1,95 @@
"""Test helpers for Hue."""
-from unittest.mock import patch
+from collections import deque
+from unittest.mock import Mock, patch
+from aiohue.groups import Groups
+from aiohue.lights import Lights
+from aiohue.sensors import Sensors
import pytest
+from homeassistant import config_entries
+from homeassistant.components import hue
+from homeassistant.components.hue import sensor_base as hue_sensor_base
+
@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
+
+
+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
+ # than expected.
+ bridge.mock_light_responses = deque()
+ bridge.mock_group_responses = deque()
+ bridge.mock_sensor_responses = deque()
+
+ async def mock_request(method, path, **kwargs):
+ kwargs["method"] = method
+ kwargs["path"] = path
+ bridge.mock_requests.append(kwargs)
+
+ if path == "lights":
+ return bridge.mock_light_responses.popleft()
+ if path == "groups":
+ return bridge.mock_group_responses.popleft()
+ if path == "sensors":
+ return bridge.mock_sensor_responses.popleft()
+ return None
+
+ async def async_request_call(task):
+ await task()
+
+ bridge.async_request_call = async_request_call
+ bridge.api.config.apiversion = "9.9.9"
+ bridge.api.lights = Lights({}, mock_request)
+ bridge.api.groups = Groups({}, mock_request)
+ bridge.api.sensors = Sensors({}, mock_request)
+ return bridge
+
+
+@pytest.fixture
+def mock_bridge(hass):
+ """Mock a Hue bridge."""
+ return create_mock_bridge(hass)
+
+
+async def setup_bridge_for_sensors(hass, mock_bridge, hostname=None):
+ """Load the Hue platform with the provided bridge for sensor-related platforms."""
+ if hostname is None:
+ hostname = "mock-host"
+ hass.config.components.add(hue.DOMAIN)
+ config_entry = config_entries.ConfigEntry(
+ 1,
+ hue.DOMAIN,
+ "Mock Title",
+ {"host": hostname},
+ "test",
+ 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")
+ # simulate a full setup by manually adding the bridge config entry
+ hass.config_entries._entries.append(config_entry)
+
+ # and make sure it completes before going further
+ await hass.async_block_till_done()
diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py
index 03966560d8d..6ac68d222eb 100644
--- a/tests/components/hue/test_bridge.py
+++ b/tests/components/hue/test_bridge.py
@@ -99,7 +99,7 @@ async def test_reset_unloads_entry_if_setup(hass):
async def test_handle_unauthorized(hass):
"""Test handling an unauthorized error on update."""
- entry = Mock()
+ entry = Mock(async_setup=Mock(return_value=mock_coro(Mock())))
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger.py
new file mode 100644
index 00000000000..b6d3f4f2f50
--- /dev/null
+++ b/tests/components/hue/test_device_trigger.py
@@ -0,0 +1,169 @@
+"""The tests for Philips Hue device triggers."""
+import pytest
+
+from homeassistant.components import hue
+import homeassistant.components.automation as automation
+from homeassistant.components.hue import device_trigger
+from homeassistant.setup import async_setup_component
+
+from .conftest import setup_bridge_for_sensors as setup_bridge
+from .test_sensor_base import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1
+
+from tests.common import (
+ assert_lists_same,
+ async_get_device_automations,
+ async_mock_service,
+ mock_device_registry,
+)
+
+REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1}
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+async def test_get_triggers(hass, mock_bridge, device_reg):
+ """Test we get the expected triggers from a hue remote."""
+ mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+
+ assert len(mock_bridge.mock_requests) == 1
+ # 2 remotes, just 1 battery sensor
+ assert len(hass.states.async_all()) == 1
+
+ # Get triggers for specific tap switch
+ hue_tap_device = device_reg.async_get_device(
+ {(hue.DOMAIN, "00:00:00:00:00:44:23:08")}, connections={}
+ )
+ triggers = await async_get_device_automations(hass, "trigger", hue_tap_device.id)
+
+ expected_triggers = [
+ {
+ "platform": "device",
+ "domain": hue.DOMAIN,
+ "device_id": hue_tap_device.id,
+ "type": t_type,
+ "subtype": t_subtype,
+ }
+ for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE.keys()
+ ]
+ assert_lists_same(triggers, expected_triggers)
+
+ # Get triggers for specific dimmer switch
+ hue_dimmer_device = device_reg.async_get_device(
+ {(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")}, connections={}
+ )
+ triggers = await async_get_device_automations(hass, "trigger", hue_dimmer_device.id)
+
+ trigger_batt = {
+ "platform": "device",
+ "domain": "sensor",
+ "device_id": hue_dimmer_device.id,
+ "type": "battery_level",
+ "entity_id": "sensor.hue_dimmer_switch_1_battery_level",
+ }
+ expected_triggers = [
+ trigger_batt,
+ *[
+ {
+ "platform": "device",
+ "domain": hue.DOMAIN,
+ "device_id": hue_dimmer_device.id,
+ "type": t_type,
+ "subtype": t_subtype,
+ }
+ for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE.keys()
+ ],
+ ]
+ assert_lists_same(triggers, expected_triggers)
+
+
+async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls):
+ """Test for button press trigger firing."""
+ mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 1
+
+ # Set an automation with a specific tap switch trigger
+ hue_tap_device = device_reg.async_get_device(
+ {(hue.DOMAIN, "00:00:00:00:00:44:23:08")}, connections={}
+ )
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": hue.DOMAIN,
+ "device_id": hue_tap_device.id,
+ "type": "remote_button_short_press",
+ "subtype": "button_4",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "B4 - {{ trigger.event.data.event }}"
+ },
+ },
+ },
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": hue.DOMAIN,
+ "device_id": "mock-device-id",
+ "type": "remote_button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "B1 - {{ trigger.event.data.event }}"
+ },
+ },
+ },
+ ]
+ },
+ )
+
+ # Fake that the remote is being pressed.
+ new_sensor_response = dict(REMOTES_RESPONSE)
+ new_sensor_response["7"]["state"] = {
+ "buttonevent": 18,
+ "lastupdated": "2019-12-28T22:58:02",
+ }
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
+ await hass.async_block_till_done()
+
+ assert len(mock_bridge.mock_requests) == 2
+
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "B4 - 18"
+
+ # Fake another button press.
+ new_sensor_response = dict(REMOTES_RESPONSE)
+ new_sensor_response["7"]["state"] = {
+ "buttonevent": 34,
+ "lastupdated": "2019-12-28T22:58:05",
+ }
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
+ await hass.async_block_till_done()
+ assert len(mock_bridge.mock_requests) == 3
+ assert len(calls) == 1
diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py
index d9131dad226..51ea3f2ae71 100644
--- a/tests/components/hue/test_init.py
+++ b/tests/components/hue/test_init.py
@@ -193,17 +193,15 @@ async def test_security_vuln_check(hass):
entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"})
entry.add_to_hass(hass)
+ config = Mock(bridgeid="", mac="", modelid="BSB002", swversion="1935144020")
+ config.name = "Hue"
+
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"
- )
- ),
+ async_setup=CoroutineMock(return_value=True), api=Mock(config=config)
)
),
):
diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py
index 72546891a63..998e3cdea50 100644
--- a/tests/components/hue/test_light.py
+++ b/tests/components/hue/test_light.py
@@ -1,13 +1,9 @@
"""Philips Hue lights platform tests."""
import asyncio
-from collections import deque
import logging
from unittest.mock import Mock
import aiohue
-from aiohue.groups import Groups
-from aiohue.lights import Lights
-import pytest
from homeassistant import config_entries
from homeassistant.components import hue
@@ -175,48 +171,6 @@ LIGHT_GAMUT = color.GamutType(
LIGHT_GAMUT_TYPE = "A"
-@pytest.fixture
-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 = []
- # We're using a deque so we can schedule multiple responses
- # and also means that `popleft()` will blow up if we get more updates
- # than expected.
- bridge.mock_light_responses = deque()
- bridge.mock_group_responses = deque()
-
- async def mock_request(method, path, **kwargs):
- kwargs["method"] = method
- kwargs["path"] = path
- bridge.mock_requests.append(kwargs)
-
- if path == "lights":
- return bridge.mock_light_responses.popleft()
- if path == "groups":
- return bridge.mock_group_responses.popleft()
- return None
-
- async def async_request_call(task):
- await task()
-
- bridge.async_request_call = async_request_call
- bridge.api.config.apiversion = "9.9.9"
- bridge.api.lights = Lights({}, mock_request)
- bridge.api.groups = Groups({}, mock_request)
-
- return bridge
-
-
async def setup_bridge(hass, mock_bridge):
"""Load the Hue light platform with the provided bridge."""
hass.config.components.add(hue.DOMAIN)
@@ -893,7 +847,7 @@ async def test_group_features(hass, mock_bridge):
"modelid": "LCT001",
"swversion": "66009461",
"manufacturername": "Philips",
- "uniqueid": "456",
+ "uniqueid": "4567",
}
light_3 = {
"state": {
@@ -945,7 +899,7 @@ async def test_group_features(hass, mock_bridge):
"modelid": "LCT001",
"swversion": "66009461",
"manufacturername": "Philips",
- "uniqueid": "123",
+ "uniqueid": "1234",
}
light_response = {
"1": light_1,
diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py
index ca83da725fa..576bc365d50 100644
--- a/tests/components/hue/test_sensor_base.py
+++ b/tests/components/hue/test_sensor_base.py
@@ -1,16 +1,13 @@
"""Philips Hue sensors platform tests."""
import asyncio
-from collections import deque
import logging
from unittest.mock import Mock
import aiohue
-from aiohue.sensors import Sensors
-import pytest
-from homeassistant import config_entries
-from homeassistant.components import hue
-from homeassistant.components.hue import sensor_base as hue_sensor_base
+from homeassistant.components.hue.hue_event import CONF_HUE_EVENT
+
+from .conftest import create_mock_bridge, setup_bridge_for_sensors as setup_bridge
_LOGGER = logging.getLogger(__name__)
@@ -241,6 +238,33 @@ UNSUPPORTED_SENSOR = {
"uniqueid": "arbitrary",
"recycle": True,
}
+HUE_TAP_REMOTE_1 = {
+ "state": {"buttonevent": 17, "lastupdated": "2019-06-22T14:43:50"},
+ "swupdate": {"state": "notupdatable", "lastinstall": None},
+ "config": {"on": True},
+ "name": "Hue Tap",
+ "type": "ZGPSwitch",
+ "modelid": "ZGPSWITCH",
+ "manufacturername": "Philips",
+ "productname": "Hue tap switch",
+ "diversityid": "d8cde5d5-0eef-4b95-b0f0-71ddd2952af4",
+ "uniqueid": "00:00:00:00:00:44:23:08-f2",
+ "capabilities": {"certified": True, "primary": True, "inputs": []},
+}
+HUE_DIMMER_REMOTE_1 = {
+ "state": {"buttonevent": 4002, "lastupdated": "2019-12-28T21:58:02"},
+ "swupdate": {"state": "noupdates", "lastinstall": "2019-10-13T13:16:15"},
+ "config": {"on": True, "battery": 100, "reachable": True, "pending": []},
+ "name": "Hue dimmer switch 1",
+ "type": "ZLLSwitch",
+ "modelid": "RWL021",
+ "manufacturername": "Philips",
+ "productname": "Hue dimmer switch",
+ "diversityid": "73bbabea-3420-499a-9856-46bf437e119b",
+ "swversion": "6.1.1.28573",
+ "uniqueid": "00:17:88:01:10:3e:3a:dc-02-fc00",
+ "capabilities": {"certified": True, "primary": True, "inputs": []},
+}
SENSOR_RESPONSE = {
"1": PRESENCE_SENSOR_1_PRESENT,
"2": LIGHT_LEVEL_SENSOR_1,
@@ -248,74 +272,11 @@ SENSOR_RESPONSE = {
"4": PRESENCE_SENSOR_2_NOT_PRESENT,
"5": LIGHT_LEVEL_SENSOR_2,
"6": TEMPERATURE_SENSOR_2,
+ "7": HUE_TAP_REMOTE_1,
+ "8": HUE_DIMMER_REMOTE_1,
}
-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
- # than expected.
- bridge.mock_sensor_responses = deque()
-
- async def mock_request(method, path, **kwargs):
- kwargs["method"] = method
- kwargs["path"] = path
- bridge.mock_requests.append(kwargs)
-
- if path == "sensors":
- return bridge.mock_sensor_responses.popleft()
- return None
-
- async def async_request_call(task):
- await task()
-
- bridge.async_request_call = async_request_call
- bridge.api.config.apiversion = "9.9.9"
- bridge.api.sensors = Sensors({}, mock_request)
- return bridge
-
-
-@pytest.fixture
-def mock_bridge(hass):
- """Mock a Hue bridge."""
- return create_mock_bridge(hass)
-
-
-async def setup_bridge(hass, mock_bridge, hostname=None):
- """Load the Hue platform with the provided bridge."""
- if hostname is None:
- hostname = "mock-host"
- hass.config.components.add(hue.DOMAIN)
- config_entry = config_entries.ConfigEntry(
- 1,
- hue.DOMAIN,
- "Mock Title",
- {"host": hostname},
- "test",
- 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
- await hass.async_block_till_done()
-
-
async def test_no_sensors(hass, mock_bridge):
"""Test the update_items function when no sensors are found."""
mock_bridge.allow_groups = True
@@ -341,8 +302,8 @@ async def test_sensors_with_multiple_bridges(hass, mock_bridge):
assert len(mock_bridge.mock_requests) == 1
assert len(mock_bridge_2.mock_requests) == 1
- # 3 "physical" sensors with 3 virtual sensors each
- assert len(hass.states.async_all()) == 9
+ # 3 "physical" sensors with 3 virtual sensors each + 1 battery sensor
+ assert len(hass.states.async_all()) == 10
async def test_sensors(hass, mock_bridge):
@@ -351,7 +312,7 @@ async def test_sensors(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 1
# 2 "physical" sensors with 3 virtual sensors each
- assert len(hass.states.async_all()) == 6
+ assert len(hass.states.async_all()) == 7
presence_sensor_1 = hass.states.get("binary_sensor.living_room_sensor_motion")
light_level_sensor_1 = hass.states.get("sensor.living_room_sensor_light_level")
@@ -377,6 +338,11 @@ async def test_sensors(hass, mock_bridge):
assert temperature_sensor_2.state == "18.75"
assert temperature_sensor_2.name == "Kitchen sensor temperature"
+ battery_remote_1 = hass.states.get("sensor.hue_dimmer_switch_1_battery_level")
+ assert battery_remote_1 is not None
+ assert battery_remote_1.state == "100"
+ assert battery_remote_1.name == "Hue dimmer switch 1 battery level"
+
async def test_unsupported_sensors(hass, mock_bridge):
"""Test that unsupported sensors don't get added and don't fail."""
@@ -385,8 +351,8 @@ async def test_unsupported_sensors(hass, mock_bridge):
mock_bridge.mock_sensor_responses.append(response_with_unsupported)
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 1
- # 2 "physical" sensors with 3 virtual sensors each
- assert len(hass.states.async_all()) == 6
+ # 2 "physical" sensors with 3 virtual sensors each + 1 battery sensor
+ assert len(hass.states.async_all()) == 7
async def test_new_sensor_discovered(hass, mock_bridge):
@@ -395,14 +361,14 @@ async def test_new_sensor_discovered(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 1
- assert len(hass.states.async_all()) == 6
+ assert len(hass.states.async_all()) == 7
new_sensor_response = dict(SENSOR_RESPONSE)
new_sensor_response.update(
{
- "7": PRESENCE_SENSOR_3_PRESENT,
- "8": LIGHT_LEVEL_SENSOR_3,
- "9": TEMPERATURE_SENSOR_3,
+ "9": PRESENCE_SENSOR_3_PRESENT,
+ "10": LIGHT_LEVEL_SENSOR_3,
+ "11": TEMPERATURE_SENSOR_3,
}
)
@@ -413,7 +379,7 @@ async def test_new_sensor_discovered(hass, mock_bridge):
await hass.async_block_till_done()
assert len(mock_bridge.mock_requests) == 2
- assert len(hass.states.async_all()) == 9
+ assert len(hass.states.async_all()) == 10
presence = hass.states.get("binary_sensor.bedroom_sensor_motion")
assert presence is not None
@@ -429,7 +395,7 @@ async def test_sensor_removed(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 1
- assert len(hass.states.async_all()) == 6
+ assert len(hass.states.async_all()) == 7
mock_bridge.mock_sensor_responses.clear()
keys = ("1", "2", "3")
@@ -466,3 +432,121 @@ async def test_update_unauthorized(hass, mock_bridge):
assert len(mock_bridge.mock_requests) == 0
assert len(hass.states.async_all()) == 0
assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1
+
+
+async def test_hue_events(hass, mock_bridge):
+ """Test that hue remotes fire events when pressed."""
+ mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE)
+
+ mock_listener = Mock()
+ unsub = hass.bus.async_listen(CONF_HUE_EVENT, mock_listener)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 7
+ assert len(mock_listener.mock_calls) == 0
+
+ new_sensor_response = dict(SENSOR_RESPONSE)
+ new_sensor_response["7"]["state"] = {
+ "buttonevent": 18,
+ "lastupdated": "2019-12-28T22:58:02",
+ }
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
+ await hass.async_block_till_done()
+
+ assert len(mock_bridge.mock_requests) == 2
+ assert len(hass.states.async_all()) == 7
+ assert len(mock_listener.mock_calls) == 1
+ assert mock_listener.mock_calls[0][1][0].data == {
+ "id": "hue_tap",
+ "unique_id": "00:00:00:00:00:44:23:08-f2",
+ "event": 18,
+ "last_updated": "2019-12-28T22:58:02",
+ }
+
+ new_sensor_response = dict(new_sensor_response)
+ new_sensor_response["8"]["state"] = {
+ "buttonevent": 3002,
+ "lastupdated": "2019-12-28T22:58:01",
+ }
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
+ await hass.async_block_till_done()
+
+ assert len(mock_bridge.mock_requests) == 3
+ assert len(hass.states.async_all()) == 7
+ assert len(mock_listener.mock_calls) == 2
+ assert mock_listener.mock_calls[1][1][0].data == {
+ "id": "hue_dimmer_switch_1",
+ "unique_id": "00:17:88:01:10:3e:3a:dc-02-fc00",
+ "event": 3002,
+ "last_updated": "2019-12-28T22:58:01",
+ }
+
+ # Add a new remote. In discovery the new event is registered **but not fired**
+ new_sensor_response = dict(new_sensor_response)
+ new_sensor_response["21"] = {
+ "state": {
+ "rotaryevent": 2,
+ "expectedrotation": 208,
+ "expectedeventduration": 400,
+ "lastupdated": "2020-01-31T15:56:19",
+ },
+ "swupdate": {"state": "noupdates", "lastinstall": "2019-11-26T03:35:21"},
+ "config": {"on": True, "battery": 100, "reachable": True, "pending": []},
+ "name": "Lutron Aurora 1",
+ "type": "ZLLRelativeRotary",
+ "modelid": "Z3-1BRL",
+ "manufacturername": "Lutron",
+ "productname": "Lutron Aurora",
+ "diversityid": "2c3a75ff-55c4-4e4d-8c44-82d330b8eb9b",
+ "swversion": "3.4",
+ "uniqueid": "ff:ff:00:0f:e7:fd:bc:b7-01-fc00-0014",
+ "capabilities": {
+ "certified": True,
+ "primary": True,
+ "inputs": [
+ {
+ "repeatintervals": [400],
+ "events": [
+ {"rotaryevent": 1, "eventtype": "start"},
+ {"rotaryevent": 2, "eventtype": "repeat"},
+ ],
+ }
+ ],
+ },
+ }
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
+ await hass.async_block_till_done()
+
+ assert len(mock_bridge.mock_requests) == 4
+ assert len(hass.states.async_all()) == 8
+ assert len(mock_listener.mock_calls) == 2
+
+ # A new press fires the event
+ new_sensor_response["21"]["state"]["lastupdated"] = "2020-01-31T15:57:19"
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ await mock_bridge.sensor_manager.coordinator.async_refresh()
+ await hass.async_block_till_done()
+
+ assert len(mock_bridge.mock_requests) == 5
+ assert len(hass.states.async_all()) == 8
+ assert len(mock_listener.mock_calls) == 3
+ assert mock_listener.mock_calls[2][1][0].data == {
+ "id": "lutron_aurora_1",
+ "unique_id": "ff:ff:00:0f:e7:fd:bc:b7-01-fc00-0014",
+ "event": 2,
+ "last_updated": "2020-01-31T15:57:19",
+ }
+
+ unsub()
diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py
new file mode 100644
index 00000000000..1c52c557024
--- /dev/null
+++ b/tests/components/ipp/__init__.py
@@ -0,0 +1,94 @@
+"""Tests for the IPP integration."""
+import os
+
+from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID, DOMAIN
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_TYPE,
+ CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+ATTR_HOSTNAME = "hostname"
+ATTR_PROPERTIES = "properties"
+
+IPP_ZEROCONF_SERVICE_TYPE = "_ipp._tcp.local."
+IPPS_ZEROCONF_SERVICE_TYPE = "_ipps._tcp.local."
+
+ZEROCONF_NAME = "EPSON123456"
+ZEROCONF_HOST = "192.168.1.31"
+ZEROCONF_HOSTNAME = "EPSON123456.local."
+ZEROCONF_PORT = 631
+
+
+MOCK_USER_INPUT = {
+ CONF_HOST: "192.168.1.31",
+ CONF_PORT: 361,
+ CONF_SSL: False,
+ CONF_VERIFY_SSL: False,
+ CONF_BASE_PATH: "/ipp/print",
+}
+
+MOCK_ZEROCONF_IPP_SERVICE_INFO = {
+ CONF_TYPE: IPP_ZEROCONF_SERVICE_TYPE,
+ CONF_NAME: f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}",
+ CONF_HOST: ZEROCONF_HOST,
+ ATTR_HOSTNAME: ZEROCONF_HOSTNAME,
+ CONF_PORT: ZEROCONF_PORT,
+ ATTR_PROPERTIES: {"rp": "ipp/print"},
+}
+
+MOCK_ZEROCONF_IPPS_SERVICE_INFO = {
+ CONF_TYPE: IPPS_ZEROCONF_SERVICE_TYPE,
+ CONF_NAME: f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}",
+ CONF_HOST: ZEROCONF_HOST,
+ ATTR_HOSTNAME: ZEROCONF_HOSTNAME,
+ CONF_PORT: ZEROCONF_PORT,
+ ATTR_PROPERTIES: {"rp": "ipp/print"},
+}
+
+
+def load_fixture_binary(filename):
+ """Load a binary fixture."""
+ path = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", filename)
+ with open(path, "rb") as fptr:
+ return fptr.read()
+
+
+async def init_integration(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False,
+) -> MockConfigEntry:
+ """Set up the IPP integration in Home Assistant."""
+ fixture = "ipp/get-printer-attributes.bin"
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print",
+ content=load_fixture_binary(fixture),
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="cfe92100-67c4-11d4-a45f-f8d027761251",
+ data={
+ CONF_HOST: "192.168.1.31",
+ CONF_PORT: 631,
+ CONF_SSL: False,
+ CONF_VERIFY_SSL: True,
+ CONF_BASE_PATH: "/ipp/print",
+ CONF_UUID: "cfe92100-67c4-11d4-a45f-f8d027761251",
+ },
+ )
+
+ entry.add_to_hass(hass)
+
+ if not skip_setup:
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py
new file mode 100644
index 00000000000..7e16a9fc6e0
--- /dev/null
+++ b/tests/components/ipp/test_config_flow.py
@@ -0,0 +1,312 @@
+"""Tests for the IPP config flow."""
+import aiohttp
+from pyipp import IPPConnectionUpgradeRequired
+
+from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID, DOMAIN
+from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import (
+ RESULT_TYPE_ABORT,
+ RESULT_TYPE_CREATE_ENTRY,
+ RESULT_TYPE_FORM,
+)
+
+from . import (
+ MOCK_USER_INPUT,
+ MOCK_ZEROCONF_IPP_SERVICE_INFO,
+ MOCK_ZEROCONF_IPPS_SERVICE_INFO,
+ init_integration,
+ load_fixture_binary,
+)
+
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+async def test_show_user_form(hass: HomeAssistant) -> None:
+ """Test that the user set up form is served."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER},
+ )
+
+ assert result["step_id"] == "user"
+ assert result["type"] == RESULT_TYPE_FORM
+
+
+async def test_show_zeroconf_form(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test that the zeroconf confirmation form is served."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print",
+ content=load_fixture_binary("ipp/get-printer-attributes.bin"),
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["step_id"] == "zeroconf_confirm"
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"}
+
+
+async def test_connection_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we show user form on IPP connection error."""
+ aioclient_mock.post("http://192.168.1.31:631/ipp/print", exc=aiohttp.ClientError)
+
+ user_input = MOCK_USER_INPUT.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ )
+
+ assert result["step_id"] == "user"
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "connection_error"}
+
+
+async def test_zeroconf_connection_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow on IPP connection error."""
+ aioclient_mock.post("http://192.168.1.31:631/ipp/print", exc=aiohttp.ClientError)
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "connection_error"
+
+
+async def test_zeroconf_confirm_connection_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow on IPP connection error."""
+ aioclient_mock.post("http://192.168.1.31:631/ipp/print", exc=aiohttp.ClientError)
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "connection_error"
+
+
+async def test_user_connection_upgrade_required(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we show the user form if connection upgrade required by server."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print", exc=IPPConnectionUpgradeRequired
+ )
+
+ user_input = MOCK_USER_INPUT.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ )
+
+ assert result["step_id"] == "user"
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "connection_upgrade"}
+
+
+async def test_zeroconf_connection_upgrade_required(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow on IPP connection error."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print", exc=IPPConnectionUpgradeRequired
+ )
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "connection_upgrade"
+
+
+async def test_user_parse_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort user flow on IPP parse error."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print",
+ content="BAD",
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ user_input = MOCK_USER_INPUT.copy()
+ 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"] == "parse_error"
+
+
+async def test_zeroconf_parse_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow on IPP parse error."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print",
+ content="BAD",
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "parse_error"
+
+
+async def test_user_device_exists_abort(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort user flow if printer already configured."""
+ await init_integration(hass, aioclient_mock)
+
+ user_input = MOCK_USER_INPUT.copy()
+ 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_zeroconf_device_exists_abort(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow if printer already configured."""
+ await init_integration(hass, aioclient_mock)
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_zeroconf_with_uuid_device_exists_abort(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow if printer already configured."""
+ await init_integration(hass, aioclient_mock)
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ discovery_info["properties"]["UUID"] = "cfe92100-67c4-11d4-a45f-f8d027761251"
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_full_user_flow_implementation(
+ hass: HomeAssistant, aioclient_mock
+) -> None:
+ """Test the full manual user flow from start to finish."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print",
+ content=load_fixture_binary("ipp/get-printer-attributes.bin"),
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER},
+ )
+
+ assert result["step_id"] == "user"
+ assert result["type"] == RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"},
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "192.168.1.31"
+
+ assert result["data"]
+ assert result["data"][CONF_HOST] == "192.168.1.31"
+ assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251"
+
+
+async def test_full_zeroconf_flow_implementation(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the full manual user flow from start to finish."""
+ aioclient_mock.post(
+ "http://192.168.1.31:631/ipp/print",
+ content=load_fixture_binary("ipp/get-printer-attributes.bin"),
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["step_id"] == "zeroconf_confirm"
+ assert result["type"] == RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "EPSON123456"
+
+ assert result["data"]
+ assert result["data"][CONF_HOST] == "192.168.1.31"
+ assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251"
+ assert not result["data"][CONF_SSL]
+
+
+async def test_full_zeroconf_tls_flow_implementation(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the full manual user flow from start to finish."""
+ aioclient_mock.post(
+ "https://192.168.1.31:631/ipp/print",
+ content=load_fixture_binary("ipp/get-printer-attributes.bin"),
+ headers={"Content-Type": "application/ipp"},
+ )
+
+ discovery_info = MOCK_ZEROCONF_IPPS_SERVICE_INFO.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ )
+
+ assert result["step_id"] == "zeroconf_confirm"
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "EPSON123456"
+
+ assert result["data"]
+ assert result["data"][CONF_HOST] == "192.168.1.31"
+ assert result["data"][CONF_NAME] == "EPSON123456"
+ assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251"
+ assert result["data"][CONF_SSL]
diff --git a/tests/components/ipp/test_init.py b/tests/components/ipp/test_init.py
new file mode 100644
index 00000000000..2ec11a1e937
--- /dev/null
+++ b/tests/components/ipp/test_init.py
@@ -0,0 +1,40 @@
+"""Tests for the IPP integration."""
+import aiohttp
+
+from homeassistant.components.ipp.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.core import HomeAssistant
+
+from tests.components.ipp import init_integration
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+async def test_config_entry_not_ready(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the IPP configuration entry not ready."""
+ aioclient_mock.post("http://192.168.1.31:631/ipp/print", exc=aiohttp.ClientError)
+
+ entry = await init_integration(hass, aioclient_mock)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_config_entry(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the IPP configuration entry unloading."""
+ entry = await init_integration(hass, aioclient_mock)
+
+ assert hass.data[DOMAIN]
+ assert entry.entry_id in hass.data[DOMAIN]
+ assert entry.state == ENTRY_STATE_LOADED
+
+ await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.entry_id not in hass.data[DOMAIN]
+ assert entry.state == ENTRY_STATE_NOT_LOADED
diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py
new file mode 100644
index 00000000000..b7db606d870
--- /dev/null
+++ b/tests/components/ipp/test_sensor.py
@@ -0,0 +1,96 @@
+"""Tests for the IPP sensor platform."""
+from datetime import datetime
+
+from asynctest import patch
+
+from homeassistant.components.ipp.const import DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, UNIT_PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
+
+from tests.components.ipp import init_integration
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+async def test_sensors(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the creation and values of the IPP sensors."""
+ entry = await init_integration(hass, aioclient_mock, skip_setup=True)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # Pre-create registry entries for disabled by default sensors
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "cfe92100-67c4-11d4-a45f-f8d027761251_uptime",
+ suggested_object_id="epson_xp_6000_series_uptime",
+ disabled_by=None,
+ )
+
+ test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=dt_util.UTC)
+ with patch("homeassistant.components.ipp.sensor.utcnow", return_value=test_time):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.epson_xp_6000_series")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:printer"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
+
+ state = hass.states.get("sensor.epson_xp_6000_series_black_ink")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:water"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE
+ assert state.state == "58"
+
+ state = hass.states.get("sensor.epson_xp_6000_series_photo_black_ink")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:water"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE
+ assert state.state == "98"
+
+ state = hass.states.get("sensor.epson_xp_6000_series_cyan_ink")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:water"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE
+ assert state.state == "91"
+
+ state = hass.states.get("sensor.epson_xp_6000_series_yellow_ink")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:water"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE
+ assert state.state == "95"
+
+ state = hass.states.get("sensor.epson_xp_6000_series_magenta_ink")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:water"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE
+ assert state.state == "73"
+
+ state = hass.states.get("sensor.epson_xp_6000_series_uptime")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
+ assert state.state == "2019-10-26T15:37:00+00:00"
+
+ entry = registry.async_get("sensor.epson_xp_6000_series_uptime")
+ assert entry
+ assert entry.unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251_uptime"
+
+
+async def test_disabled_by_default_sensors(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the disabled by default IPP sensors."""
+ await init_integration(hass, aioclient_mock)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("sensor.epson_xp_6000_series_uptime")
+ assert state is None
+
+ entry = registry.async_get("sensor.epson_xp_6000_series_uptime")
+ assert entry
+ assert entry.disabled
+ assert entry.disabled_by == "integration"
diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py
index 3638f40735b..917afc5357a 100644
--- a/tests/components/konnected/test_config_flow.py
+++ b/tests/components/konnected/test_config_flow.py
@@ -403,6 +403,14 @@ async def test_import_existing_config(hass, mock_panel):
"pause": 100,
"repeat": 4,
},
+ {
+ "zone": 8,
+ "name": "alarm",
+ "activation": "low",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ },
{"zone": "out1"},
{"zone": "alarm1"},
],
@@ -442,6 +450,7 @@ async def test_import_existing_config(hass, mock_panel):
"alarm1": "Switchable Output",
},
"blink": True,
+ "api_host": "",
"discovery": True,
"binary_sensors": [
{"zone": "2", "type": "door", "inverse": False},
@@ -463,6 +472,14 @@ async def test_import_existing_config(hass, mock_panel):
"pause": 100,
"repeat": 4,
},
+ {
+ "zone": "8",
+ "name": "alarm",
+ "activation": "low",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ },
{"activation": "high", "zone": "out1"},
{"activation": "high", "zone": "alarm1"},
],
@@ -612,6 +629,7 @@ async def test_import_pin_config(hass, mock_panel):
"out": "Switchable Output",
},
"blink": True,
+ "api_host": "",
"discovery": True,
"binary_sensors": [
{"zone": "1", "type": "door", "inverse": False},
@@ -713,6 +731,7 @@ async def test_option_flow(hass, mock_panel):
assert result["step_id"] == "options_switch"
assert result["description_placeholders"] == {
"zone": "Zone 4",
+ "state": "1",
}
# zone 4
@@ -723,6 +742,7 @@ async def test_option_flow(hass, mock_panel):
assert result["step_id"] == "options_switch"
assert result["description_placeholders"] == {
"zone": "OUT",
+ "state": "1",
}
# zone out
@@ -734,14 +754,47 @@ async def test_option_flow(hass, mock_panel):
"momentary": 50,
"pause": 100,
"repeat": 4,
+ "more_states": "Yes",
+ },
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "options_switch"
+ assert result["description_placeholders"] == {
+ "zone": "OUT",
+ "state": "2",
+ }
+
+ # zone out - state 2
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ "name": "alarm",
+ "activation": "low",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ "more_states": "No",
},
)
assert result["type"] == "form"
assert result["step_id"] == "options_misc"
-
+ # make sure we enforce url format
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"blink": True},
+ result["flow_id"],
+ user_input={"blink": True, "override_api_host": True, "api_host": "badhosturl"},
+ )
+
+ 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,
+ "override_api_host": True,
+ "api_host": "http://overridehost:1111",
+ },
)
assert result["type"] == "create_entry"
assert result["data"] == {
@@ -753,6 +806,7 @@ async def test_option_flow(hass, mock_panel):
"out": "Switchable Output",
},
"blink": True,
+ "api_host": "http://overridehost:1111",
"binary_sensors": [
{"zone": "2", "type": "door", "inverse": False},
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
@@ -768,6 +822,14 @@ async def test_option_flow(hass, mock_panel):
"pause": 100,
"repeat": 4,
},
+ {
+ "zone": "out",
+ "name": "alarm",
+ "activation": "low",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ },
],
}
@@ -911,7 +973,7 @@ async def test_option_flow_pro(hass, mock_panel):
assert result["step_id"] == "options_misc"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"blink": True},
+ result["flow_id"], user_input={"blink": True, "override_api_host": False},
)
assert result["type"] == "create_entry"
@@ -929,6 +991,7 @@ async def test_option_flow_pro(hass, mock_panel):
"out1": "Switchable Output",
},
"blink": True,
+ "api_host": "",
"binary_sensors": [
{"zone": "2", "type": "door", "inverse": False},
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
@@ -977,6 +1040,14 @@ async def test_option_flow_import(hass, mock_panel):
"pause": 100,
"repeat": 4,
},
+ {
+ "zone": "3",
+ "name": "alarm",
+ "activation": "low",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ },
],
}
)
@@ -1056,8 +1127,9 @@ async def test_option_flow_import(hass, mock_panel):
assert schema["momentary"] == 50
assert schema["pause"] == 100
assert schema["repeat"] == 4
+ assert schema["more_states"] == "Yes"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"activation": "high"}
+ result["flow_id"], user_input={"activation": "high", "more_states": "No"}
)
assert result["type"] == "form"
assert result["step_id"] == "options_misc"
@@ -1065,7 +1137,7 @@ async def test_option_flow_import(hass, mock_panel):
schema = result["data_schema"]({})
assert schema["blink"] is True
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"blink": False},
+ result["flow_id"], user_input={"blink": False, "override_api_host": False},
)
# verify the updated fields
@@ -1073,6 +1145,7 @@ async def test_option_flow_import(hass, mock_panel):
assert result["data"] == {
"io": {"1": "Binary Sensor", "2": "Digital Sensor", "3": "Switchable Output"},
"blink": False,
+ "api_host": "",
"binary_sensors": [
{"zone": "1", "type": "door", "inverse": True, "name": "winder"},
],
diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py
index e410aa9d60a..2a9c3f8cd4f 100644
--- a/tests/components/konnected/test_init.py
+++ b/tests/components/konnected/test_init.py
@@ -43,6 +43,7 @@ async def test_config_schema(hass):
"""Test that config schema is imported properly."""
config = {
konnected.DOMAIN: {
+ konnected.CONF_API_HOST: "http://1.1.1.1:8888",
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}],
}
@@ -50,10 +51,12 @@ async def test_config_schema(hass):
assert konnected.CONFIG_SCHEMA(config) == {
"konnected": {
"access_token": "abcdefgh",
+ "api_host": "http://1.1.1.1:8888",
"devices": [
{
"default_options": {
"blink": True,
+ "api_host": "http://1.1.1.1:8888",
"discovery": True,
"io": {
"1": "Disabled",
@@ -96,6 +99,7 @@ async def test_config_schema(hass):
{
"default_options": {
"blink": True,
+ "api_host": "",
"discovery": True,
"io": {
"1": "Disabled",
@@ -124,7 +128,7 @@ async def test_config_schema(hass):
}
}
- # check pin to zone
+ # check pin to zone and multiple output
config = {
konnected.DOMAIN: {
konnected.CONF_ACCESS_TOKEN: "abcdefgh",
@@ -135,6 +139,22 @@ async def test_config_schema(hass):
{"pin": 2, "type": "door"},
{"zone": 1, "type": "door"},
],
+ "switches": [
+ {
+ "zone": 3,
+ "name": "Beep Beep",
+ "momentary": 65,
+ "pause": 55,
+ "repeat": 4,
+ },
+ {
+ "zone": 3,
+ "name": "Warning",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ },
+ ],
}
],
}
@@ -146,6 +166,7 @@ async def test_config_schema(hass):
{
"default_options": {
"blink": True,
+ "api_host": "",
"discovery": True,
"io": {
"1": "Binary Sensor",
@@ -153,7 +174,7 @@ async def test_config_schema(hass):
"11": "Disabled",
"12": "Disabled",
"2": "Binary Sensor",
- "3": "Disabled",
+ "3": "Switchable Output",
"4": "Disabled",
"5": "Disabled",
"6": "Disabled",
@@ -169,6 +190,24 @@ async def test_config_schema(hass):
{"inverse": False, "type": "door", "zone": "2"},
{"inverse": False, "type": "door", "zone": "1"},
],
+ "switches": [
+ {
+ "zone": "3",
+ "activation": "high",
+ "name": "Beep Beep",
+ "momentary": 65,
+ "pause": 55,
+ "repeat": 4,
+ },
+ {
+ "zone": "3",
+ "activation": "high",
+ "name": "Warning",
+ "momentary": 100,
+ "pause": 100,
+ "repeat": -1,
+ },
+ ],
},
"id": "aabbccddeeff",
}
diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py
index 250a0fe26a8..1c40f352ff0 100644
--- a/tests/components/light/test_reproduce_state.py
+++ b/tests/components/light/test_reproduce_state.py
@@ -166,4 +166,4 @@ async def test_deprecation_warning(hass, caplog):
[State("light.entity_off", "on", {"brightness_pct": 80})], blocking=True
)
assert len(turn_on_calls) == 1
- assert DEPRECATION_WARNING in caplog.text
+ assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text
diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py
index 98653dc5a6c..cc07d6cf40f 100644
--- a/tests/components/logbook/test_init.py
+++ b/tests/components/logbook/test_init.py
@@ -1270,7 +1270,7 @@ class TestComponentLogbook(unittest.TestCase):
async def test_logbook_view(hass, hass_client):
"""Test the logbook view."""
- await hass.async_add_job(init_recorder_component, hass)
+ await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "logbook", {})
await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
client = await hass_client()
@@ -1280,7 +1280,7 @@ async def test_logbook_view(hass, hass_client):
async def test_logbook_view_period_entity(hass, hass_client):
"""Test the logbook view with period and entity."""
- await hass.async_add_job(init_recorder_component, hass)
+ await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "logbook", {})
await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py
index 775b2760c96..8509ad37fcd 100644
--- a/tests/components/lovelace/test_dashboard.py
+++ b/tests/components/lovelace/test_dashboard.py
@@ -165,7 +165,7 @@ async def test_system_health_info_autogen(hass):
"""Test system health info endpoint."""
assert await async_setup_component(hass, "lovelace", {})
info = await get_system_health_info(hass, "lovelace")
- assert info == {"mode": "auto-gen"}
+ assert info == {"dashboards": 1, "mode": "auto-gen", "resources": 0}
async def test_system_health_info_storage(hass, hass_storage):
@@ -177,7 +177,7 @@ async def test_system_health_info_storage(hass, hass_storage):
}
assert await async_setup_component(hass, "lovelace", {})
info = await get_system_health_info(hass, "lovelace")
- assert info == {"mode": "storage", "resources": 0, "views": 0}
+ assert info == {"dashboards": 1, "mode": "storage", "resources": 0, "views": 0}
async def test_system_health_info_yaml(hass):
@@ -188,7 +188,7 @@ async def test_system_health_info_yaml(hass):
return_value={"views": [{"cards": []}]},
):
info = await get_system_health_info(hass, "lovelace")
- assert info == {"mode": "yaml", "resources": 0, "views": 1}
+ assert info == {"dashboards": 1, "mode": "yaml", "resources": 0, "views": 1}
async def test_system_health_info_yaml_not_found(hass):
@@ -196,8 +196,10 @@ async def test_system_health_info_yaml_not_found(hass):
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
info = await get_system_health_info(hass, "lovelace")
assert info == {
+ "dashboards": 1,
"mode": "yaml",
"error": "{} not found".format(hass.config.path("ui-lovelace.yaml")),
+ "resources": 0,
}
diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py
index 30626fbdcb0..bc49ed08109 100644
--- a/tests/components/minecraft_server/test_config_flow.py
+++ b/tests/components/minecraft_server/test_config_flow.py
@@ -1,5 +1,8 @@
"""Test the Minecraft Server config flow."""
+import asyncio
+
+import aiodns
from asynctest import patch
from mcstatus.pinger import PingResponse
@@ -19,6 +22,19 @@ from homeassistant.helpers.typing import HomeAssistantType
from tests.common import MockConfigEntry
+
+class QueryMock:
+ """Mock for result of aiodns.DNSResolver.query."""
+
+ def __init__(self):
+ """Set up query result mock."""
+ self.host = "mc.dummyserver.com"
+ self.port = 23456
+ self.priority = 1
+ self.weight = 1
+ self.ttl = None
+
+
STATUS_RESPONSE_RAW = {
"description": {"text": "Dummy Description"},
"version": {"name": "Dummy Version", "protocol": 123},
@@ -35,34 +51,34 @@ STATUS_RESPONSE_RAW = {
USER_INPUT = {
CONF_NAME: DEFAULT_NAME,
- CONF_HOST: "mc.dummyserver.com",
- CONF_PORT: DEFAULT_PORT,
+ CONF_HOST: f"mc.dummyserver.com:{DEFAULT_PORT}",
}
+USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: "dummyserver.com"}
+
USER_INPUT_IPV4 = {
CONF_NAME: DEFAULT_NAME,
- CONF_HOST: "1.1.1.1",
- CONF_PORT: DEFAULT_PORT,
+ CONF_HOST: f"1.1.1.1:{DEFAULT_PORT}",
}
USER_INPUT_IPV6 = {
CONF_NAME: DEFAULT_NAME,
- CONF_HOST: "::ffff:0101:0101",
- CONF_PORT: DEFAULT_PORT,
+ CONF_HOST: f"[::ffff:0101:0101]:{DEFAULT_PORT}",
}
USER_INPUT_PORT_TOO_SMALL = {
CONF_NAME: DEFAULT_NAME,
- CONF_HOST: "mc.dummyserver.com",
- CONF_PORT: 1023,
+ CONF_HOST: f"mc.dummyserver.com:1023",
}
USER_INPUT_PORT_TOO_LARGE = {
CONF_NAME: DEFAULT_NAME,
- CONF_HOST: "mc.dummyserver.com",
- CONF_PORT: 65536,
+ CONF_HOST: f"mc.dummyserver.com:65536",
}
+SRV_RECORDS = asyncio.Future()
+SRV_RECORDS.set_result([QueryMock()])
+
async def test_show_config_form(hass: HomeAssistantType) -> None:
"""Test if initial configuration form is shown."""
@@ -87,54 +103,96 @@ async def test_invalid_ip(hass: HomeAssistantType) -> None:
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)
+ with patch(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
+ with patch(
+ "mcstatus.server.MinecraftServer.status",
+ return_value=PingResponse(STATUS_RESPONSE_RAW),
+ ):
+ unique_id = "mc.dummyserver.com-25565"
+ config_data = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: "mc.dummyserver.com",
+ CONF_PORT: DEFAULT_PORT,
+ }
+ mock_config_entry = MockConfigEntry(
+ domain=DOMAIN, unique_id=unique_id, data=config_data
+ )
+ mock_config_entry.add_to_hass(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT
- )
+ 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"
+ 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
- )
+ with patch(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
+ 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"}
+ 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
- )
+ with patch(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
+ 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"}
+ 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
- )
+ with patch(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
+ with patch("mcstatus.server.MinecraftServer.status", 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"}
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_connection_succeeded_with_srv_record(hass: HomeAssistantType) -> None:
+ """Test config entry in case of a successful connection with a SRV record."""
+ with patch(
+ "aiodns.DNSResolver.query", return_value=SRV_RECORDS,
+ ):
+ 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_SRV
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == USER_INPUT_SRV[CONF_HOST]
+ assert result["data"][CONF_NAME] == USER_INPUT_SRV[CONF_NAME]
+ assert result["data"][CONF_HOST] == USER_INPUT_SRV[CONF_HOST]
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(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
with patch(
"mcstatus.server.MinecraftServer.status",
return_value=PingResponse(STATUS_RESPONSE_RAW),
@@ -144,16 +202,17 @@ async def test_connection_succeeded_with_host(hass: HomeAssistantType) -> None:
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == f"{USER_INPUT[CONF_HOST]}:{USER_INPUT[CONF_PORT]}"
+ assert result["title"] == USER_INPUT[CONF_HOST]
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]
+ assert result["data"][CONF_HOST] == "mc.dummyserver.com"
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(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
with patch(
"mcstatus.server.MinecraftServer.status",
return_value=PingResponse(STATUS_RESPONSE_RAW),
@@ -163,19 +222,17 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistantType) -> None:
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert (
- result["title"]
- == f"{USER_INPUT_IPV4[CONF_HOST]}:{USER_INPUT_IPV4[CONF_PORT]}"
- )
+ assert result["title"] == USER_INPUT_IPV4[CONF_HOST]
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]
+ assert result["data"][CONF_HOST] == "1.1.1.1"
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(
+ "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ ):
with patch(
"mcstatus.server.MinecraftServer.status",
return_value=PingResponse(STATUS_RESPONSE_RAW),
@@ -185,10 +242,6 @@ async def test_connection_succeeded_with_ip6(hass: HomeAssistantType) -> None:
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert (
- result["title"]
- == f"{USER_INPUT_IPV6[CONF_HOST]}:{USER_INPUT_IPV6[CONF_PORT]}"
- )
+ assert result["title"] == USER_INPUT_IPV6[CONF_HOST]
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]
+ assert result["data"][CONF_HOST] == "::ffff:0101:0101"
diff --git a/tests/components/modbus/test_modbus_sensor.py b/tests/components/modbus/test_modbus_sensor.py
index 16d8f9a1936..1c4094387a9 100644
--- a/tests/components/modbus/test_modbus_sensor.py
+++ b/tests/components/modbus/test_modbus_sensor.py
@@ -4,10 +4,12 @@ from unittest import mock
import pytest
-from homeassistant.components.modbus import DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
-from homeassistant.components.modbus.sensor import (
+from homeassistant.components.modbus.const import (
+ CALL_TYPE_REGISTER_HOLDING,
+ CALL_TYPE_REGISTER_INPUT,
CONF_COUNT,
CONF_DATA_TYPE,
+ CONF_OFFSET,
CONF_PRECISION,
CONF_REGISTER,
CONF_REGISTER_TYPE,
@@ -17,16 +19,10 @@ from homeassistant.components.modbus.sensor import (
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_UINT,
- DEFAULT_REGISTER_TYPE_HOLDING,
- DEFAULT_REGISTER_TYPE_INPUT,
-)
-from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.const import (
- CONF_NAME,
- CONF_OFFSET,
- CONF_PLATFORM,
- CONF_SCAN_INTERVAL,
+ DEFAULT_HUB,
+ MODBUS_DOMAIN,
)
+from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -61,7 +57,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
sensor_name = "modbus_test_sensor"
scan_interval = 5
config = {
- SENSOR_DOMAIN: {
+ MODBUS_DOMAIN: {
CONF_PLATFORM: "modbus",
CONF_SCAN_INTERVAL: scan_interval,
CONF_REGISTERS: [
@@ -72,7 +68,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) == DEFAULT_REGISTER_TYPE_INPUT:
+ if register_config.get(CONF_REGISTER_TYPE) == CALL_TYPE_REGISTER_INPUT:
mock_hub.read_input_registers.return_value = read_result
else:
mock_hub.read_holding_registers.return_value = read_result
@@ -80,7 +76,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
# Initialize sensor
now = dt_util.utcnow()
with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now):
- assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ assert await async_setup_component(hass, MODBUS_DOMAIN, config)
# Trigger update call with time_changed event
now += timedelta(seconds=scan_interval + 1)
@@ -88,11 +84,6 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
- # Check state
- entity_id = f"{SENSOR_DOMAIN}.{sensor_name}"
- state = hass.states.get(entity_id).state
- assert state == expected
-
async def test_simple_word_register(hass, mock_hub):
"""Test conversion of single word register."""
@@ -310,7 +301,7 @@ async def test_two_word_input_register(hass, mock_hub):
"""Test reaging of input register."""
register_config = {
CONF_COUNT: 2,
- CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_INPUT,
+ CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_INPUT,
CONF_DATA_TYPE: DATA_TYPE_UINT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
@@ -329,7 +320,7 @@ async def test_two_word_holding_register(hass, mock_hub):
"""Test reaging of holding register."""
register_config = {
CONF_COUNT: 2,
- CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING,
+ CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING,
CONF_DATA_TYPE: DATA_TYPE_UINT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
@@ -348,7 +339,7 @@ async def test_float_data_type(hass, mock_hub):
"""Test floating point register data type."""
register_config = {
CONF_COUNT: 2,
- CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING,
+ CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING,
CONF_DATA_TYPE: DATA_TYPE_FLOAT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
diff --git a/tests/components/monoprice/test_config_flow.py b/tests/components/monoprice/test_config_flow.py
new file mode 100644
index 00000000000..ecafa17e174
--- /dev/null
+++ b/tests/components/monoprice/test_config_flow.py
@@ -0,0 +1,119 @@
+"""Test the Monoprice 6-Zone Amplifier config flow."""
+from asynctest import patch
+from serial import SerialException
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.monoprice.const import (
+ CONF_SOURCE_1,
+ CONF_SOURCE_4,
+ CONF_SOURCE_5,
+ CONF_SOURCES,
+ DOMAIN,
+)
+from homeassistant.const import CONF_PORT
+
+from tests.common import MockConfigEntry
+
+CONFIG = {
+ CONF_PORT: "/test/port",
+ CONF_SOURCE_1: "one",
+ CONF_SOURCE_4: "four",
+ CONF_SOURCE_5: " ",
+}
+
+
+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.monoprice.config_flow.get_async_monoprice",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.monoprice.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.monoprice.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], CONFIG
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == CONFIG[CONF_PORT]
+ assert result2["data"] == {
+ CONF_PORT: CONFIG[CONF_PORT],
+ CONF_SOURCES: {"1": CONFIG[CONF_SOURCE_1], "4": CONFIG[CONF_SOURCE_4]},
+ }
+ 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_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.monoprice.config_flow.get_async_monoprice",
+ side_effect=SerialException,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], CONFIG
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_generic_exception(hass):
+ """Test we handle cannot generic exception."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.monoprice.config_flow.get_async_monoprice",
+ side_effect=Exception,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], CONFIG
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_options_flow(hass):
+ """Test config flow options."""
+ conf = {CONF_PORT: "/test/port", CONF_SOURCES: {"4": "four"}}
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ # unique_id="abcde12345",
+ data=conf,
+ # options={CONF_SHOW_ON_MAP: True},
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.monoprice.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={CONF_SOURCE_1: "one", CONF_SOURCE_4: "", CONF_SOURCE_5: "five"},
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options[CONF_SOURCES] == {"1": "one", "5": "five"}
diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py
index e6747dfc4bf..3778f2af04b 100644
--- a/tests/components/monoprice/test_media_player.py
+++ b/tests/components/monoprice/test_media_player.py
@@ -1,12 +1,15 @@
"""The tests for Monoprice Media player platform."""
from collections import defaultdict
-import unittest
-from unittest import mock
-import pytest
-import voluptuous as vol
+from asynctest import patch
+from serial import SerialException
from homeassistant.components.media_player.const import (
+ ATTR_INPUT_SOURCE,
+ ATTR_INPUT_SOURCE_LIST,
+ ATTR_MEDIA_VOLUME_LEVEL,
+ DOMAIN as MEDIA_PLAYER_DOMAIN,
+ SERVICE_SELECT_SOURCE,
SUPPORT_SELECT_SOURCE,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
@@ -15,18 +18,29 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_STEP,
)
from homeassistant.components.monoprice.const import (
+ CONF_SOURCES,
DOMAIN,
SERVICE_RESTORE,
SERVICE_SNAPSHOT,
)
-from homeassistant.components.monoprice.media_player import (
- DATA_MONOPRICE,
- PLATFORM_SCHEMA,
- setup_platform,
+from homeassistant.const import (
+ CONF_PORT,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ SERVICE_VOLUME_DOWN,
+ SERVICE_VOLUME_MUTE,
+ SERVICE_VOLUME_SET,
+ SERVICE_VOLUME_UP,
)
-from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.entity_component import async_update_entity
-import tests.common
+from tests.common import MockConfigEntry
+
+MOCK_CONFIG = {CONF_PORT: "fake port", CONF_SOURCES: {"1": "one", "3": "three"}}
+MOCK_OPTIONS = {CONF_SOURCES: {"2": "two", "4": "four"}}
+
+ZONE_1_ID = "media_player.zone_11"
+ZONE_2_ID = "media_player.zone_12"
class AttrDict(dict):
@@ -77,426 +91,339 @@ class MockMonoprice:
self.zones[zone.zone] = AttrDict(zone)
-class TestMonopriceSchema(unittest.TestCase):
- """Test Monoprice schema."""
+async def test_cannot_connect(hass):
+ """Test connection error."""
- def test_valid_schema(self):
- """Test valid schema."""
- valid_schema = {
- "platform": "monoprice",
- "port": "/dev/ttyUSB0",
- "zones": {
- 11: {"name": "a"},
- 12: {"name": "a"},
- 13: {"name": "a"},
- 14: {"name": "a"},
- 15: {"name": "a"},
- 16: {"name": "a"},
- 21: {"name": "a"},
- 22: {"name": "a"},
- 23: {"name": "a"},
- 24: {"name": "a"},
- 25: {"name": "a"},
- 26: {"name": "a"},
- 31: {"name": "a"},
- 32: {"name": "a"},
- 33: {"name": "a"},
- 34: {"name": "a"},
- 35: {"name": "a"},
- 36: {"name": "a"},
- },
- "sources": {
- 1: {"name": "a"},
- 2: {"name": "a"},
- 3: {"name": "a"},
- 4: {"name": "a"},
- 5: {"name": "a"},
- 6: {"name": "a"},
- },
- }
- PLATFORM_SCHEMA(valid_schema)
+ with patch(
+ "homeassistant.components.monoprice.get_monoprice", side_effect=SerialException,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ # setup_component(self.hass, DOMAIN, MOCK_CONFIG)
+ # self.hass.async_block_till_done()
+ await hass.async_block_till_done()
+ assert hass.states.get(ZONE_1_ID) is None
- def test_invalid_schemas(self):
- """Test invalid schemas."""
- schemas = (
- {}, # Empty
- None, # None
- # Missing port
- {
- "platform": "monoprice",
- "name": "Name",
- "zones": {11: {"name": "a"}},
- "sources": {1: {"name": "b"}},
- },
- # Invalid zone number
- {
- "platform": "monoprice",
- "port": "aaa",
- "name": "Name",
- "zones": {10: {"name": "a"}},
- "sources": {1: {"name": "b"}},
- },
- # Invalid source number
- {
- "platform": "monoprice",
- "port": "aaa",
- "name": "Name",
- "zones": {11: {"name": "a"}},
- "sources": {0: {"name": "b"}},
- },
- # Zone missing name
- {
- "platform": "monoprice",
- "port": "aaa",
- "name": "Name",
- "zones": {11: {}},
- "sources": {1: {"name": "b"}},
- },
- # Source missing name
- {
- "platform": "monoprice",
- "port": "aaa",
- "name": "Name",
- "zones": {11: {"name": "a"}},
- "sources": {1: {}},
- },
+
+async def _setup_monoprice(hass, monoprice):
+ with patch(
+ "homeassistant.components.monoprice.get_monoprice", new=lambda *a: monoprice,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ # setup_component(self.hass, DOMAIN, MOCK_CONFIG)
+ # self.hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+
+async def _setup_monoprice_with_options(hass, monoprice):
+ with patch(
+ "homeassistant.components.monoprice.get_monoprice", new=lambda *a: monoprice,
+ ):
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS
)
- for value in schemas:
- with pytest.raises(vol.MultipleInvalid):
- PLATFORM_SCHEMA(value)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ # setup_component(self.hass, DOMAIN, MOCK_CONFIG)
+ # self.hass.async_block_till_done()
+ await hass.async_block_till_done()
-class TestMonopriceMediaPlayer(unittest.TestCase):
- """Test the media_player module."""
+async def _call_media_player_service(hass, name, data):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, name, service_data=data, blocking=True
+ )
- def setUp(self):
- """Set up the test case."""
- self.monoprice = MockMonoprice()
- self.hass = tests.common.get_test_home_assistant()
- self.hass.start()
- # Note, source dictionary is unsorted!
- with mock.patch(
- "homeassistant.components.monoprice.media_player.get_monoprice",
- new=lambda *a: self.monoprice,
- ):
- setup_platform(
- self.hass,
- {
- "platform": "monoprice",
- "port": "/dev/ttyS0",
- "name": "Name",
- "zones": {12: {"name": "Zone name"}},
- "sources": {
- 1: {"name": "one"},
- 3: {"name": "three"},
- 2: {"name": "two"},
- },
- },
- lambda *args, **kwargs: None,
- {},
- )
- self.hass.block_till_done()
- self.media_player = self.hass.data[DATA_MONOPRICE][0]
- self.media_player.hass = self.hass
- self.media_player.entity_id = "media_player.zone_1"
- def tearDown(self):
- """Tear down the test case."""
- self.hass.stop()
+async def _call_homeassistant_service(hass, name, data):
+ await hass.services.async_call(
+ "homeassistant", name, service_data=data, blocking=True
+ )
- def test_setup_platform(self, *args):
- """Test setting up platform."""
- # Two services must be registered
- assert self.hass.services.has_service(DOMAIN, SERVICE_RESTORE)
- assert self.hass.services.has_service(DOMAIN, SERVICE_SNAPSHOT)
- assert len(self.hass.data[DATA_MONOPRICE]) == 1
- assert self.hass.data[DATA_MONOPRICE][0].name == "Zone name"
- def test_service_calls_with_entity_id(self):
- """Test snapshot save/restore service calls."""
- self.media_player.update()
- assert "Zone name" == self.media_player.name
- assert STATE_ON == self.media_player.state
- assert 0.0 == self.media_player.volume_level, 0.0001
- assert self.media_player.is_volume_muted
- assert "one" == self.media_player.source
+async def _call_monoprice_service(hass, name, data):
+ await hass.services.async_call(DOMAIN, name, service_data=data, blocking=True)
- # Saving default values
- self.hass.services.call(
- DOMAIN,
- SERVICE_SNAPSHOT,
- {"entity_id": "media_player.zone_1"},
- blocking=True,
- )
- # self.hass.block_till_done()
- # Changing media player to new state
- self.media_player.set_volume_level(1)
- self.media_player.select_source("two")
- self.media_player.mute_volume(False)
- self.media_player.turn_off()
+async def test_service_calls_with_entity_id(hass):
+ """Test snapshot save/restore service calls."""
+ await _setup_monoprice(hass, MockMonoprice())
- # Checking that values were indeed changed
- self.media_player.update()
- assert "Zone name" == self.media_player.name
- assert STATE_OFF == self.media_player.state
- assert 1.0 == self.media_player.volume_level, 0.0001
- assert not self.media_player.is_volume_muted
- assert "two" == self.media_player.source
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"}
+ )
- # Restoring wrong media player to its previous state
- # Nothing should be done
- self.hass.services.call(
- DOMAIN, SERVICE_RESTORE, {"entity_id": "media.not_existing"}, blocking=True
- )
- # self.hass.block_till_done()
+ # Saving existing values
+ await _call_monoprice_service(hass, SERVICE_SNAPSHOT, {"entity_id": ZONE_1_ID})
- # Checking that values were not (!) restored
- self.media_player.update()
- assert "Zone name" == self.media_player.name
- assert STATE_OFF == self.media_player.state
- assert 1.0 == self.media_player.volume_level, 0.0001
- assert not self.media_player.is_volume_muted
- assert "two" == self.media_player.source
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"}
+ )
- # Restoring media player to its previous state
- self.hass.services.call(
- DOMAIN, SERVICE_RESTORE, {"entity_id": "media_player.zone_1"}, blocking=True
- )
- self.hass.block_till_done()
+ # Restoring other media player to its previous state
+ # The zone should not be restored
+ await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_2_ID})
+ await hass.async_block_till_done()
- # Checking that values were restored
- assert "Zone name" == self.media_player.name
- assert STATE_ON == self.media_player.state
- assert 0.0 == self.media_player.volume_level, 0.0001
- assert self.media_player.is_volume_muted
- assert "one" == self.media_player.source
+ # Checking that values were not (!) restored
+ state = hass.states.get(ZONE_1_ID)
- def test_service_calls_without_entity_id(self):
- """Test snapshot save/restore service calls."""
- self.media_player.update()
- assert "Zone name" == self.media_player.name
- assert STATE_ON == self.media_player.state
- assert 0.0 == self.media_player.volume_level, 0.0001
- assert self.media_player.is_volume_muted
- assert "one" == self.media_player.source
+ assert 1.0 == state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
+ assert "three" == state.attributes[ATTR_INPUT_SOURCE]
- # Restoring media player
- # since there is no snapshot, nothing should be done
- self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True)
- self.hass.block_till_done()
- self.media_player.update()
- assert "Zone name" == self.media_player.name
- assert STATE_ON == self.media_player.state
- assert 0.0 == self.media_player.volume_level, 0.0001
- assert self.media_player.is_volume_muted
- assert "one" == self.media_player.source
+ # Restoring media player to its previous state
+ await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID})
+ await hass.async_block_till_done()
- # Saving default values
- self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT, blocking=True)
- self.hass.block_till_done()
+ state = hass.states.get(ZONE_1_ID)
- # Changing media player to new state
- self.media_player.set_volume_level(1)
- self.media_player.select_source("two")
- self.media_player.mute_volume(False)
- self.media_player.turn_off()
+ assert 0.0 == state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
+ assert "one" == state.attributes[ATTR_INPUT_SOURCE]
- # Checking that values were indeed changed
- self.media_player.update()
- assert "Zone name" == self.media_player.name
- assert STATE_OFF == self.media_player.state
- assert 1.0 == self.media_player.volume_level, 0.0001
- assert not self.media_player.is_volume_muted
- assert "two" == self.media_player.source
- # Restoring media player to its previous state
- self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True)
- self.hass.block_till_done()
+async def test_service_calls_with_all_entities(hass):
+ """Test snapshot save/restore service calls."""
+ await _setup_monoprice(hass, MockMonoprice())
- # Checking that values were restored
- assert "Zone name" == self.media_player.name
- assert STATE_ON == self.media_player.state
- assert 0.0 == self.media_player.volume_level, 0.0001
- assert self.media_player.is_volume_muted
- assert "one" == self.media_player.source
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"}
+ )
- def test_update(self):
- """Test updating values from monoprice."""
- assert self.media_player.state is None
- assert self.media_player.volume_level is None
- assert self.media_player.is_volume_muted is None
- assert self.media_player.source is None
+ # Saving existing values
+ await _call_monoprice_service(hass, SERVICE_SNAPSHOT, {"entity_id": "all"})
- self.media_player.update()
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"}
+ )
- assert STATE_ON == self.media_player.state
- assert 0.0 == self.media_player.volume_level, 0.0001
- assert self.media_player.is_volume_muted
- assert "one" == self.media_player.source
+ # Restoring media player to its previous state
+ await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": "all"})
+ await hass.async_block_till_done()
- def test_name(self):
- """Test name property."""
- assert "Zone name" == self.media_player.name
+ state = hass.states.get(ZONE_1_ID)
- def test_state(self):
- """Test state property."""
- assert self.media_player.state is None
+ assert 0.0 == state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
+ assert "one" == state.attributes[ATTR_INPUT_SOURCE]
- self.media_player.update()
- assert STATE_ON == self.media_player.state
- self.monoprice.zones[12].power = False
- self.media_player.update()
- assert STATE_OFF == self.media_player.state
+async def test_service_calls_without_relevant_entities(hass):
+ """Test snapshot save/restore service calls."""
+ await _setup_monoprice(hass, MockMonoprice())
- def test_volume_level(self):
- """Test volume level property."""
- assert self.media_player.volume_level is None
- self.media_player.update()
- assert 0.0 == self.media_player.volume_level, 0.0001
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"}
+ )
- self.monoprice.zones[12].volume = 38
- self.media_player.update()
- assert 1.0 == self.media_player.volume_level, 0.0001
+ # Saving existing values
+ await _call_monoprice_service(hass, SERVICE_SNAPSHOT, {"entity_id": "all"})
- self.monoprice.zones[12].volume = 19
- self.media_player.update()
- assert 0.5 == self.media_player.volume_level, 0.0001
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"}
+ )
- def test_is_volume_muted(self):
- """Test volume muted property."""
- assert self.media_player.is_volume_muted is None
+ # Restoring media player to its previous state
+ await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": "light.demo"})
+ await hass.async_block_till_done()
- self.media_player.update()
- assert self.media_player.is_volume_muted
+ state = hass.states.get(ZONE_1_ID)
- self.monoprice.zones[12].mute = False
- self.media_player.update()
- assert not self.media_player.is_volume_muted
+ assert 1.0 == state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
+ assert "three" == state.attributes[ATTR_INPUT_SOURCE]
- def test_supported_features(self):
- """Test supported features property."""
- assert (
- SUPPORT_VOLUME_MUTE
- | SUPPORT_VOLUME_SET
- | SUPPORT_VOLUME_STEP
- | SUPPORT_TURN_ON
- | SUPPORT_TURN_OFF
- | SUPPORT_SELECT_SOURCE
- == self.media_player.supported_features
- )
- def test_source(self):
- """Test source property."""
- assert self.media_player.source is None
- self.media_player.update()
- assert "one" == self.media_player.source
+async def test_restore_without_snapshort(hass):
+ """Test restore when snapshot wasn't called."""
+ await _setup_monoprice(hass, MockMonoprice())
- def test_media_title(self):
- """Test media title property."""
- assert self.media_player.media_title is None
- self.media_player.update()
- assert "one" == self.media_player.media_title
+ with patch.object(MockMonoprice, "restore_zone") as method_call:
+ await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID})
+ await hass.async_block_till_done()
- def test_source_list(self):
- """Test source list property."""
- # Note, the list is sorted!
- assert ["one", "two", "three"] == self.media_player.source_list
+ assert not method_call.called
- def test_select_source(self):
- """Test source selection methods."""
- self.media_player.update()
- assert "one" == self.media_player.source
+async def test_update(hass):
+ """Test updating values from monoprice."""
+ monoprice = MockMonoprice()
+ await _setup_monoprice(hass, monoprice)
- self.media_player.select_source("two")
- assert 2 == self.monoprice.zones[12].source
- self.media_player.update()
- assert "two" == self.media_player.source
+ # Changing media player to new state
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"}
+ )
- # Trying to set unknown source
- self.media_player.select_source("no name")
- assert 2 == self.monoprice.zones[12].source
- self.media_player.update()
- assert "two" == self.media_player.source
+ monoprice.set_source(11, 3)
+ monoprice.set_volume(11, 38)
- def test_turn_on(self):
- """Test turning on the zone."""
- self.monoprice.zones[12].power = False
- self.media_player.update()
- assert STATE_OFF == self.media_player.state
+ await async_update_entity(hass, ZONE_1_ID)
+ await hass.async_block_till_done()
- self.media_player.turn_on()
- assert self.monoprice.zones[12].power
- self.media_player.update()
- assert STATE_ON == self.media_player.state
+ state = hass.states.get(ZONE_1_ID)
- def test_turn_off(self):
- """Test turning off the zone."""
- self.monoprice.zones[12].power = True
- self.media_player.update()
- assert STATE_ON == self.media_player.state
+ assert 1.0 == state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
+ assert "three" == state.attributes[ATTR_INPUT_SOURCE]
- self.media_player.turn_off()
- assert not self.monoprice.zones[12].power
- self.media_player.update()
- assert STATE_OFF == self.media_player.state
- def test_mute_volume(self):
- """Test mute functionality."""
- self.monoprice.zones[12].mute = True
- self.media_player.update()
- assert self.media_player.is_volume_muted
+async def test_supported_features(hass):
+ """Test supported features property."""
+ await _setup_monoprice(hass, MockMonoprice())
- self.media_player.mute_volume(False)
- assert not self.monoprice.zones[12].mute
- self.media_player.update()
- assert not self.media_player.is_volume_muted
+ state = hass.states.get(ZONE_1_ID)
+ assert (
+ SUPPORT_VOLUME_MUTE
+ | SUPPORT_VOLUME_SET
+ | SUPPORT_VOLUME_STEP
+ | SUPPORT_TURN_ON
+ | SUPPORT_TURN_OFF
+ | SUPPORT_SELECT_SOURCE
+ == state.attributes["supported_features"]
+ )
- self.media_player.mute_volume(True)
- assert self.monoprice.zones[12].mute
- self.media_player.update()
- assert self.media_player.is_volume_muted
- def test_set_volume_level(self):
- """Test set volume level."""
- self.media_player.set_volume_level(1.0)
- assert 38 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
+async def test_source_list(hass):
+ """Test source list property."""
+ await _setup_monoprice(hass, MockMonoprice())
- self.media_player.set_volume_level(0.0)
- assert 0 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
+ state = hass.states.get(ZONE_1_ID)
+ # Note, the list is sorted!
+ assert ["one", "three"] == state.attributes[ATTR_INPUT_SOURCE_LIST]
- self.media_player.set_volume_level(0.5)
- assert 19 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
- def test_volume_up(self):
- """Test increasing volume by one."""
- self.monoprice.zones[12].volume = 37
- self.media_player.update()
- self.media_player.volume_up()
- assert 38 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
+async def test_source_list_with_options(hass):
+ """Test source list property."""
+ await _setup_monoprice_with_options(hass, MockMonoprice())
- # Try to raise value beyond max
- self.media_player.update()
- self.media_player.volume_up()
- assert 38 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
+ state = hass.states.get(ZONE_1_ID)
+ # Note, the list is sorted!
+ assert ["two", "four"] == state.attributes[ATTR_INPUT_SOURCE_LIST]
- def test_volume_down(self):
- """Test decreasing volume by one."""
- self.monoprice.zones[12].volume = 1
- self.media_player.update()
- self.media_player.volume_down()
- assert 0 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
- # Try to lower value beyond minimum
- self.media_player.update()
- self.media_player.volume_down()
- assert 0 == self.monoprice.zones[12].volume
- assert isinstance(self.monoprice.zones[12].volume, int)
+async def test_select_source(hass):
+ """Test source selection methods."""
+ monoprice = MockMonoprice()
+ await _setup_monoprice(hass, monoprice)
+
+ await _call_media_player_service(
+ hass,
+ SERVICE_SELECT_SOURCE,
+ {"entity_id": ZONE_1_ID, ATTR_INPUT_SOURCE: "three"},
+ )
+ assert 3 == monoprice.zones[11].source
+
+ # Trying to set unknown source
+ await _call_media_player_service(
+ hass,
+ SERVICE_SELECT_SOURCE,
+ {"entity_id": ZONE_1_ID, ATTR_INPUT_SOURCE: "no name"},
+ )
+ assert 3 == monoprice.zones[11].source
+
+
+async def test_unknown_source(hass):
+ """Test behavior when device has unknown source."""
+ monoprice = MockMonoprice()
+ await _setup_monoprice(hass, monoprice)
+
+ monoprice.set_source(11, 5)
+
+ await async_update_entity(hass, ZONE_1_ID)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ZONE_1_ID)
+
+ assert state.attributes.get(ATTR_INPUT_SOURCE) is None
+
+
+async def test_turn_on_off(hass):
+ """Test turning on the zone."""
+ monoprice = MockMonoprice()
+ await _setup_monoprice(hass, monoprice)
+
+ await _call_media_player_service(hass, SERVICE_TURN_OFF, {"entity_id": ZONE_1_ID})
+ assert not monoprice.zones[11].power
+
+ await _call_media_player_service(hass, SERVICE_TURN_ON, {"entity_id": ZONE_1_ID})
+ assert monoprice.zones[11].power
+
+
+async def test_mute_volume(hass):
+ """Test mute functionality."""
+ monoprice = MockMonoprice()
+ await _setup_monoprice(hass, monoprice)
+
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.5}
+ )
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": False}
+ )
+ assert not monoprice.zones[11].mute
+
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True}
+ )
+ assert monoprice.zones[11].mute
+
+
+async def test_volume_up_down(hass):
+ """Test increasing volume by one."""
+ monoprice = MockMonoprice()
+ await _setup_monoprice(hass, monoprice)
+
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0}
+ )
+ assert 0 == monoprice.zones[11].volume
+
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID}
+ )
+ # should not go below zero
+ assert 0 == monoprice.zones[11].volume
+
+ await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID})
+ assert 1 == monoprice.zones[11].volume
+
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0}
+ )
+ assert 38 == monoprice.zones[11].volume
+
+ await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID})
+ # should not go above 38
+ assert 38 == monoprice.zones[11].volume
+
+ await _call_media_player_service(
+ hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID}
+ )
+ assert 37 == monoprice.zones[11].volume
diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py
deleted file mode 100644
index f8de0faf82f..00000000000
--- a/tests/components/mqtt/common.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""Common test objects."""
-import json
-from unittest.mock import ANY
-
-from homeassistant.components import mqtt
-from homeassistant.components.mqtt.discovery import async_start
-
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- async_setup_component,
- mock_registry,
-)
-
-
-async def help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, domain, config
-):
- """Test the setting of attribute via MQTT with JSON payload.
-
- This is a test helper for the MqttAttributes mixin.
- """
- assert await async_setup_component(hass, domain, config,)
-
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get(f"{domain}.test")
-
- assert state.attributes.get("val") == "100"
-
-
-async def help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, domain, config
-):
- """Test attributes get extracted from a JSON result.
-
- This is a test helper for the MqttAttributes mixin.
- """
- assert await async_setup_component(hass, domain, config,)
-
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get(f"{domain}.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
-
-async def help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, domain, config
-):
- """Test JSON validation of attributes.
-
- This is a test helper for the MqttAttributes mixin.
- """
- assert await async_setup_component(hass, domain, config,)
-
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get(f"{domain}.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
-
-async def help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, domain, data1, data2
-):
- """Test update of discovered MQTTAttributes.
-
- This is a test helper for the MqttAttributes mixin.
- """
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get(f"{domain}.test")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
- await hass.async_block_till_done()
-
- # Verify we are no longer subscribing to the old topic
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }')
- state = hass.states.get(f"{domain}.test")
- assert state.attributes.get("val") == "100"
-
- # Verify we are subscribing to the new topic
- async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }')
- state = hass.states.get(f"{domain}.test")
- assert state.attributes.get("val") == "75"
-
-
-async def help_test_unique_id(hass, domain, config):
- """Test unique id option only creates one entity per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(hass, domain, config,)
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(domain)) == 1
-
-
-async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data):
- """Test removal of discovered component.
-
- This is a test helper for the MqttDiscoveryUpdate mixin.
- """
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.test")
- assert state is not None
- assert state.name == "test"
-
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.test")
- assert state is None
-
-
-async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, data2):
- """Test update of discovered component.
-
- This is a test helper for the MqttDiscoveryUpdate mixin.
- """
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.beer")
- assert state is not None
- assert state.name == "Milk"
-
- state = hass.states.get(f"{domain}.milk")
- assert state is None
-
-
-async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, data2):
- """Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get(f"{domain}.beer")
- assert state is None
-
-
-async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, config):
- """Test device registry integration.
-
- This is a test helper for the MqttDiscoveryUpdate mixin.
- """
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- entry.add_to_hass(hass)
- await async_start(hass, "homeassistant", {}, entry)
- registry = await hass.helpers.device_registry.async_get_registry()
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/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 help_test_entity_device_info_update(hass, mqtt_mock, domain, config):
- """Test device registry update.
-
- This is a test helper for the MqttDiscoveryUpdate mixin.
- """
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- entry.add_to_hass(hass)
- await async_start(hass, "homeassistant", {}, entry)
- registry = await hass.helpers.device_registry.async_get_registry()
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/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, f"homeassistant/{domain}/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"
-
-
-async def help_test_entity_id_update(hass, mqtt_mock, domain, config):
- """Test MQTT subscriptions are managed when entity_id is updated."""
- registry = mock_registry(hass, {})
- mock_mqtt = await async_mock_mqtt_component(hass)
- assert await async_setup_component(hass, domain, config,)
-
- state = hass.states.get(f"{domain}.beer")
- assert state is not None
- assert mock_mqtt.async_subscribe.call_count == 2
- mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8")
- mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8")
- mock_mqtt.async_subscribe.reset_mock()
-
- registry.async_update_entity(f"{domain}.beer", new_entity_id=f"{domain}.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get(f"{domain}.beer")
- assert state is None
-
- state = hass.states.get(f"{domain}.milk")
- assert state is not None
- assert mock_mqtt.async_subscribe.call_count == 2
- mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8")
- mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8")
diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py
index 9a0df0bcd8d..45c123fa2fe 100644
--- a/tests/components/mqtt/test_alarm_control_panel.py
+++ b/tests/components/mqtt/test_alarm_control_panel.py
@@ -10,19 +10,25 @@ from homeassistant.const import (
STATE_ALARM_DISARMED,
STATE_ALARM_PENDING,
STATE_ALARM_TRIGGERED,
- STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -46,16 +52,6 @@ DEFAULT_CONFIG = {
}
}
-DEFAULT_CONFIG_ATTR = {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "json_attributes_topic": "attr-topic",
- }
-}
-
DEFAULT_CONFIG_CODE = {
alarm_control_panel.DOMAIN: {
"platform": "mqtt",
@@ -67,22 +63,6 @@ DEFAULT_CONFIG_CODE = {
}
}
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
-
async def test_fail_setup_without_state_topic(hass, mqtt_mock):
"""Test for failing with no state topic."""
@@ -330,48 +310,6 @@ async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_m
assert mqtt_mock.async_publish.call_count == call_count
-async def test_default_availability_payload(hass, mqtt_mock):
- """Test availability by default payload with defined topic."""
- config = copy.deepcopy(DEFAULT_CONFIG_CODE)
- config[alarm_control_panel.DOMAIN]["availability_topic"] = "availability-topic"
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.state == STATE_UNAVAILABLE
-
-
-async def test_custom_availability_payload(hass, mqtt_mock):
- """Test availability by custom payload with defined topic."""
- config = copy.deepcopy(DEFAULT_CONFIG)
- config[alarm_control_panel.DOMAIN]["availability_topic"] = "availability-topic"
- config[alarm_control_panel.DOMAIN]["payload_available"] = "good"
- config[alarm_control_panel.DOMAIN]["payload_not_available"] = "nogood"
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.state == STATE_UNAVAILABLE
-
-
async def test_update_state_via_state_topic_template(hass, mqtt_mock):
"""Test updating with template_value via state topic."""
assert await async_setup_component(
@@ -402,38 +340,59 @@ async def test_update_state_via_state_topic_template(hass, mqtt_mock):
assert state.state == STATE_ALARM_ARMED_AWAY
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE
+ )
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE
+ )
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE
+ )
+
+
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
- config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
- config1["json_attributes_topic"] = "attr-topic1"
- config2["json_attributes_topic"] = "attr-topic2"
- data1 = json.dumps(config1)
- data2 = json.dumps(config2)
-
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
@@ -495,34 +454,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT alarm control panel device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT alarm control panel device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- alarm_control_panel.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(
- hass, mqtt_mock, alarm_control_panel.DOMAIN, config
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG
)
diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py
index 2cc917c527b..a73919844c1 100644
--- a/tests/components/mqtt/test_binary_sensor.py
+++ b/tests/components/mqtt/test_binary_sensor.py
@@ -15,15 +15,22 @@ import homeassistant.core as ha
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -39,30 +46,6 @@ DEFAULT_CONFIG = {
}
}
-DEFAULT_CONFIG_ATTR = {
- binary_sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
-
async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog):
"""Test the expiration of the value."""
@@ -264,81 +247,24 @@ async def test_invalid_device_class(hass, mqtt_mock):
async def test_availability_without_topic(hass, mqtt_mock):
"""Test availability without defined availability topic."""
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- }
- },
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("binary_sensor.test")
- assert state.state != STATE_UNAVAILABLE
-
-async def test_availability_by_defaults(hass, mqtt_mock):
- """Test availability by defaults with defined topic."""
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "availability_topic": "availability-topic",
- }
- },
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("binary_sensor.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("binary_sensor.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("binary_sensor.test")
- assert state.state == STATE_UNAVAILABLE
-
-
-async def test_availability_by_custom_payload(hass, mqtt_mock):
+async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("binary_sensor.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("binary_sensor.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("binary_sensor.test")
- assert state.state == STATE_UNAVAILABLE
-
async def test_force_update_disabled(hass, mqtt_mock):
"""Test force update option."""
@@ -458,35 +384,35 @@ async def test_off_delay(hass, mqtt_mock):
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- config1 = copy.deepcopy(DEFAULT_CONFIG_ATTR[binary_sensor.DOMAIN])
- config2 = copy.deepcopy(DEFAULT_CONFIG_ATTR[binary_sensor.DOMAIN])
- config1["json_attributes_topic"] = "attr-topic1"
- config2["json_attributes_topic"] = "attr-topic2"
- data1 = json.dumps(config1)
- data2 = json.dumps(config2)
-
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
@@ -542,31 +468,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT binary sensor device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT binary sensor device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- binary_sensor.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, binary_sensor.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py
index 0e7d8ada759..21f552b4163 100644
--- a/tests/components/mqtt/test_camera.py
+++ b/tests/components/mqtt/test_camera.py
@@ -1,18 +1,41 @@
"""The tests for mqtt camera component."""
import json
-from unittest.mock import ANY
from homeassistant.components import camera, mqtt
from homeassistant.components.mqtt.discovery import async_start
from homeassistant.setup import async_setup_component
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
+)
+
from tests.common import (
MockConfigEntry,
async_fire_mqtt_message,
async_mock_mqtt_component,
- mock_registry,
)
+DEFAULT_CONFIG = {
+ camera.DOMAIN: {"platform": "mqtt", "name": "test", "topic": "test_topic"}
+}
+
async def test_run_camera_setup(hass, aiohttp_client):
"""Test that it fetches the given payload."""
@@ -35,53 +58,87 @@ async def test_run_camera_setup(hass, aiohttp_client):
assert body == "beer"
-async def test_unique_id(hass):
- """Test unique id option only creates one camera per unique_id."""
- await async_mock_mqtt_component(hass)
- await async_setup_component(
- hass,
- "camera",
- {
- "camera": [
- {
- "platform": "mqtt",
- "name": "Test Camera 1",
- "topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test Camera 2",
- "topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
)
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_all()) == 1
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, camera.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one camera per unique_id."""
+ config = {
+ camera.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, camera.DOMAIN, config)
async def test_discovery_removal_camera(hass, mqtt_mock, caplog):
"""Test removal of discovered camera."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data = '{ "name": "Beer",' ' "topic": "test_topic"}'
-
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data)
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.beer")
- assert state is None
+ data = json.dumps(DEFAULT_CONFIG[camera.DOMAIN])
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, camera.DOMAIN, data)
async def test_discovery_update_camera(hass, mqtt_mock, caplog):
@@ -92,21 +149,9 @@ async def test_discovery_update_camera(hass, mqtt_mock, caplog):
data1 = '{ "name": "Beer",' ' "topic": "test_topic"}'
data2 = '{ "name": "Milk",' ' "topic": "test_topic"}'
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("camera.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
@@ -117,130 +162,48 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
data1 = '{ "name": "Beer" }'
data2 = '{ "name": "Milk",' ' "topic": "test_topic"}'
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("camera.beer")
- assert state is None
-
-
-async def test_entity_id_update(hass, mqtt_mock):
- """Test MQTT subscriptions are managed when entity_id is updated."""
- registry = mock_registry(hass, {})
- mock_mqtt = await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- camera.DOMAIN,
- {
- camera.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2
)
- state = hass.states.get("camera.beer")
- assert state is not None
- assert mock_mqtt.async_subscribe.call_count == 1
- mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, None)
- mock_mqtt.async_subscribe.reset_mock()
- registry.async_update_entity("camera.beer", new_entity_id="camera.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("camera.beer")
- assert state is None
-
- state = hass.states.get("camera.milk")
- assert state is not None
- assert mock_mqtt.async_subscribe.call_count == 1
- mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, None)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT camera device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT camera device registry integration."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- entry.add_to_hass(hass)
- await async_start(hass, "homeassistant", {}, entry)
- registry = await hass.helpers.device_registry.async_get_registry()
-
- data = json.dumps(
- {
- "platform": "mqtt",
- "name": "Test 1",
- "topic": "test-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
)
- async_fire_mqtt_message(hass, "homeassistant/camera/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=mqtt.DOMAIN)
- entry.add_to_hass(hass)
- await async_start(hass, "homeassistant", {}, entry)
- registry = await hass.helpers.device_registry.async_get_registry()
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
- config = {
- "platform": "mqtt",
- "name": "Test 1",
- "topic": "test-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
- }
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data)
- await hass.async_block_till_done()
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
- 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/camera/bla/config", data)
- await hass.async_block_till_done()
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG, ["test_topic"]
+ )
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Milk"
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py
index a6fb5f2cc66..ce21aa53d27 100644
--- a/tests/components/mqtt/test_climate.py
+++ b/tests/components/mqtt/test_climate.py
@@ -23,17 +23,24 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
-from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
+from homeassistant.const import STATE_OFF
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -45,7 +52,7 @@ from tests.components.climate import common
ENTITY_CLIMATE = "climate.test"
DEFAULT_CONFIG = {
- "climate": {
+ CLIMATE_DOMAIN: {
"platform": "mqtt",
"name": "test",
"mode_command_topic": "mode-topic",
@@ -60,32 +67,6 @@ DEFAULT_CONFIG = {
}
}
-DEFAULT_CONFIG_ATTR = {
- CLIMATE_DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "power_state_topic": "test-topic",
- "power_command_topic": "test_topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "power_state_topic": "test-topic",
- "power_command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
-
async def test_setup_params(hass, mqtt_mock):
"""Test the initial parameters."""
@@ -596,27 +577,25 @@ async def test_set_aux(hass, mqtt_mock):
assert state.attributes.get("aux_heat") == "off"
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- config = copy.deepcopy(DEFAULT_CONFIG)
- config["climate"]["availability_topic"] = "availability-topic"
- config["climate"]["payload_available"] = "good"
- config["climate"]["payload_not_available"] = "nogood"
-
- assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
-
- state = hass.states.get("climate.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("climate.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("climate.test")
- assert state.state == STATE_UNAVAILABLE
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
async def test_set_target_temperature_low_high_with_templates(hass, mqtt_mock, caplog):
@@ -800,35 +779,35 @@ async def test_temp_step_custom(hass, mqtt_mock):
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- config1 = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN])
- config2 = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN])
- config1["json_attributes_topic"] = "attr-topic1"
- config2["json_attributes_topic"] = "attr-topic2"
- data1 = json.dumps(config1)
- data2 = json.dumps(config2)
-
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG
)
@@ -879,34 +858,54 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT climate device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT climate device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
config = {
- CLIMATE_DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "mode_state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
+ CLIMATE_DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "mode_state_topic": "test-topic",
+ "availability_topic": "avty-topic",
+ }
}
- await help_test_entity_id_update(hass, mqtt_mock, CLIMATE_DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, CLIMATE_DOMAIN, config, ["test-topic", "avty-topic"]
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG
+ )
async def test_precision_default(hass, mqtt_mock):
diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py
new file mode 100644
index 00000000000..0d1b892611d
--- /dev/null
+++ b/tests/components/mqtt/test_common.py
@@ -0,0 +1,751 @@
+"""Common test objects."""
+import copy
+import json
+from unittest.mock import ANY
+
+from homeassistant.components import mqtt
+from homeassistant.components.mqtt import debug_info
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE
+
+from tests.common import (
+ MockConfigEntry,
+ async_fire_mqtt_message,
+ async_mock_mqtt_component,
+ async_setup_component,
+ mock_registry,
+)
+
+DEFAULT_CONFIG_DEVICE_INFO_ID = {
+ "identifiers": ["helloworld"],
+ "manufacturer": "Whatever",
+ "name": "Beer",
+ "model": "Glass",
+ "sw_version": "0.1-beta",
+}
+
+DEFAULT_CONFIG_DEVICE_INFO_MAC = {
+ "connections": [["mac", "02:5b:26:a8:dc:12"]],
+ "manufacturer": "Whatever",
+ "name": "Beer",
+ "model": "Glass",
+ "sw_version": "0.1-beta",
+}
+
+
+async def help_test_availability_without_topic(hass, mqtt_mock, domain, config):
+ """Test availability without defined availability topic."""
+ assert "availability_topic" not in config[domain]
+ assert await async_setup_component(hass, domain, config)
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state != STATE_UNAVAILABLE
+
+
+async def help_test_default_availability_payload(
+ hass,
+ mqtt_mock,
+ domain,
+ config,
+ no_assumed_state=False,
+ state_topic=None,
+ state_message=None,
+):
+ """Test availability by default payload with defined topic.
+
+ This is a test helper for the MqttAvailability mixin.
+ """
+ # Add availability settings to config
+ config = copy.deepcopy(config)
+ config[domain]["availability_topic"] = "availability-topic"
+ assert await async_setup_component(hass, domain, config,)
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, "availability-topic", "online")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state != STATE_UNAVAILABLE
+ if no_assumed_state:
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, "availability-topic", "offline")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ if state_topic:
+ async_fire_mqtt_message(hass, state_topic, state_message)
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, "availability-topic", "online")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state != STATE_UNAVAILABLE
+
+
+async def help_test_custom_availability_payload(
+ hass,
+ mqtt_mock,
+ domain,
+ config,
+ no_assumed_state=False,
+ state_topic=None,
+ state_message=None,
+):
+ """Test availability by custom payload with defined topic.
+
+ This is a test helper for the MqttAvailability mixin.
+ """
+ # Add availability settings to config
+ config = copy.deepcopy(config)
+ config[domain]["availability_topic"] = "availability-topic"
+ config[domain]["payload_available"] = "good"
+ config[domain]["payload_not_available"] = "nogood"
+ assert await async_setup_component(hass, domain, config,)
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, "availability-topic", "good")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state != STATE_UNAVAILABLE
+ if no_assumed_state:
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, "availability-topic", "nogood")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ if state_topic:
+ async_fire_mqtt_message(hass, state_topic, state_message)
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, "availability-topic", "good")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.state != STATE_UNAVAILABLE
+
+
+async def help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, domain, config
+):
+ """Test the setting of attribute via MQTT with JSON payload.
+
+ This is a test helper for the MqttAttributes mixin.
+ """
+ # Add JSON attributes settings to config
+ config = copy.deepcopy(config)
+ config[domain]["json_attributes_topic"] = "attr-topic"
+ assert await async_setup_component(hass, domain, config,)
+
+ async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
+ state = hass.states.get(f"{domain}.test")
+
+ assert state.attributes.get("val") == "100"
+
+
+async def help_test_setting_attribute_with_template(hass, mqtt_mock, domain, config):
+ """Test the setting of attribute via MQTT with JSON payload.
+
+ This is a test helper for the MqttAttributes mixin.
+ """
+ # Add JSON attributes settings to config
+ config = copy.deepcopy(config)
+ config[domain]["json_attributes_topic"] = "attr-topic"
+ config[domain]["json_attributes_template"] = "{{ value_json['Timer1'] | tojson }}"
+ assert await async_setup_component(hass, domain, config,)
+
+ async_fire_mqtt_message(
+ hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}})
+ )
+ state = hass.states.get(f"{domain}.test")
+
+ assert state.attributes.get("Arm") == 0
+ assert state.attributes.get("Time") == "22:18"
+
+
+async def help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, domain, config
+):
+ """Test attributes get extracted from a JSON result.
+
+ This is a test helper for the MqttAttributes mixin.
+ """
+ # Add JSON attributes settings to config
+ config = copy.deepcopy(config)
+ config[domain]["json_attributes_topic"] = "attr-topic"
+ assert await async_setup_component(hass, domain, config,)
+
+ async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
+ state = hass.states.get(f"{domain}.test")
+
+ assert state.attributes.get("val") is None
+ assert "JSON result was not a dictionary" in caplog.text
+
+
+async def help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, domain, config
+):
+ """Test JSON validation of attributes.
+
+ This is a test helper for the MqttAttributes mixin.
+ """
+ # Add JSON attributes settings to config
+ config = copy.deepcopy(config)
+ config[domain]["json_attributes_topic"] = "attr-topic"
+ assert await async_setup_component(hass, domain, config,)
+
+ async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
+
+ state = hass.states.get(f"{domain}.test")
+ assert state.attributes.get("val") is None
+ assert "Erroneous JSON: This is not JSON" in caplog.text
+
+
+async def help_test_discovery_update_attr(hass, mqtt_mock, caplog, domain, config):
+ """Test update of discovered MQTTAttributes.
+
+ This is a test helper for the MqttAttributes mixin.
+ """
+ # Add JSON attributes settings to config
+ config1 = copy.deepcopy(config)
+ config1[domain]["json_attributes_topic"] = "attr-topic1"
+ config2 = copy.deepcopy(config)
+ config2[domain]["json_attributes_topic"] = "attr-topic2"
+ data1 = json.dumps(config1[domain])
+ data2 = json.dumps(config2[domain])
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, "homeassistant", {}, entry)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
+ state = hass.states.get(f"{domain}.test")
+ assert state.attributes.get("val") == "100"
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }')
+ state = hass.states.get(f"{domain}.test")
+ assert state.attributes.get("val") == "100"
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }')
+ state = hass.states.get(f"{domain}.test")
+ assert state.attributes.get("val") == "75"
+
+
+async def help_test_unique_id(hass, domain, config):
+ """Test unique id option only creates one entity per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, domain, config,)
+ async_fire_mqtt_message(hass, "test-topic", "payload")
+ assert len(hass.states.async_entity_ids(domain)) == 1
+
+
+async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data):
+ """Test removal of discovered component.
+
+ This is a test helper for the MqttDiscoveryUpdate mixin.
+ """
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.test")
+ assert state is not None
+ assert state.name == "test"
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "")
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.test")
+ assert state is None
+
+
+async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, data2):
+ """Test update of discovered component.
+
+ This is a test helper for the MqttDiscoveryUpdate mixin.
+ """
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.beer")
+ assert state is not None
+ assert state.name == "Beer"
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.beer")
+ assert state is not None
+ assert state.name == "Milk"
+
+ state = hass.states.get(f"{domain}.milk")
+ assert state is None
+
+
+async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, data2):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.beer")
+ assert state is None
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.milk")
+ assert state is not None
+ assert state.name == "Milk"
+ state = hass.states.get(f"{domain}.beer")
+ assert state is None
+
+
+async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, config):
+ """Test device registry integration.
+
+ This is a test helper for the MqttDiscoveryUpdate mixin.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/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.manufacturer == "Whatever"
+ assert device.name == "Beer"
+ assert device.model == "Glass"
+ assert device.sw_version == "0.1-beta"
+
+
+async def help_test_entity_device_info_with_connection(hass, mqtt_mock, domain, config):
+ """Test device registry integration.
+
+ This is a test helper for the MqttDiscoveryUpdate mixin.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_MAC)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device(set(), {("mac", "02:5b:26:a8:dc:12")})
+ assert device is not None
+ 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 help_test_entity_device_info_remove(hass, mqtt_mock, domain, config):
+ """Test device registry remove."""
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ dev_registry = await hass.helpers.device_registry.async_get_registry()
+ ent_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = dev_registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+ assert ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique")
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "")
+ await hass.async_block_till_done()
+
+ device = dev_registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is None
+ assert not ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique")
+
+
+async def help_test_entity_device_info_update(hass, mqtt_mock, domain, config):
+ """Test device registry update.
+
+ This is a test helper for the MqttDiscoveryUpdate mixin.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/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, f"homeassistant/{domain}/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"
+
+
+async def help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, domain, config, topics=None
+):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ # Add unique_id to config
+ config = copy.deepcopy(config)
+ config[domain]["unique_id"] = "TOTALLY_UNIQUE"
+
+ if topics is None:
+ # Add default topics to config
+ config[domain]["availability_topic"] = "avty-topic"
+ config[domain]["state_topic"] = "test-topic"
+ topics = ["avty-topic", "test-topic"]
+ assert len(topics) > 0
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, domain, config,)
+
+ state = hass.states.get(f"{domain}.test")
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == len(topics)
+ for topic in topics:
+ mock_mqtt.async_subscribe.assert_any_call(topic, ANY, ANY, ANY)
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk")
+ await hass.async_block_till_done()
+
+ state = hass.states.get(f"{domain}.test")
+ assert state is None
+
+ state = hass.states.get(f"{domain}.milk")
+ assert state is not None
+ for topic in topics:
+ mock_mqtt.async_subscribe.assert_any_call(topic, ANY, ANY, ANY)
+
+
+async def help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, domain, config, topic=None
+):
+ """Test MQTT discovery update after entity_id is updated."""
+ # Add unique_id to config
+ config = copy.deepcopy(config)
+ config[domain]["unique_id"] = "TOTALLY_UNIQUE"
+
+ if topic is None:
+ # Add default topic to config
+ config[domain]["availability_topic"] = "avty-topic"
+ topic = "avty-topic"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ ent_registry = mock_registry(hass, {})
+
+ data = json.dumps(config[domain])
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, topic, "online")
+ state = hass.states.get(f"{domain}.test")
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, topic, "offline")
+ state = hass.states.get(f"{domain}.test")
+ assert state.state == STATE_UNAVAILABLE
+
+ ent_registry.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk")
+ await hass.async_block_till_done()
+
+ config[domain]["availability_topic"] = f"{topic}_2"
+ data = json.dumps(config[domain])
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+ assert len(hass.states.async_entity_ids(domain)) == 1
+
+ async_fire_mqtt_message(hass, f"{topic}_2", "online")
+ state = hass.states.get(f"{domain}.milk")
+ assert state.state != STATE_UNAVAILABLE
+
+
+async def help_test_entity_debug_info(hass, mqtt_mock, domain, config):
+ """Test debug_info.
+
+ This is a test helper for MQTT debug_info.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"]) == 1
+ assert (
+ debug_info_data["entities"][0]["discovery_data"]["topic"]
+ == f"homeassistant/{domain}/bla/config"
+ )
+ assert debug_info_data["entities"][0]["discovery_data"]["payload"] == config
+ assert len(debug_info_data["entities"][0]["topics"]) == 1
+ assert {"topic": "test-topic", "messages": []} in debug_info_data["entities"][0][
+ "topics"
+ ]
+ assert len(debug_info_data["triggers"]) == 0
+
+
+async def help_test_entity_debug_info_max_messages(hass, mqtt_mock, domain, config):
+ """Test debug_info message overflow.
+
+ This is a test helper for MQTT debug_info.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"][0]["topics"]) == 1
+ assert {"topic": "test-topic", "messages": []} in debug_info_data["entities"][0][
+ "topics"
+ ]
+
+ for i in range(0, debug_info.STORED_MESSAGES + 1):
+ async_fire_mqtt_message(hass, "test-topic", f"{i}")
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"][0]["topics"]) == 1
+ assert (
+ len(debug_info_data["entities"][0]["topics"][0]["messages"])
+ == debug_info.STORED_MESSAGES
+ )
+ messages = [f"{i}" for i in range(1, debug_info.STORED_MESSAGES + 1)]
+ assert {"topic": "test-topic", "messages": messages} in debug_info_data["entities"][
+ 0
+ ]["topics"]
+
+
+async def help_test_entity_debug_info_message(
+ hass, mqtt_mock, domain, config, topic=None, payload=None
+):
+ """Test debug_info message overflow.
+
+ This is a test helper for MQTT debug_info.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ if topic is None:
+ # Add default topic to config
+ config["state_topic"] = "state-topic"
+ topic = "state-topic"
+
+ if payload is None:
+ payload = "ON"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"][0]["topics"]) >= 1
+ assert {"topic": topic, "messages": []} in debug_info_data["entities"][0]["topics"]
+
+ async_fire_mqtt_message(hass, topic, payload)
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"][0]["topics"]) >= 1
+ assert {"topic": topic, "messages": [payload]} in debug_info_data["entities"][0][
+ "topics"
+ ]
+
+
+async def help_test_entity_debug_info_remove(hass, mqtt_mock, domain, config):
+ """Test debug_info.
+
+ This is a test helper for MQTT debug_info.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"]) == 1
+ assert (
+ debug_info_data["entities"][0]["discovery_data"]["topic"]
+ == f"homeassistant/{domain}/bla/config"
+ )
+ assert debug_info_data["entities"][0]["discovery_data"]["payload"] == config
+ assert len(debug_info_data["entities"][0]["topics"]) == 1
+ assert {"topic": "test-topic", "messages": []} in debug_info_data["entities"][0][
+ "topics"
+ ]
+ assert len(debug_info_data["triggers"]) == 0
+ assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.test"
+ entity_id = debug_info_data["entities"][0]["entity_id"]
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "")
+ await hass.async_block_till_done()
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"]) == 0
+ assert len(debug_info_data["triggers"]) == 0
+ assert entity_id not in hass.data[debug_info.DATA_MQTT_DEBUG_INFO]["entities"]
+
+
+async def help_test_entity_debug_info_update_entity_id(hass, mqtt_mock, domain, config):
+ """Test debug_info.
+
+ This is a test helper for MQTT debug_info.
+ """
+ # Add device settings to config
+ config = copy.deepcopy(config[domain])
+ config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID)
+ config["unique_id"] = "veryunique"
+
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, entry)
+ dev_registry = await hass.helpers.device_registry.async_get_registry()
+ ent_registry = mock_registry(hass, {})
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data)
+ await hass.async_block_till_done()
+
+ device = dev_registry.async_get_device({("mqtt", "helloworld")}, set())
+ assert device is not None
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"]) == 1
+ assert (
+ debug_info_data["entities"][0]["discovery_data"]["topic"]
+ == f"homeassistant/{domain}/bla/config"
+ )
+ assert debug_info_data["entities"][0]["discovery_data"]["payload"] == config
+ assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.test"
+ assert len(debug_info_data["entities"][0]["topics"]) == 1
+ assert {"topic": "test-topic", "messages": []} in debug_info_data["entities"][0][
+ "topics"
+ ]
+ assert len(debug_info_data["triggers"]) == 0
+
+ ent_registry.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk")
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"]) == 1
+ assert (
+ debug_info_data["entities"][0]["discovery_data"]["topic"]
+ == f"homeassistant/{domain}/bla/config"
+ )
+ assert debug_info_data["entities"][0]["discovery_data"]["payload"] == config
+ assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.milk"
+ assert len(debug_info_data["entities"][0]["topics"]) == 1
+ assert {"topic": "test-topic", "messages": []} in debug_info_data["entities"][0][
+ "topics"
+ ]
+ assert len(debug_info_data["triggers"]) == 0
+ assert (
+ f"{domain}.test" not in hass.data[debug_info.DATA_MQTT_DEBUG_INFO]["entities"]
+ )
diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py
index 78f7dc72a24..7749c419ca0 100644
--- a/tests/components/mqtt/test_cover.py
+++ b/tests/components/mqtt/test_cover.py
@@ -18,20 +18,26 @@ from homeassistant.const import (
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
- STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -39,29 +45,8 @@ from .common import (
from tests.common import async_fire_mqtt_message
-DEFAULT_CONFIG_ATTR = {
- cover.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
+DEFAULT_CONFIG = {
+ cover.DOMAIN: {"platform": "mqtt", "name": "test", "state_topic": "test-topic"}
}
@@ -1600,88 +1585,24 @@ async def test_find_in_range_altered_inverted(hass, mqtt_mock):
async def test_availability_without_topic(hass, mqtt_mock):
"""Test availability without defined availability topic."""
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- }
- },
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("cover.test")
- assert state.state != STATE_UNAVAILABLE
-
-async def test_availability_by_defaults(hass, mqtt_mock):
- """Test availability by defaults with defined topic."""
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "availability_topic": "availability-topic",
- }
- },
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("cover.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability-topic", "online")
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.test")
- assert state.state == STATE_UNAVAILABLE
-
-
-async def test_availability_by_custom_payload(hass, mqtt_mock):
+async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("cover.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.test")
- assert state.state == STATE_UNAVAILABLE
-
async def test_valid_device_class(hass, mqtt_mock):
"""Test the setting of a valid sensor class."""
@@ -1724,39 +1645,35 @@ async def test_invalid_device_class(hass, mqtt_mock):
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
-
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG
)
@@ -1805,31 +1722,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT cover device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT cover device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- cover.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, cover.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py
index c7d1f636c02..7274badbed9 100644
--- a/tests/components/mqtt/test_device_trigger.py
+++ b/tests/components/mqtt/test_device_trigger.py
@@ -4,7 +4,7 @@ import json
import pytest
import homeassistant.components.automation as automation
-from homeassistant.components.mqtt import DOMAIN
+from homeassistant.components.mqtt import DOMAIN, debug_info
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
@@ -233,8 +233,8 @@ async def test_update_remove_triggers(hass, device_reg, entity_reg, mqtt_mock):
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, [])
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is None
async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock):
@@ -757,6 +757,40 @@ async def test_attach_remove_late2(hass, device_reg, mqtt_mock):
assert len(calls) == 0
+async def test_entity_device_info_with_connection(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": {
+ "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(set(), {("mac", "02:5b:26:a8:dc:12")})
+ assert device is not None
+ 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_with_identifier(hass, mqtt_mock):
"""Test MQTT device registry integration."""
entry = MockConfigEntry(domain=DOMAIN)
@@ -772,7 +806,6 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"subtype": "bar",
"device": {
"identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
"manufacturer": "Whatever",
"name": "Beer",
"model": "Glass",
@@ -786,7 +819,6 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock):
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"
@@ -833,8 +865,8 @@ async def test_entity_device_info_update(hass, mqtt_mock):
assert device.name == "Milk"
-async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock):
- """Test discovered device is cleaned up when removed from registry."""
+async def test_cleanup_trigger(hass, device_reg, entity_reg, mqtt_mock):
+ """Test trigger discovery topic is cleaned when device is removed from registry."""
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
await async_start(hass, "homeassistant", {}, config_entry)
@@ -863,10 +895,253 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock):
await hass.async_block_till_done()
# Verify device registry entry is cleared
- device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
assert device_entry is None
# Verify retained discovery topic has been cleared
mqtt_mock.async_publish.assert_called_once_with(
"homeassistant/device_automation/bla/config", "", 0, True
)
+
+
+async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock):
+ """Test removal from device registry when trigger is removed."""
+ config_entry = MockConfigEntry(domain=DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ config = {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {"identifiers": ["helloworld"]},
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data)
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is created
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert triggers[0]["type"] == "foo"
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is None
+
+
+async def test_cleanup_device_several_triggers(hass, device_reg, entity_reg, mqtt_mock):
+ """Test removal from device registry when the last trigger is removed."""
+ config_entry = MockConfigEntry(domain=DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ config1 = {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {"identifiers": ["helloworld"]},
+ }
+
+ config2 = {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo2",
+ "subtype": "bar",
+ "device": {"identifiers": ["helloworld"]},
+ }
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2)
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is created
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert len(triggers) == 2
+ assert triggers[0]["type"] == "foo"
+ assert triggers[1]["type"] == "foo2"
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is not cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert len(triggers) == 1
+ assert triggers[0]["type"] == "foo2"
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is None
+
+
+async def test_cleanup_device_with_entity1(hass, device_reg, entity_reg, mqtt_mock):
+ """Test removal from device registry for device with entity.
+
+ Trigger removed first, then entity.
+ """
+ config_entry = MockConfigEntry(domain=DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ config1 = {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {"identifiers": ["helloworld"]},
+ }
+
+ config2 = {
+ "name": "test_binary_sensor",
+ "state_topic": "test-topic",
+ "device": {"identifiers": ["helloworld"]},
+ "unique_id": "veryunique",
+ }
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla2/config", data2)
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is created
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is not cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert len(triggers) == 2 # 2 binary_sensor triggers
+
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla2/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is None
+
+
+async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mock):
+ """Test removal from device registry for device with entity.
+
+ Entity removed first, then trigger.
+ """
+ config_entry = MockConfigEntry(domain=DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ config1 = {
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {"identifiers": ["helloworld"]},
+ }
+
+ config2 = {
+ "name": "test_binary_sensor",
+ "state_topic": "test-topic",
+ "device": {"identifiers": ["helloworld"]},
+ "unique_id": "veryunique",
+ }
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla2/config", data2)
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is created
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger
+
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla2/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is not cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is not None
+
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert len(triggers) == 1 # device trigger
+
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "")
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set())
+ assert device_entry is None
+
+
+async def test_trigger_debug_info(hass, mqtt_mock):
+ """Test debug_info.
+
+ This is a test helper for MQTT debug_info.
+ """
+ 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 = {
+ "platform": "mqtt",
+ "automation_type": "trigger",
+ "topic": "test-topic",
+ "type": "foo",
+ "subtype": "bar",
+ "device": {
+ "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(set(), {("mac", "02:5b:26:a8:dc:12")})
+ assert device is not None
+
+ debug_info_data = await debug_info.info_for_device(hass, device.id)
+ assert len(debug_info_data["entities"]) == 0
+ assert len(debug_info_data["triggers"]) == 1
+ assert (
+ debug_info_data["triggers"][0]["discovery_data"]["topic"]
+ == "homeassistant/device_automation/bla/config"
+ )
+ assert debug_info_data["triggers"][0]["discovery_data"]["payload"] == config
diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py
index 4a28b95e32c..8c925bdf315 100644
--- a/tests/components/mqtt/test_discovery.py
+++ b/tests/components/mqtt/test_discovery.py
@@ -117,7 +117,9 @@ async def test_correct_config_discovery(hass, mqtt_mock, caplog):
await async_start(hass, "homeassistant", {}, entry)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
@@ -199,7 +201,9 @@ async def test_discovery_incl_nodeid(hass, mqtt_mock, caplog):
await async_start(hass, "homeassistant", {}, entry)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/my_node_id/bla/config", '{ "name": "Beer" }',
+ hass,
+ "homeassistant/binary_sensor/my_node_id/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
@@ -217,10 +221,14 @@ async def test_non_duplicate_discovery(hass, mqtt_mock, caplog):
await async_start(hass, "homeassistant", {}, entry)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
@@ -240,7 +248,9 @@ async def test_removal(hass, mqtt_mock, caplog):
await async_start(hass, "homeassistant", {}, entry)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.beer")
@@ -259,7 +269,9 @@ async def test_rediscover(hass, mqtt_mock, caplog):
await async_start(hass, "homeassistant", {}, entry)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.beer")
@@ -271,7 +283,9 @@ async def test_rediscover(hass, mqtt_mock, caplog):
assert state is None
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.beer")
@@ -285,7 +299,9 @@ async def test_duplicate_removal(hass, mqtt_mock, caplog):
await async_start(hass, "homeassistant", {}, entry)
async_fire_mqtt_message(
- hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ hass,
+ "homeassistant/binary_sensor/bla/config",
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
@@ -431,93 +447,6 @@ async def test_missing_discover_abbreviations(hass, mqtt_mock, caplog):
assert not missing
-async def test_implicit_state_topic_alarm(hass, mqtt_mock, caplog):
- """Test implicit state topic for alarm_control_panel."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
-
- await async_start(hass, "homeassistant", {}, entry)
-
- data = (
- '{ "name": "Test1",'
- ' "command_topic": "homeassistant/alarm_control_panel/bla/cmnd"'
- "}"
- )
-
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data)
- await hass.async_block_till_done()
- assert (
- "implicit state_topic is deprecated, add "
- '"state_topic":"homeassistant/alarm_control_panel/bla/state"' in caplog.text
- )
-
- state = hass.states.get("alarm_control_panel.Test1")
- assert state is not None
- assert state.name == "Test1"
- assert ("alarm_control_panel", "bla") in hass.data[ALREADY_DISCOVERED]
- assert state.state == "unknown"
-
- async_fire_mqtt_message(
- hass, "homeassistant/alarm_control_panel/bla/state", "armed_away"
- )
-
- state = hass.states.get("alarm_control_panel.Test1")
- assert state.state == "armed_away"
-
-
-async def test_implicit_state_topic_binary_sensor(hass, mqtt_mock, caplog):
- """Test implicit state topic for binary_sensor."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
-
- await async_start(hass, "homeassistant", {}, entry)
-
- data = '{ "name": "Test1"' "}"
-
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data)
- await hass.async_block_till_done()
- assert (
- "implicit state_topic is deprecated, add "
- '"state_topic":"homeassistant/binary_sensor/bla/state"' in caplog.text
- )
-
- state = hass.states.get("binary_sensor.Test1")
- assert state is not None
- assert state.name == "Test1"
- assert ("binary_sensor", "bla") in hass.data[ALREADY_DISCOVERED]
- assert state.state == "off"
-
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/state", "ON")
-
- state = hass.states.get("binary_sensor.Test1")
- assert state.state == "on"
-
-
-async def test_implicit_state_topic_sensor(hass, mqtt_mock, caplog):
- """Test implicit state topic for sensor."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
-
- await async_start(hass, "homeassistant", {}, entry)
-
- data = '{ "name": "Test1"' "}"
-
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
- await hass.async_block_till_done()
- assert (
- "implicit state_topic is deprecated, add "
- '"state_topic":"homeassistant/sensor/bla/state"' in caplog.text
- )
-
- state = hass.states.get("sensor.Test1")
- assert state is not None
- assert state.name == "Test1"
- assert ("sensor", "bla") in hass.data[ALREADY_DISCOVERED]
- assert state.state == "unknown"
-
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/state", "1234")
-
- state = hass.states.get("sensor.Test1")
- assert state.state == "1234"
-
-
async def test_no_implicit_state_topic_switch(hass, mqtt_mock, caplog):
"""Test no implicit state topic for switch."""
entry = MockConfigEntry(domain=mqtt.DOMAIN)
@@ -552,7 +481,7 @@ async def test_complex_discovery_topic_prefix(hass, mqtt_mock, caplog):
async_fire_mqtt_message(
hass,
("my_home/homeassistant/register/binary_sensor/node1/object1/config"),
- '{ "name": "Beer" }',
+ '{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py
index 512dddd4fc6..460c99618bd 100644
--- a/tests/components/mqtt/test_fan.py
+++ b/tests/components/mqtt/test_fan.py
@@ -1,22 +1,24 @@
"""Test MQTT fans."""
from homeassistant.components import fan
-from homeassistant.const import (
- ATTR_ASSUMED_STATE,
- STATE_OFF,
- STATE_ON,
- STATE_UNAVAILABLE,
-)
+from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -25,31 +27,15 @@ from .common import (
from tests.common import async_fire_mqtt_message
from tests.components.fan import common
-DEFAULT_CONFIG_ATTR = {
+DEFAULT_CONFIG = {
fan.DOMAIN: {
"platform": "mqtt",
"name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
+ "state_topic": "state-topic",
+ "command_topic": "command-topic",
}
}
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
-
async def test_fail_setup_if_no_command_topic(hass, mqtt_mock):
"""Test if command fails with command topic."""
@@ -384,125 +370,59 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock):
assert state.attributes.get(ATTR_ASSUMED_STATE)
-async def test_default_availability_payload(hass, mqtt_mock):
- """Test the availability payload."""
- assert await async_setup_component(
- hass,
- fan.DOMAIN,
- {
- fan.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "availability_topic": "availability_topic",
- }
- },
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("fan.test")
- assert state.state is STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability_topic", "online")
-
- state = hass.states.get("fan.test")
- assert state.state is not STATE_UNAVAILABLE
- assert not state.attributes.get(ATTR_ASSUMED_STATE)
-
- async_fire_mqtt_message(hass, "availability_topic", "offline")
-
- state = hass.states.get("fan.test")
- assert state.state is STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "state-topic", "1")
-
- state = hass.states.get("fan.test")
- assert state.state is STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability_topic", "online")
-
- state = hass.states.get("fan.test")
- assert state.state is not STATE_UNAVAILABLE
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1"
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
- """Test the availability payload."""
- assert await async_setup_component(
- hass,
- fan.DOMAIN,
- {
- fan.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "availability_topic": "availability_topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ """Test availability by custom payload with defined topic."""
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1"
)
- state = hass.states.get("fan.test")
- assert state.state is STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability_topic", "good")
-
- state = hass.states.get("fan.test")
- assert state.state is not STATE_UNAVAILABLE
- assert not state.attributes.get(ATTR_ASSUMED_STATE)
-
- async_fire_mqtt_message(hass, "availability_topic", "nogood")
-
- state = hass.states.get("fan.test")
- assert state.state is STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "state-topic", "1")
-
- state = hass.states.get("fan.test")
- assert state.state is STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability_topic", "good")
-
- state = hass.states.get("fan.test")
- assert state.state is not STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG
)
@@ -549,32 +469,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
await help_test_discovery_broken(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT fan device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT fan device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- fan.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, fan.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py
index 7d06c62b915..7aa185c2c39 100644
--- a/tests/components/mqtt/test_init.py
+++ b/tests/components/mqtt/test_init.py
@@ -1,5 +1,6 @@
"""The tests for the MQTT component."""
from datetime import timedelta
+import json
import ssl
import unittest
from unittest import mock
@@ -934,3 +935,48 @@ async def test_mqtt_ws_remove_non_mqtt_device(
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND
+
+
+async def test_mqtt_ws_get_device_debug_info(
+ hass, device_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device debug info."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ config = {
+ "device": {"identifiers": ["0AFFD2"]},
+ "platform": "mqtt",
+ "state_topic": "foobar/sensor",
+ "unique_id": "unique",
+ }
+ data = json.dumps(config)
+
+ 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/debug_info", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ expected_result = {
+ "entities": [
+ {
+ "entity_id": "sensor.mqtt_sensor",
+ "topics": [{"topic": "foobar/sensor", "messages": []}],
+ "discovery_data": {
+ "payload": config,
+ "topic": "homeassistant/sensor/bla/config",
+ },
+ }
+ ],
+ "triggers": [],
+ }
+ assert response["result"] == expected_result
diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py
index c3500e6ac6a..14ab79b2d20 100644
--- a/tests/components/mqtt/test_legacy_vacuum.py
+++ b/tests/components/mqtt/test_legacy_vacuum.py
@@ -16,24 +16,25 @@ from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
ATTR_STATUS,
)
-from homeassistant.const import (
- CONF_NAME,
- CONF_PLATFORM,
- STATE_OFF,
- STATE_ON,
- STATE_UNAVAILABLE,
-)
+from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -63,28 +64,7 @@ DEFAULT_CONFIG = {
mqttvacuum.CONF_FAN_SPEED_LIST: ["min", "medium", "high", "max"],
}
-DEFAULT_CONFIG_ATTR = {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
+DEFAULT_CONFIG_2 = {vacuum.DOMAIN: {"platform": "mqtt", "name": "test"}}
async def test_default_supported_features(hass, mqtt_mock):
@@ -498,89 +478,59 @@ async def test_missing_fan_speed_template(hass, mqtt_mock):
assert state is None
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
async def test_default_availability_payload(hass, mqtt_mock):
"""Test availability by default payload with defined topic."""
- config = deepcopy(DEFAULT_CONFIG)
- config.update({"availability_topic": "availability-topic"})
-
- assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config})
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- config = deepcopy(DEFAULT_CONFIG)
- config.update(
- {
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
- assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config})
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
@@ -607,7 +557,7 @@ async def test_unique_id(hass, mqtt_mock):
async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog):
"""Test removal of discovered vacuum."""
- data = json.dumps(DEFAULT_CONFIG_ATTR[vacuum.DOMAIN])
+ data = json.dumps(DEFAULT_CONFIG_2[vacuum.DOMAIN])
await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data)
@@ -629,33 +579,53 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT vacuum device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT vacuum device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
config = {
- vacuum.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "battery_level_topic": "test-topic",
- "battery_level_template": "{{ value_json.battery_level }}",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
+ vacuum.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "battery_level_topic": "test-topic",
+ "battery_level_template": "{{ value_json.battery_level }}",
+ "command_topic": "command-topic",
+ "availability_topic": "avty-topic",
+ }
}
- await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, vacuum.DOMAIN, config, ["test-topic", "avty-topic"]
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py
index f2bde3d3b43..bc4f5fc3393 100644
--- a/tests/components/mqtt/test_light.py
+++ b/tests/components/mqtt/test_light.py
@@ -158,24 +158,26 @@ from unittest.mock import patch
from homeassistant.components import light, mqtt
from homeassistant.components.mqtt.discovery import async_start
-from homeassistant.const import (
- ATTR_ASSUMED_STATE,
- STATE_OFF,
- STATE_ON,
- STATE_UNAVAILABLE,
-)
+from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -189,29 +191,8 @@ from tests.common import (
)
from tests.components.light import common
-DEFAULT_CONFIG_ATTR = {
- light.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
+DEFAULT_CONFIG = {
+ light.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"}
}
@@ -355,6 +336,105 @@ async def test_controlling_state_via_topic(hass, mqtt_mock):
assert light_state.attributes.get("xy_color") == (0.672, 0.324)
+async def test_invalid_state_via_topic(hass, mqtt_mock, caplog):
+ """Test handling of empty data via topic."""
+ config = {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "test_light_rgb/status",
+ "command_topic": "test_light_rgb/set",
+ "brightness_state_topic": "test_light_rgb/brightness/status",
+ "brightness_command_topic": "test_light_rgb/brightness/set",
+ "rgb_state_topic": "test_light_rgb/rgb/status",
+ "rgb_command_topic": "test_light_rgb/rgb/set",
+ "color_temp_state_topic": "test_light_rgb/color_temp/status",
+ "color_temp_command_topic": "test_light_rgb/color_temp/set",
+ "effect_state_topic": "test_light_rgb/effect/status",
+ "effect_command_topic": "test_light_rgb/effect/set",
+ "hs_state_topic": "test_light_rgb/hs/status",
+ "hs_command_topic": "test_light_rgb/hs/set",
+ "white_value_state_topic": "test_light_rgb/white_value/status",
+ "white_value_command_topic": "test_light_rgb/white_value/set",
+ "xy_state_topic": "test_light_rgb/xy/status",
+ "xy_command_topic": "test_light_rgb/xy/set",
+ "qos": "0",
+ "payload_on": 1,
+ "payload_off": 0,
+ }
+ }
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert state.attributes.get("rgb_color") is None
+ assert state.attributes.get("brightness") is None
+ assert state.attributes.get("color_temp") is None
+ assert state.attributes.get("effect") is None
+ assert state.attributes.get("hs_color") is None
+ assert state.attributes.get("white_value") is None
+ assert state.attributes.get("xy_color") is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, "test_light_rgb/status", "1")
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("rgb_color") == (255, 255, 255)
+ assert state.attributes.get("brightness") == 255
+ assert state.attributes.get("color_temp") == 150
+ assert state.attributes.get("effect") == "none"
+ assert state.attributes.get("hs_color") == (0, 0)
+ assert state.attributes.get("white_value") == 255
+ assert state.attributes.get("xy_color") == (0.323, 0.329)
+
+ async_fire_mqtt_message(hass, "test_light_rgb/status", "")
+ assert "Ignoring empty state message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, "test_light_rgb/brightness/status", "")
+ assert "Ignoring empty brightness message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes["brightness"] == 255
+
+ async_fire_mqtt_message(hass, "test_light_rgb/color_temp/status", "")
+ assert "Ignoring empty color temp message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes["color_temp"] == 150
+
+ async_fire_mqtt_message(hass, "test_light_rgb/effect/status", "")
+ assert "Ignoring empty effect message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes["effect"] == "none"
+
+ async_fire_mqtt_message(hass, "test_light_rgb/white_value/status", "")
+ assert "Ignoring empty white value message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes["white_value"] == 255
+
+ async_fire_mqtt_message(hass, "test_light_rgb/rgb/status", "")
+ assert "Ignoring empty rgb message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes.get("rgb_color") == (255, 255, 255)
+
+ async_fire_mqtt_message(hass, "test_light_rgb/hs/status", "")
+ assert "Ignoring empty hs message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes.get("hs_color") == (0, 0)
+
+ async_fire_mqtt_message(hass, "test_light_rgb/hs/status", "bad,bad")
+ assert "Failed to parse hs state update" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes.get("hs_color") == (0, 0)
+
+ async_fire_mqtt_message(hass, "test_light_rgb/xy/status", "")
+ assert "Ignoring empty xy-color message" in caplog.text
+ light_state = hass.states.get("light.test")
+ assert light_state.attributes.get("xy_color") == (0.323, 0.329)
+
+
async def test_brightness_controlling_scale(hass, mqtt_mock):
"""Test the brightness controlling scale."""
with assert_setup_component(1, light.DOMAIN):
@@ -776,7 +856,7 @@ async def test_show_color_temp_only_if_command_topic(hass, mqtt_mock):
async def test_show_effect_only_if_command_topic(hass, mqtt_mock):
- """Test the color temp only if a command topic is present."""
+ """Test the effect only if a command topic is present."""
config = {
light.DOMAIN: {
"platform": "mqtt",
@@ -1033,105 +1113,131 @@ async def test_on_command_rgb(hass, mqtt_mock):
mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False)
-async def test_default_availability_payload(hass, mqtt_mock):
- """Test availability by default payload with defined topic."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test_light/set",
- "brightness_command_topic": "test_light/bright",
- "rgb_command_topic": "test_light/rgb",
- "availability_topic": "availability-topic",
- }
- },
+async def test_on_command_rgb_template(hass, mqtt_mock):
+ """Test on command in RGB brightness mode with RGB template."""
+ config = {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "command_topic": "test_light/set",
+ "rgb_command_topic": "test_light/rgb",
+ "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}",
+ }
+ }
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, "light.test", brightness=127)
+
+ # Should get the following MQTT messages.
+ # test_light/rgb: '127,127,127'
+ # test_light/set: 'ON'
+ mqtt_mock.async_publish.assert_has_calls(
+ [
+ mock.call("test_light/rgb", "127/127/127", 0, False),
+ mock.call("test_light/set", "ON", 0, False),
+ ],
+ any_order=True,
+ )
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, "light.test")
+
+ mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False)
+
+
+async def test_effect(hass, mqtt_mock):
+ """Test effect."""
+ config = {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "command_topic": "test_light/set",
+ "effect_command_topic": "test_light/effect/set",
+ "effect_list": ["rainbow", "colorloop"],
+ }
+ }
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, "light.test", effect="rainbow")
+
+ # Should get the following MQTT messages.
+ # test_light/effect/set: 'rainbow'
+ # test_light/set: 'ON'
+ mqtt_mock.async_publish.assert_has_calls(
+ [
+ mock.call("test_light/effect/set", "rainbow", 0, False),
+ mock.call("test_light/set", "ON", 0, False),
+ ],
+ any_order=True,
+ )
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, "light.test")
+
+ mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False)
+
+
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("light.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test_light/set",
- "brightness_command_topic": "test_light/bright",
- "rgb_command_topic": "test_light/rgb",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("light.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
@@ -1212,32 +1318,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT light device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT light device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py
index 71ced8f1db2..f71791e019f 100644
--- a/tests/components/mqtt/test_light_json.py
+++ b/tests/components/mqtt/test_light_json.py
@@ -91,62 +91,49 @@ import json
from unittest import mock
from unittest.mock import patch
-from homeassistant.components import light, mqtt
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import light
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_SUPPORTED_FEATURES,
STATE_OFF,
STATE_ON,
- STATE_UNAVAILABLE,
)
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
)
-from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro
+from tests.common import async_fire_mqtt_message, mock_coro
from tests.components.light import common
-DEFAULT_CONFIG_ATTR = {
+DEFAULT_CONFIG = {
light.DOMAIN: {
"platform": "mqtt",
"schema": "json",
"name": "test",
"command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
}
}
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "schema": "json",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
-
class JsonValidator(object):
"""Helper to compare JSON."""
@@ -377,6 +364,18 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
state = hass.states.get("light.test")
assert state.state == STATE_ON
+ await common.async_turn_on(hass, "light.test", color_temp=90)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set",
+ JsonValidator('{"state": "ON", "color_temp": 90}'),
+ 2,
+ False,
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
await common.async_turn_off(hass, "light.test")
mqtt_mock.async_publish.assert_called_once_with(
@@ -679,6 +678,64 @@ async def test_sending_xy_color(hass, mqtt_mock):
)
+async def test_effect(hass, mqtt_mock):
+ """Test for effect being sent when included."""
+ assert await async_setup_component(
+ hass,
+ light.DOMAIN,
+ {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "schema": "json",
+ "name": "test",
+ "command_topic": "test_light_rgb/set",
+ "effect": True,
+ "qos": 0,
+ }
+ },
+ )
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 44
+
+ await common.async_turn_on(hass, "light.test")
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", JsonValidator('{"state": "ON"}'), 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("effect") == "none"
+
+ await common.async_turn_on(hass, "light.test", effect="rainbow")
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set",
+ JsonValidator('{"state": "ON", "effect": "rainbow"}'),
+ 0,
+ False,
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("effect") == "rainbow"
+
+ await common.async_turn_on(hass, "light.test", effect="colorloop")
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set",
+ JsonValidator('{"state": "ON", "effect": "colorloop"}'),
+ 0,
+ False,
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("effect") == "colorloop"
+
+
async def test_flash_short_and_long(hass, mqtt_mock):
"""Test for flash length being sent when included."""
assert await async_setup_component(
@@ -805,8 +862,8 @@ async def test_brightness_scale(hass, mqtt_mock):
assert state.attributes.get("brightness") == 255
-async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
- """Test that invalid color/brightness/white values are ignored."""
+async def test_invalid_values(hass, mqtt_mock):
+ """Test that invalid color/brightness/white/etc. values are ignored."""
assert await async_setup_component(
hass,
light.DOMAIN,
@@ -818,6 +875,7 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
"state_topic": "test_light_rgb",
"command_topic": "test_light_rgb/set",
"brightness": True,
+ "color_temp": True,
"rgb": True,
"white_value": True,
"qos": "0",
@@ -827,10 +885,11 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
state = hass.states.get("light.test")
assert state.state == STATE_OFF
- assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 185
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 187
assert state.attributes.get("rgb_color") is None
assert state.attributes.get("brightness") is None
assert state.attributes.get("white_value") is None
+ assert state.attributes.get("color_temp") is None
assert not state.attributes.get(ATTR_ASSUMED_STATE)
# Turn on the light
@@ -840,7 +899,9 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
'{"state":"ON",'
'"color":{"r":255,"g":255,"b":255},'
'"brightness": 255,'
- '"white_value": 255}',
+ '"white_value": 255,'
+ '"color_temp": 100,'
+ '"effect": "rainbow"}',
)
state = hass.states.get("light.test")
@@ -848,8 +909,19 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
assert state.attributes.get("rgb_color") == (255, 255, 255)
assert state.attributes.get("brightness") == 255
assert state.attributes.get("white_value") == 255
+ assert state.attributes.get("color_temp") == 100
- # Bad color values
+ # Bad HS color values
+ async_fire_mqtt_message(
+ hass, "test_light_rgb", '{"state":"ON",' '"color":{"h":"bad","s":"val"}}',
+ )
+
+ # Color should not have changed
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("rgb_color") == (255, 255, 255)
+
+ # Bad RGB color values
async_fire_mqtt_message(
hass,
"test_light_rgb",
@@ -861,6 +933,16 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
assert state.state == STATE_ON
assert state.attributes.get("rgb_color") == (255, 255, 255)
+ # Bad XY color values
+ async_fire_mqtt_message(
+ hass, "test_light_rgb", '{"state":"ON",' '"color":{"x":"bad","y":"val"}}',
+ )
+
+ # Color should not have changed
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("rgb_color") == (255, 255, 255)
+
# Bad brightness values
async_fire_mqtt_message(
hass, "test_light_rgb", '{"state":"ON",' '"brightness": "badValue"}'
@@ -881,108 +963,70 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
assert state.state == STATE_ON
assert state.attributes.get("white_value") == 255
+ # Bad color temperature
+ async_fire_mqtt_message(
+ hass, "test_light_rgb", '{"state":"ON",' '"color_temp": "badValue"}'
+ )
+
+ # Color temperature should not have changed
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("color_temp") == 100
+
+
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
async def test_default_availability_payload(hass, mqtt_mock):
"""Test availability by default payload with defined topic."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "json",
- "name": "test",
- "state_topic": "test_light_rgb",
- "command_topic": "test_light_rgb/set",
- "availability_topic": "availability-topic",
- }
- },
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("light.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "json",
- "name": "test",
- "state_topic": "test_light_rgb",
- "command_topic": "test_light_rgb/set",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("light.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "schema": "json",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "schema": "json",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
@@ -1017,22 +1061,6 @@ async def test_discovery_removal(hass, mqtt_mock, caplog):
await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data)
-async def test_discovery_deprecated(hass, mqtt_mock, caplog):
- """Test discovery of mqtt_json light with deprecated platform option."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {"mqtt": {}}, entry)
- data = (
- '{ "name": "Beer",'
- ' "platform": "mqtt_json",'
- ' "command_topic": "test_topic"}'
- )
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Beer"
-
-
async def test_discovery_update_light(hass, mqtt_mock, caplog):
"""Test update of discovered light."""
data1 = (
@@ -1066,33 +1094,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT light device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT light device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "schema": "json",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py
index 9d4d3fcba25..c9612a7ded7 100644
--- a/tests/components/mqtt/test_light_template.py
+++ b/tests/components/mqtt/test_light_template.py
@@ -28,39 +28,41 @@ If your light doesn't support RGB feature, omit `(red|green|blue)_template`.
"""
from unittest.mock import patch
-from homeassistant.components import light, mqtt
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import light
from homeassistant.const import (
ATTR_ASSUMED_STATE,
+ ATTR_SUPPORTED_FEATURES,
STATE_OFF,
STATE_ON,
- STATE_UNAVAILABLE,
)
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
)
-from tests.common import (
- MockConfigEntry,
- assert_setup_component,
- async_fire_mqtt_message,
- mock_coro,
-)
+from tests.common import assert_setup_component, async_fire_mqtt_message, mock_coro
+from tests.components.light import common
-DEFAULT_CONFIG_ATTR = {
+DEFAULT_CONFIG = {
light.DOMAIN: {
"platform": "mqtt",
"schema": "template",
@@ -68,29 +70,9 @@ DEFAULT_CONFIG_ATTR = {
"command_topic": "test-topic",
"command_on_template": "on,{{ transition }}",
"command_off_template": "off,{{ transition|d }}",
- "json_attributes_topic": "attr-topic",
}
}
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "schema": "template",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
-}
-
async def test_setup_fails(hass, mqtt_mock):
"""Test that setup fails with missing required configuration items."""
@@ -288,8 +270,8 @@ async def test_state_brightness_color_effect_temp_white_change_via_topic(
assert light_state.attributes.get("effect") == "rainbow"
-async def test_optimistic(hass, mqtt_mock):
- """Test optimistic mode."""
+async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
+ """Test the sending of command in optimistic mode."""
fake_state = ha.State(
"light.test",
"on",
@@ -339,9 +321,284 @@ async def test_optimistic(hass, mqtt_mock):
assert state.attributes.get("white_value") == 50
assert state.attributes.get(ATTR_ASSUMED_STATE)
+ await common.async_turn_off(hass, "light.test")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "off", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, "light.test")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,,--", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
+ # Set color_temp
+ await common.async_turn_on(hass, "light.test", color_temp=70)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,70,,--", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("color_temp") == 70
+
+ # Set full brightness
+ await common.async_turn_on(hass, "light.test", brightness=255)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,255,,,--", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("brightness") == 255
+
+ # Full brightness - no scaling of RGB values sent over MQTT
+ await common.async_turn_on(
+ hass, "light.test", rgb_color=[255, 128, 0], white_value=80
+ )
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,80,255-128-0", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("white_value") == 80
+ assert state.attributes.get("rgb_color") == (255, 128, 0)
+
+ # Full brightness - normalization of RGB values sent over MQTT
+ await common.async_turn_on(hass, "light.test", rgb_color=[128, 64, 0])
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,,255-127-0", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("rgb_color") == (255, 127, 0)
+
+ # Set half brightness
+ await common.async_turn_on(hass, "light.test", brightness=128)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,128,,,--", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("brightness") == 128
+
+ # Half brightness - scaling of RGB values sent over MQTT
+ await common.async_turn_on(
+ hass, "light.test", rgb_color=[0, 255, 128], white_value=40
+ )
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,40,0-128-64", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("white_value") == 40
+ assert state.attributes.get("rgb_color") == (0, 255, 128)
+
+ # Half brightness - normalization+scaling of RGB values sent over MQTT
+ await common.async_turn_on(
+ hass, "light.test", rgb_color=[0, 32, 16], white_value=40
+ )
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,40,0-128-64", 2, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("white_value") == 40
+ assert state.attributes.get("rgb_color") == (0, 255, 127)
+
+
+async def test_sending_mqtt_commands_non_optimistic_brightness_template(
+ hass, mqtt_mock
+):
+ """Test the sending of command in optimistic mode."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(
+ hass,
+ light.DOMAIN,
+ {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "schema": "template",
+ "name": "test",
+ "effect_list": ["rainbow", "colorloop"],
+ "state_topic": "test_light_rgb",
+ "command_topic": "test_light_rgb/set",
+ "command_on_template": "on,"
+ "{{ brightness|d }},"
+ "{{ color_temp|d }},"
+ "{{ white_value|d }},"
+ "{{ red|d }}-"
+ "{{ green|d }}-"
+ "{{ blue|d }}",
+ "command_off_template": "off",
+ "state_template": '{{ value.split(",")[0] }}',
+ "brightness_template": '{{ value.split(",")[1] }}',
+ "color_temp_template": '{{ value.split(",")[2] }}',
+ "white_value_template": '{{ value.split(",")[3] }}',
+ "red_template": '{{ value.split(",")[4].' 'split("-")[0] }}',
+ "green_template": '{{ value.split(",")[4].' 'split("-")[1] }}',
+ "blue_template": '{{ value.split(",")[4].' 'split("-")[2] }}',
+ "effect_template": '{{ value.split(",")[5] }}',
+ }
+ },
+ )
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert not state.attributes.get("brightness")
+ assert not state.attributes.get("hs_color")
+ assert not state.attributes.get("effect")
+ assert not state.attributes.get("color_temp")
+ assert not state.attributes.get("white_value")
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_off(hass, "light.test")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "off", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, "light.test")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,,--", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+ # Set color_temp
+ await common.async_turn_on(hass, "light.test", color_temp=70)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,70,,--", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert not state.attributes.get("color_temp")
+
+ # Set full brightness
+ await common.async_turn_on(hass, "light.test", brightness=255)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,255,,,--", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert not state.attributes.get("brightness")
+
+ # Full brightness - no scaling of RGB values sent over MQTT
+ await common.async_turn_on(
+ hass, "light.test", rgb_color=[255, 128, 0], white_value=80
+ )
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,80,255-128-0", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert not state.attributes.get("white_value")
+ assert not state.attributes.get("rgb_color")
+
+ # Full brightness - normalization of RGB values sent over MQTT
+ await common.async_turn_on(hass, "light.test", rgb_color=[128, 64, 0])
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,,255-127-0", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+
+ # Set half brightness
+ await common.async_turn_on(hass, "light.test", brightness=128)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,128,,,--", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+
+ # Half brightness - no scaling of RGB values sent over MQTT
+ await common.async_turn_on(
+ hass, "light.test", rgb_color=[0, 255, 128], white_value=40
+ )
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,40,0-255-128", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+
+ # Half brightness - normalization but no scaling of RGB values sent over MQTT
+ await common.async_turn_on(
+ hass, "light.test", rgb_color=[0, 32, 16], white_value=40
+ )
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,,,40,0-255-127", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+
+
+async def test_effect(hass, mqtt_mock):
+ """Test effect sent over MQTT in optimistic mode."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(
+ hass,
+ light.DOMAIN,
+ {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "schema": "template",
+ "effect_list": ["rainbow", "colorloop"],
+ "name": "test",
+ "command_topic": "test_light_rgb/set",
+ "command_on_template": "on,{{ effect }}",
+ "command_off_template": "off",
+ "qos": 0,
+ }
+ },
+ )
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 44
+
+ await common.async_turn_on(hass, "light.test")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert not state.attributes.get("effect")
+
+ await common.async_turn_on(hass, "light.test", effect="rainbow")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,rainbow", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("effect") == "rainbow"
+
+ await common.async_turn_on(hass, "light.test", effect="colorloop")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,colorloop", 0, False
+ )
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+ assert state.attributes.get("effect") == "colorloop"
+
async def test_flash(hass, mqtt_mock):
- """Test flash."""
+ """Test flash sent over MQTT in optimistic mode."""
with assert_setup_component(1, light.DOMAIN):
assert await async_setup_component(
hass,
@@ -361,6 +618,30 @@ async def test_flash(hass, mqtt_mock):
state = hass.states.get("light.test")
assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 40
+
+ await common.async_turn_on(hass, "light.test")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
+ await common.async_turn_on(hass, "light.test", flash="short")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,short", 0, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
+ await common.async_turn_on(hass, "light.test", flash="long")
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,long", 0, False
+ )
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
async def test_transition(hass, mqtt_mock):
@@ -377,6 +658,7 @@ async def test_transition(hass, mqtt_mock):
"command_topic": "test_light_rgb/set",
"command_on_template": "on,{{ transition }}",
"command_off_template": "off,{{ transition|d }}",
+ "qos": 1,
}
},
)
@@ -384,6 +666,23 @@ async def test_transition(hass, mqtt_mock):
state = hass.states.get("light.test")
assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 40
+
+ await common.async_turn_on(hass, "light.test", transition=10)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "on,10", 1, False
+ )
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
+ await common.async_turn_off(hass, "light.test", transition=20)
+ mqtt_mock.async_publish.assert_called_once_with(
+ "test_light_rgb/set", "off,20", 1, False
+ )
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
async def test_invalid_values(hass, mqtt_mock):
"""Test that invalid values are ignored."""
@@ -484,113 +783,59 @@ async def test_invalid_values(hass, mqtt_mock):
assert state.attributes.get("effect") == "rainbow"
-async def test_default_availability_payload(hass, mqtt_mock):
- """Test availability by default payload with defined topic."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "template",
- "name": "test",
- "command_topic": "test_light_rgb/set",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "availability_topic": "availability-topic",
- }
- },
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("light.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "template",
- "name": "test",
- "command_topic": "test_light_rgb/set",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("light.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("light.test")
- assert state.state == STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "schema": "template",'
- ' "command_topic": "test_topic",'
- ' "command_on_template": "on",'
- ' "command_off_template": "off",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "schema": "template",'
- ' "command_topic": "test_topic",'
- ' "command_on_template": "on",'
- ' "command_off_template": "off",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG
)
@@ -633,24 +878,6 @@ async def test_discovery_removal(hass, mqtt_mock, caplog):
await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data)
-async def test_discovery_deprecated(hass, mqtt_mock, caplog):
- """Test discovery of mqtt template light with deprecated option."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {"mqtt": {}}, entry)
- data = (
- '{ "name": "Beer",'
- ' "platform": "mqtt_template",'
- ' "command_topic": "test_topic",'
- ' "command_on_template": "on",'
- ' "command_off_template": "off"}'
- )
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Beer"
-
-
async def test_discovery_update_light(hass, mqtt_mock, caplog):
"""Test update of discovered light."""
data1 = (
@@ -690,35 +917,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT light device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT light device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "schema": "template",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py
index d636eb1534d..151021a45f8 100644
--- a/tests/components/mqtt/test_lock.py
+++ b/tests/components/mqtt/test_lock.py
@@ -1,22 +1,24 @@
"""The tests for the MQTT lock platform."""
from homeassistant.components import lock
-from homeassistant.const import (
- ATTR_ASSUMED_STATE,
- STATE_LOCKED,
- STATE_UNAVAILABLE,
- STATE_UNLOCKED,
-)
+from homeassistant.const import ATTR_ASSUMED_STATE, STATE_LOCKED, STATE_UNLOCKED
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -25,29 +27,8 @@ from .common import (
from tests.common import async_fire_mqtt_message
from tests.components.lock import common
-DEFAULT_CONFIG_ATTR = {
- lock.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
+DEFAULT_CONFIG = {
+ lock.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"}
}
@@ -269,109 +250,59 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock):
assert state.attributes.get(ATTR_ASSUMED_STATE)
-async def test_default_availability_payload(hass, mqtt_mock):
- """Test availability by default payload with defined topic."""
- assert await async_setup_component(
- hass,
- lock.DOMAIN,
- {
- lock.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "payload_lock": "LOCK",
- "payload_unlock": "UNLOCK",
- "availability_topic": "availability-topic",
- }
- },
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("lock.test")
- assert state.state is STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("lock.test")
- assert state.state is not STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("lock.test")
- assert state.state is STATE_UNAVAILABLE
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- lock.DOMAIN,
- {
- lock.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "payload_lock": "LOCK",
- "payload_unlock": "UNLOCK",
- "state_locked": "LOCKED",
- "state_unlocked": "UNLOCKED",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("lock.test")
- assert state.state is STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("lock.test")
- assert state.state is not STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("lock.test")
- assert state.state is STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG
)
@@ -428,32 +359,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
await help_test_discovery_broken(hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT lock device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT lock device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- lock.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, lock.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py
index 0cf24894bcb..34d3c33f8d7 100644
--- a/tests/components/mqtt/test_sensor.py
+++ b/tests/components/mqtt/test_sensor.py
@@ -6,20 +6,32 @@ from unittest.mock import patch
from homeassistant.components import mqtt
from homeassistant.components.mqtt.discovery import async_start
import homeassistant.components.sensor as sensor
-from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE
+from homeassistant.const import EVENT_STATE_CHANGED
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_debug_info,
+ help_test_entity_debug_info_max_messages,
+ help_test_entity_debug_info_message,
+ help_test_entity_debug_info_remove,
+ help_test_entity_debug_info_update_entity_id,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -31,28 +43,8 @@ from tests.common import (
async_fire_time_changed,
)
-DEFAULT_CONFIG_ATTR = {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
+DEFAULT_CONFIG = {
+ sensor.DOMAIN: {"platform": "mqtt", "name": "test", "state_topic": "test-topic"}
}
@@ -233,157 +225,26 @@ async def test_force_update_enabled(hass, mqtt_mock):
assert len(events) == 2
-async def test_default_availability_payload(hass, mqtt_mock):
- """Test availability by default payload with defined topic."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "availability_topic": "availability-topic",
- }
- },
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("sensor.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("sensor.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("sensor.test")
- assert state.state == STATE_UNAVAILABLE
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("sensor.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("sensor.test")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("sensor.test")
- assert state.state == STATE_UNAVAILABLE
-
-
-async def test_setting_sensor_attribute_via_legacy_mqtt_json_message(hass, mqtt_mock):
- """Test the setting of attribute via MQTT with JSON payload."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "unit_of_measurement": "fav unit",
- "json_attributes_topic": "test-attributes-topic",
- }
- },
- )
-
- async_fire_mqtt_message(hass, "test-attributes-topic", '{ "val": "100" }')
- state = hass.states.get("sensor.test")
-
- assert state.attributes.get("val") == "100"
-
-
-async def test_update_with_legacy_json_attrs_not_dict(hass, mqtt_mock, caplog):
- """Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "unit_of_measurement": "fav unit",
- "json_attributes_topic": "test-attributes-topic",
- }
- },
- )
-
- async_fire_mqtt_message(hass, "test-attributes-topic", '[ "list", "of", "things"]')
- state = hass.states.get("sensor.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
-
-async def test_update_with_legacy_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
- """Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "unit_of_measurement": "fav unit",
- "json_attributes_topic": "test-attributes-topic",
- }
- },
- )
-
- async_fire_mqtt_message(hass, "test-attributes-topic", "This is not JSON")
-
- state = hass.states.get("sensor.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
-
-async def test_update_with_legacy_json_attrs_and_template(hass, mqtt_mock):
- """Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "unit_of_measurement": "fav unit",
- "value_template": "{{ value_json.val }}",
- "json_attributes": "val",
- }
- },
- )
-
- async_fire_mqtt_message(hass, "test-topic", '{ "val": "100" }')
- state = hass.states.get("sensor.test")
-
- assert state.attributes.get("val") == "100"
- assert state.state == "100"
-
async def test_invalid_device_class(hass, mqtt_mock):
"""Test device_class option with invalid value."""
@@ -431,72 +292,36 @@ async def test_valid_device_class(hass, mqtt_mock):
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
- config = {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- }
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, sensor.DOMAIN, config
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_setting_attribute_with_template(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- "json_attributes_template": "{{ value_json['Timer1'] | tojson }}",
- }
- },
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
)
- async_fire_mqtt_message(
- hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}})
- )
- state = hass.states.get("sensor.test")
-
- assert state.attributes.get("Arm") == 0
- assert state.attributes.get("Time") == "22:18"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "state_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "state_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG
)
@@ -545,34 +370,46 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT sensor device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT sensor device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- sensor.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, sensor.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
async def test_entity_device_info_with_hub(hass, mqtt_mock):
@@ -605,3 +442,36 @@ async def test_entity_device_info_with_hub(hass, mqtt_mock):
device = registry.async_get_device({("mqtt", "helloworld")}, set())
assert device is not None
assert device.via_device_id == hub.id
+
+
+async def test_entity_debug_info(hass, mqtt_mock):
+ """Test MQTT sensor debug info."""
+ await help_test_entity_debug_info(hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG)
+
+
+async def test_entity_debug_info_max_messages(hass, mqtt_mock):
+ """Test MQTT sensor debug info."""
+ await help_test_entity_debug_info_max_messages(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_debug_info_message(hass, mqtt_mock):
+ """Test MQTT debug info."""
+ await help_test_entity_debug_info_message(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_debug_info_remove(hass, mqtt_mock):
+ """Test MQTT sensor debug info."""
+ await help_test_entity_debug_info_remove(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_debug_info_update_entity_id(hass, mqtt_mock):
+ """Test MQTT sensor debug info."""
+ await help_test_entity_debug_info_update_entity_id(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py
index 52c101d138c..ecb38ef5774 100644
--- a/tests/components/mqtt/test_state_vacuum.py
+++ b/tests/components/mqtt/test_state_vacuum.py
@@ -26,20 +26,26 @@ from homeassistant.const import (
CONF_NAME,
CONF_PLATFORM,
ENTITY_MATCH_ALL,
- STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -63,29 +69,8 @@ DEFAULT_CONFIG = {
mqttvacuum.CONF_FAN_SPEED_LIST: ["min", "medium", "high", "max"],
}
-DEFAULT_CONFIG_ATTR = {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "schema": "state",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "schema": "state",
- "name": "Test 1",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
+DEFAULT_CONFIG_2 = {
+ vacuum.DOMAIN: {"platform": "mqtt", "schema": "state", "name": "test"}
}
@@ -326,91 +311,59 @@ async def test_status_invalid_json(hass, mqtt_mock):
assert state.state == STATE_UNKNOWN
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
async def test_default_availability_payload(hass, mqtt_mock):
"""Test availability by default payload with defined topic."""
- config = deepcopy(DEFAULT_CONFIG)
- config.update({"availability_topic": "availability-topic"})
-
- assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config})
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "online")
-
- state = hass.states.get("vacuum.mqtttest")
- assert STATE_UNAVAILABLE != state.state
-
- async_fire_mqtt_message(hass, "availability-topic", "offline")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
async def test_custom_availability_payload(hass, mqtt_mock):
"""Test availability by custom payload with defined topic."""
- config = deepcopy(DEFAULT_CONFIG)
- config.update(
- {
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
- assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config})
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "good")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state != STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability-topic", "nogood")
-
- state = hass.states.get("vacuum.mqtttest")
- assert state.state == STATE_UNAVAILABLE
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "schema": "state",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "schema": "state",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
@@ -461,33 +414,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT vacuum device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT vacuum device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- vacuum.DOMAIN: [
- {
- "platform": "mqtt",
- "schema": "state",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2
+ )
diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py
index 983d91f08a2..d8ca8031390 100644
--- a/tests/components/mqtt/test_switch.py
+++ b/tests/components/mqtt/test_switch.py
@@ -3,24 +3,26 @@ from asynctest import patch
import pytest
from homeassistant.components import switch
-from homeassistant.const import (
- ATTR_ASSUMED_STATE,
- STATE_OFF,
- STATE_ON,
- STATE_UNAVAILABLE,
-)
+from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
-from .common import (
+from .test_common import (
+ help_test_availability_without_topic,
+ help_test_custom_availability_payload,
+ help_test_default_availability_payload,
help_test_discovery_broken,
help_test_discovery_removal,
help_test_discovery_update,
help_test_discovery_update_attr,
+ help_test_entity_device_info_remove,
help_test_entity_device_info_update,
+ help_test_entity_device_info_with_connection,
help_test_entity_device_info_with_identifier,
- help_test_entity_id_update,
+ help_test_entity_id_update_discovery_update,
+ help_test_entity_id_update_subscriptions,
help_test_setting_attribute_via_mqtt_json_message,
+ help_test_setting_attribute_with_template,
help_test_unique_id,
help_test_update_with_json_attrs_bad_JSON,
help_test_update_with_json_attrs_not_dict,
@@ -29,29 +31,8 @@ from .common import (
from tests.common import async_fire_mqtt_message, async_mock_mqtt_component, mock_coro
from tests.components.switch import common
-DEFAULT_CONFIG_ATTR = {
- switch.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
-}
-
-DEFAULT_CONFIG_DEVICE_INFO = {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test-command-topic",
- "device": {
- "identifiers": ["helloworld"],
- "connections": [["mac", "02:5b:26:a8:dc:12"]],
- "manufacturer": "Whatever",
- "name": "Beer",
- "model": "Glass",
- "sw_version": "0.1-beta",
- },
- "unique_id": "veryunique",
+DEFAULT_CONFIG = {
+ switch.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"}
}
@@ -170,92 +151,47 @@ async def test_controlling_state_via_topic_and_json_message(hass, mock_publish):
assert state.state == STATE_OFF
-async def test_default_availability_payload(hass, mock_publish):
- """Test the availability payload."""
- assert await async_setup_component(
- hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "availability_topic": "availability_topic",
- "payload_on": 1,
- "payload_off": 0,
- }
- },
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ await help_test_availability_without_topic(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
)
- state = hass.states.get("switch.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability_topic", "online")
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ config = {
+ switch.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "state-topic",
+ "command_topic": "command-topic",
+ "payload_on": 1,
+ "payload_off": 0,
+ }
+ }
- state = hass.states.get("switch.test")
- assert state.state == STATE_OFF
- assert not state.attributes.get(ATTR_ASSUMED_STATE)
-
- async_fire_mqtt_message(hass, "availability_topic", "offline")
-
- state = hass.states.get("switch.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "state-topic", "1")
-
- state = hass.states.get("switch.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability_topic", "online")
-
- state = hass.states.get("switch.test")
- assert state.state == STATE_ON
-
-
-async def test_custom_availability_payload(hass, mock_publish):
- """Test the availability payload."""
- assert await async_setup_component(
- hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "state-topic",
- "command_topic": "command-topic",
- "availability_topic": "availability_topic",
- "payload_on": 1,
- "payload_off": 0,
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
+ await help_test_default_availability_payload(
+ hass, mqtt_mock, switch.DOMAIN, config, True, "state-topic", "1"
)
- state = hass.states.get("switch.test")
- assert state.state == STATE_UNAVAILABLE
- async_fire_mqtt_message(hass, "availability_topic", "good")
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ config = {
+ switch.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "state-topic",
+ "command_topic": "command-topic",
+ "payload_on": 1,
+ "payload_off": 0,
+ }
+ }
- state = hass.states.get("switch.test")
- assert state.state == STATE_OFF
- assert not state.attributes.get(ATTR_ASSUMED_STATE)
-
- async_fire_mqtt_message(hass, "availability_topic", "nogood")
-
- state = hass.states.get("switch.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "state-topic", "1")
-
- state = hass.states.get("switch.test")
- assert state.state == STATE_UNAVAILABLE
-
- async_fire_mqtt_message(hass, "availability_topic", "good")
-
- state = hass.states.get("switch.test")
- assert state.state == STATE_ON
+ await help_test_custom_availability_payload(
+ hass, mqtt_mock, switch.DOMAIN, config, True, "state-topic", "1"
+ )
async def test_custom_state_payload(hass, mock_publish):
@@ -295,38 +231,35 @@ async def test_custom_state_payload(hass, mock_publish):
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
await help_test_setting_attribute_via_mqtt_json_message(
- hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ await help_test_setting_attribute_with_template(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_not_dict(
- hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG
)
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
await help_test_update_with_json_attrs_bad_JSON(
- hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG_ATTR
+ hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG
)
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- data1 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
- )
- data2 = (
- '{ "name": "test",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
await help_test_discovery_update_attr(
- hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2
+ hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG
)
@@ -393,32 +326,43 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
)
+async def test_entity_device_info_with_connection(hass, mqtt_mock):
+ """Test MQTT switch device registry integration."""
+ await help_test_entity_device_info_with_connection(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
+ )
+
+
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT switch device registry integration."""
await help_test_entity_device_info_with_identifier(
- hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
)
async def test_entity_device_info_update(hass, mqtt_mock):
"""Test device registry update."""
await help_test_entity_device_info_update(
- hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
)
-async def test_entity_id_update(hass, mqtt_mock):
+async def test_entity_device_info_remove(hass, mqtt_mock):
+ """Test device registry remove."""
+ await help_test_entity_device_info_remove(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_subscriptions(hass, mqtt_mock):
"""Test MQTT subscriptions are managed when entity_id is updated."""
- config = {
- switch.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- }
- await help_test_entity_id_update(hass, mqtt_mock, switch.DOMAIN, config)
+ await help_test_entity_id_update_subscriptions(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
+ )
+
+
+async def test_entity_id_update_discovery_update(hass, mqtt_mock):
+ """Test MQTT discovery update when entity_id is updated."""
+ await help_test_entity_id_update_discovery_update(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG
+ )
diff --git a/tests/components/myq/__init__.py b/tests/components/myq/__init__.py
new file mode 100644
index 00000000000..63dd25a4d0b
--- /dev/null
+++ b/tests/components/myq/__init__.py
@@ -0,0 +1 @@
+"""Tests for the MyQ integration."""
diff --git a/tests/components/myq/test_binary_sensor.py b/tests/components/myq/test_binary_sensor.py
new file mode 100644
index 00000000000..cef1f2e2409
--- /dev/null
+++ b/tests/components/myq/test_binary_sensor.py
@@ -0,0 +1,20 @@
+"""The scene tests for the myq platform."""
+
+from homeassistant.const import STATE_ON
+
+from .util import async_init_integration
+
+
+async def test_create_binary_sensors(hass):
+ """Test creation of binary_sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("binary_sensor.happy_place_myq_gateway")
+ assert state.state == STATE_ON
+ expected_attributes = {"device_class": "connectivity"}
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
diff --git a/tests/components/myq/test_config_flow.py b/tests/components/myq/test_config_flow.py
new file mode 100644
index 00000000000..9fb3b34ca63
--- /dev/null
+++ b/tests/components/myq/test_config_flow.py
@@ -0,0 +1,127 @@
+"""Test the MyQ config flow."""
+from asynctest import patch
+from pymyq.errors import InvalidCredentialsError, MyQError
+
+from homeassistant import config_entries, setup
+from homeassistant.components.myq.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from tests.common import MockConfigEntry
+
+
+async def test_form_user(hass):
+ """Test we get the user 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.myq.config_flow.pymyq.login", return_value=True,
+ ), patch(
+ "homeassistant.components.myq.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.myq.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"username": "test-username", "password": "test-password"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "test-username"
+ assert result2["data"] == {
+ "username": "test-username",
+ "password": "test-password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_import(hass):
+ """Test we can import."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ with patch(
+ "homeassistant.components.myq.config_flow.pymyq.login", return_value=True,
+ ), patch(
+ "homeassistant.components.myq.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.myq.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-username", "password": "test-password"},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "test-username"
+ assert result["data"] == {
+ "username": "test-username",
+ "password": "test-password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.myq.config_flow.pymyq.login",
+ side_effect=InvalidCredentialsError,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"username": "test-username", "password": "test-password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.myq.config_flow.pymyq.login", side_effect=MyQError,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"username": "test-username", "password": "test-password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_homekit(hass):
+ """Test that we abort from homekit if myq is already setup."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "homekit"}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ entry = MockConfigEntry(
+ domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "homekit"}
+ )
+ assert result["type"] == "abort"
diff --git a/tests/components/myq/test_cover.py b/tests/components/myq/test_cover.py
new file mode 100644
index 00000000000..5029c4f6b0b
--- /dev/null
+++ b/tests/components/myq/test_cover.py
@@ -0,0 +1,50 @@
+"""The scene tests for the myq platform."""
+
+from homeassistant.const import STATE_CLOSED
+
+from .util import async_init_integration
+
+
+async def test_create_covers(hass):
+ """Test creation of covers."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("cover.large_garage_door")
+ assert state.state == STATE_CLOSED
+ expected_attributes = {
+ "device_class": "garage",
+ "friendly_name": "Large Garage Door",
+ "supported_features": 3,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("cover.small_garage_door")
+ assert state.state == STATE_CLOSED
+ expected_attributes = {
+ "device_class": "garage",
+ "friendly_name": "Small Garage Door",
+ "supported_features": 3,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("cover.gate")
+ assert state.state == STATE_CLOSED
+ expected_attributes = {
+ "device_class": "gate",
+ "friendly_name": "Gate",
+ "supported_features": 3,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
diff --git a/tests/components/myq/util.py b/tests/components/myq/util.py
new file mode 100644
index 00000000000..48af17188eb
--- /dev/null
+++ b/tests/components/myq/util.py
@@ -0,0 +1,42 @@
+"""Tests for the myq integration."""
+
+import json
+
+from asynctest import patch
+
+from homeassistant.components.myq.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry, load_fixture
+
+
+async def async_init_integration(
+ hass: HomeAssistant, skip_setup: bool = False,
+) -> MockConfigEntry:
+ """Set up the myq integration in Home Assistant."""
+
+ devices_fixture = "myq/devices.json"
+ devices_json = load_fixture(devices_fixture)
+ devices_dict = json.loads(devices_json)
+
+ def _handle_mock_api_request(method, endpoint, **kwargs):
+ if endpoint == "Login":
+ return {"SecurityToken": 1234}
+ elif endpoint == "My":
+ return {"Account": {"Id": 1}}
+ elif endpoint == "Accounts/1/Devices":
+ return devices_dict
+ return {}
+
+ with patch("pymyq.api.API.request", side_effect=_handle_mock_api_request):
+ entry = MockConfigEntry(
+ domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}
+ )
+ entry.add_to_hass(hass)
+
+ if not skip_setup:
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/nexia/__init__.py b/tests/components/nexia/__init__.py
new file mode 100644
index 00000000000..27e986cc148
--- /dev/null
+++ b/tests/components/nexia/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Nexia integration."""
diff --git a/tests/components/nexia/test_binary_sensor.py b/tests/components/nexia/test_binary_sensor.py
new file mode 100644
index 00000000000..64b2946ee2f
--- /dev/null
+++ b/tests/components/nexia/test_binary_sensor.py
@@ -0,0 +1,35 @@
+"""The binary_sensor tests for the nexia platform."""
+
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from .util import async_init_integration
+
+
+async def test_create_binary_sensors(hass):
+ """Test creation of binary sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("binary_sensor.master_suite_blower_active")
+ assert state.state == STATE_ON
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Master Suite Blower Active",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("binary_sensor.downstairs_east_wing_blower_active")
+ assert state.state == STATE_OFF
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Downstairs East Wing Blower Active",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
diff --git a/tests/components/nexia/test_climate.py b/tests/components/nexia/test_climate.py
new file mode 100644
index 00000000000..7f3ed900d3c
--- /dev/null
+++ b/tests/components/nexia/test_climate.py
@@ -0,0 +1,81 @@
+"""The lock tests for the august platform."""
+
+from homeassistant.components.climate.const import HVAC_MODE_HEAT_COOL
+
+from .util import async_init_integration
+
+
+async def test_climate_zones(hass):
+ """Test creation climate zones."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("climate.nick_office")
+ assert state.state == HVAC_MODE_HEAT_COOL
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "current_humidity": 52.0,
+ "current_temperature": 22.8,
+ "dehumidify_setpoint": 45.0,
+ "dehumidify_supported": True,
+ "fan_mode": "auto",
+ "fan_modes": ["auto", "on", "circulate"],
+ "friendly_name": "Nick Office",
+ "humidify_supported": False,
+ "humidity": 45.0,
+ "hvac_action": "cooling",
+ "hvac_modes": ["off", "auto", "heat_cool", "heat", "cool"],
+ "max_humidity": 65.0,
+ "max_temp": 37.2,
+ "min_humidity": 35.0,
+ "min_temp": 12.8,
+ "preset_mode": "None",
+ "preset_modes": ["None", "Home", "Away", "Sleep"],
+ "supported_features": 31,
+ "target_temp_high": 26.1,
+ "target_temp_low": 17.2,
+ "target_temp_step": 1.0,
+ "temperature": None,
+ "zone_status": "Relieving Air",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("climate.kitchen")
+ assert state.state == HVAC_MODE_HEAT_COOL
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "current_humidity": 36.0,
+ "current_temperature": 25.0,
+ "dehumidify_setpoint": 50.0,
+ "dehumidify_supported": True,
+ "fan_mode": "auto",
+ "fan_modes": ["auto", "on", "circulate"],
+ "friendly_name": "Kitchen",
+ "humidify_supported": False,
+ "humidity": 50.0,
+ "hvac_action": "idle",
+ "hvac_modes": ["off", "auto", "heat_cool", "heat", "cool"],
+ "max_humidity": 65.0,
+ "max_temp": 37.2,
+ "min_humidity": 35.0,
+ "min_temp": 12.8,
+ "preset_mode": "None",
+ "preset_modes": ["None", "Home", "Away", "Sleep"],
+ "supported_features": 31,
+ "target_temp_high": 26.1,
+ "target_temp_low": 17.2,
+ "target_temp_step": 1.0,
+ "temperature": None,
+ "zone_status": "Idle",
+ }
+
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
diff --git a/tests/components/nexia/test_config_flow.py b/tests/components/nexia/test_config_flow.py
new file mode 100644
index 00000000000..3cb57d77f12
--- /dev/null
+++ b/tests/components/nexia/test_config_flow.py
@@ -0,0 +1,76 @@
+"""Test the nexia config flow."""
+from asynctest import patch
+from asynctest.mock import MagicMock
+from requests.exceptions import ConnectTimeout
+
+from homeassistant import config_entries, setup
+from homeassistant.components.nexia.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, 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.nexia.config_flow.NexiaHome.get_name",
+ return_value="myhouse",
+ ), patch(
+ "homeassistant.components.nexia.config_flow.NexiaHome.login",
+ side_effect=MagicMock(),
+ ), patch(
+ "homeassistant.components.nexia.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.nexia.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "myhouse"
+ assert result2["data"] == {
+ CONF_USERNAME: "username",
+ CONF_PASSWORD: "password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("homeassistant.components.nexia.config_flow.NexiaHome.login"):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "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.nexia.config_flow.NexiaHome.login",
+ side_effect=ConnectTimeout,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/nexia/test_scene.py b/tests/components/nexia/test_scene.py
new file mode 100644
index 00000000000..4a325552e80
--- /dev/null
+++ b/tests/components/nexia/test_scene.py
@@ -0,0 +1,72 @@
+"""The scene tests for the nexia platform."""
+
+from .util import async_init_integration
+
+
+async def test_automation_scenes(hass):
+ """Test creation automation scenes."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("scene.away_short")
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "description": "When IFTTT activates the automation Upstairs "
+ "West Wing will permanently hold the heat to 63.0 "
+ "and cool to 80.0 AND Downstairs East Wing will "
+ "permanently hold the heat to 63.0 and cool to "
+ "79.0 AND Downstairs West Wing will permanently "
+ "hold the heat to 63.0 and cool to 79.0 AND "
+ "Upstairs West Wing will permanently hold the "
+ "heat to 63.0 and cool to 81.0 AND Upstairs West "
+ "Wing will change Fan Mode to Auto AND Downstairs "
+ "East Wing will change Fan Mode to Auto AND "
+ "Downstairs West Wing will change Fan Mode to "
+ "Auto AND Activate the mode named 'Away Short' "
+ "AND Master Suite will permanently hold the heat "
+ "to 63.0 and cool to 79.0 AND Master Suite will "
+ "change Fan Mode to Auto",
+ "friendly_name": "Away Short",
+ "icon": "mdi:script-text-outline",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("scene.power_outage")
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "description": "When IFTTT activates the automation Upstairs "
+ "West Wing will permanently hold the heat to 55.0 "
+ "and cool to 90.0 AND Downstairs East Wing will "
+ "permanently hold the heat to 55.0 and cool to "
+ "90.0 AND Downstairs West Wing will permanently "
+ "hold the heat to 55.0 and cool to 90.0 AND "
+ "Activate the mode named 'Power Outage'",
+ "friendly_name": "Power Outage",
+ "icon": "mdi:script-text-outline",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("scene.power_restored")
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "description": "When IFTTT activates the automation Upstairs "
+ "West Wing will Run Schedule AND Downstairs East "
+ "Wing will Run Schedule AND Downstairs West Wing "
+ "will Run Schedule AND Activate the mode named "
+ "'Home'",
+ "friendly_name": "Power Restored",
+ "icon": "mdi:script-text-outline",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py
new file mode 100644
index 00000000000..6e258d0ad55
--- /dev/null
+++ b/tests/components/nexia/test_sensor.py
@@ -0,0 +1,133 @@
+"""The sensor tests for the nexia platform."""
+
+from .util import async_init_integration
+
+
+async def test_create_sensors(hass):
+ """Test creation of sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("sensor.nick_office_temperature")
+ assert state.state == "23"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "device_class": "temperature",
+ "friendly_name": "Nick Office Temperature",
+ "unit_of_measurement": "°C",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.nick_office_zone_setpoint_status")
+ assert state.state == "Permanent Hold"
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Nick Office Zone Setpoint Status",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.nick_office_zone_status")
+ assert state.state == "Relieving Air"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Nick Office Zone Status",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.master_suite_air_cleaner_mode")
+ assert state.state == "auto"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Master Suite Air Cleaner Mode",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.master_suite_current_compressor_speed")
+ assert state.state == "69.0"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Master Suite Current Compressor Speed",
+ "unit_of_measurement": "%",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.master_suite_outdoor_temperature")
+ assert state.state == "30.6"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "device_class": "temperature",
+ "friendly_name": "Master Suite Outdoor Temperature",
+ "unit_of_measurement": "°C",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.master_suite_relative_humidity")
+ assert state.state == "52.0"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "device_class": "humidity",
+ "friendly_name": "Master Suite Relative Humidity",
+ "unit_of_measurement": "%",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.master_suite_requested_compressor_speed")
+ assert state.state == "69.0"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Master Suite Requested Compressor Speed",
+ "unit_of_measurement": "%",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
+
+ state = hass.states.get("sensor.master_suite_system_status")
+ assert state.state == "Cooling"
+
+ expected_attributes = {
+ "attribution": "Data provided by mynexia.com",
+ "friendly_name": "Master Suite System Status",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(
+ state.attributes[key] == expected_attributes[key] for key in expected_attributes
+ )
diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py
new file mode 100644
index 00000000000..cc2b11afcbe
--- /dev/null
+++ b/tests/components/nexia/util.py
@@ -0,0 +1,45 @@
+"""Tests for the nexia integration."""
+import uuid
+
+from asynctest import patch
+from nexia.home import NexiaHome
+import requests_mock
+
+from homeassistant.components.nexia.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry, load_fixture
+
+
+async def async_init_integration(
+ hass: HomeAssistant, skip_setup: bool = False,
+) -> MockConfigEntry:
+ """Set up the nexia integration in Home Assistant."""
+
+ house_fixture = "nexia/mobile_houses_123456.json"
+ session_fixture = "nexia/session_123456.json"
+ sign_in_fixture = "nexia/sign_in.json"
+
+ with requests_mock.mock() as m, patch(
+ "nexia.home.load_or_create_uuid", return_value=uuid.uuid4()
+ ):
+ m.post(NexiaHome.API_MOBILE_SESSION_URL, text=load_fixture(session_fixture))
+ m.get(
+ NexiaHome.API_MOBILE_HOUSES_URL.format(house_id=123456),
+ text=load_fixture(house_fixture),
+ )
+ m.post(
+ NexiaHome.API_MOBILE_ACCOUNTS_SIGN_IN_URL,
+ text=load_fixture(sign_in_fixture),
+ )
+ entry = MockConfigEntry(
+ domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}
+ )
+ entry.add_to_hass(hass)
+
+ if not skip_setup:
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/nuheat/mocks.py b/tests/components/nuheat/mocks.py
new file mode 100644
index 00000000000..a9adfd3aa57
--- /dev/null
+++ b/tests/components/nuheat/mocks.py
@@ -0,0 +1,125 @@
+"""The test for the NuHeat thermostat module."""
+from asynctest.mock import MagicMock, Mock
+from nuheat.config import SCHEDULE_HOLD, SCHEDULE_RUN, SCHEDULE_TEMPORARY_HOLD
+
+from homeassistant.components.nuheat.const import DOMAIN
+from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME
+
+
+def _get_mock_thermostat_run():
+ serial_number = "12345"
+ thermostat = Mock(
+ serial_number=serial_number,
+ room="Master bathroom",
+ online=True,
+ heating=True,
+ temperature=2222,
+ celsius=22,
+ fahrenheit=72,
+ max_celsius=69,
+ max_fahrenheit=157,
+ min_celsius=5,
+ min_fahrenheit=41,
+ schedule_mode=SCHEDULE_RUN,
+ target_celsius=22,
+ target_fahrenheit=72,
+ target_temperature=2217,
+ )
+
+ thermostat.get_data = Mock()
+ thermostat.resume_schedule = Mock()
+ thermostat.schedule_mode = Mock()
+ return thermostat
+
+
+def _get_mock_thermostat_schedule_hold_unavailable():
+ serial_number = "876"
+ thermostat = Mock(
+ serial_number=serial_number,
+ room="Guest bathroom",
+ online=False,
+ heating=False,
+ temperature=12,
+ celsius=12,
+ fahrenheit=102,
+ max_celsius=99,
+ max_fahrenheit=357,
+ min_celsius=9,
+ min_fahrenheit=21,
+ schedule_mode=SCHEDULE_HOLD,
+ target_celsius=23,
+ target_fahrenheit=79,
+ target_temperature=2609,
+ )
+
+ thermostat.get_data = Mock()
+ thermostat.resume_schedule = Mock()
+ thermostat.schedule_mode = Mock()
+ return thermostat
+
+
+def _get_mock_thermostat_schedule_hold_available():
+ serial_number = "876"
+ thermostat = Mock(
+ serial_number=serial_number,
+ room="Available bathroom",
+ online=True,
+ heating=False,
+ temperature=12,
+ celsius=12,
+ fahrenheit=102,
+ max_celsius=99,
+ max_fahrenheit=357,
+ min_celsius=9,
+ min_fahrenheit=21,
+ schedule_mode=SCHEDULE_HOLD,
+ target_celsius=23,
+ target_fahrenheit=79,
+ target_temperature=2609,
+ )
+
+ thermostat.get_data = Mock()
+ thermostat.resume_schedule = Mock()
+ thermostat.schedule_mode = Mock()
+ return thermostat
+
+
+def _get_mock_thermostat_schedule_temporary_hold():
+ serial_number = "999"
+ thermostat = Mock(
+ serial_number=serial_number,
+ room="Temp bathroom",
+ online=True,
+ heating=False,
+ temperature=14,
+ celsius=13,
+ fahrenheit=202,
+ max_celsius=39,
+ max_fahrenheit=357,
+ min_celsius=3,
+ min_fahrenheit=31,
+ schedule_mode=SCHEDULE_TEMPORARY_HOLD,
+ target_celsius=43,
+ target_fahrenheit=99,
+ target_temperature=3729,
+ )
+
+ thermostat.get_data = Mock()
+ thermostat.resume_schedule = Mock()
+ thermostat.schedule_mode = Mock()
+ return thermostat
+
+
+def _get_mock_nuheat(authenticate=None, get_thermostat=None):
+ nuheat_mock = MagicMock()
+ type(nuheat_mock).authenticate = MagicMock()
+ type(nuheat_mock).get_thermostat = MagicMock(return_value=get_thermostat)
+
+ return nuheat_mock
+
+
+def _mock_get_config():
+ """Return a default nuheat config."""
+ return {
+ DOMAIN: {CONF_USERNAME: "me", CONF_PASSWORD: "secret", CONF_DEVICES: [12345]}
+ }
diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py
index c35497968ac..7bf52026ef9 100644
--- a/tests/components/nuheat/test_climate.py
+++ b/tests/components/nuheat/test_climate.py
@@ -1,196 +1,133 @@
"""The test for the NuHeat thermostat module."""
-import unittest
-from unittest.mock import Mock, patch
+from asynctest.mock import patch
-from homeassistant.components.climate.const import (
- HVAC_MODE_HEAT,
- HVAC_MODE_OFF,
- SUPPORT_PRESET_MODE,
- SUPPORT_TARGET_TEMPERATURE,
+from homeassistant.components.nuheat.const import DOMAIN
+from homeassistant.setup import async_setup_component
+
+from .mocks import (
+ _get_mock_nuheat,
+ _get_mock_thermostat_run,
+ _get_mock_thermostat_schedule_hold_available,
+ _get_mock_thermostat_schedule_hold_unavailable,
+ _get_mock_thermostat_schedule_temporary_hold,
+ _mock_get_config,
)
-import homeassistant.components.nuheat.climate as nuheat
-from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
-
-from tests.common import get_test_home_assistant
-
-SCHEDULE_HOLD = 3
-SCHEDULE_RUN = 1
-SCHEDULE_TEMPORARY_HOLD = 2
-class TestNuHeat(unittest.TestCase):
- """Tests for NuHeat climate."""
+async def test_climate_thermostat_run(hass):
+ """Test a thermostat with the schedule running."""
+ mock_thermostat = _get_mock_thermostat_run()
+ mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
- # pylint: disable=protected-access, no-self-use
+ with patch(
+ "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
- def setUp(self): # pylint: disable=invalid-name
- """Set up test variables."""
- serial_number = "12345"
- temperature_unit = "F"
+ state = hass.states.get("climate.master_bathroom")
+ assert state.state == "auto"
+ expected_attributes = {
+ "current_temperature": 22.2,
+ "friendly_name": "Master bathroom",
+ "hvac_action": "heating",
+ "hvac_modes": ["auto", "heat"],
+ "max_temp": 69.4,
+ "min_temp": 5.0,
+ "preset_mode": "Run Schedule",
+ "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
+ "supported_features": 17,
+ "temperature": 22.2,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
- thermostat = Mock(
- serial_number=serial_number,
- room="Master bathroom",
- online=True,
- heating=True,
- temperature=2222,
- celsius=22,
- fahrenheit=72,
- max_celsius=69,
- max_fahrenheit=157,
- min_celsius=5,
- min_fahrenheit=41,
- schedule_mode=SCHEDULE_RUN,
- target_celsius=22,
- target_fahrenheit=72,
- )
- thermostat.get_data = Mock()
- thermostat.resume_schedule = Mock()
+async def test_climate_thermostat_schedule_hold_unavailable(hass):
+ """Test a thermostat with the schedule hold that is offline."""
+ mock_thermostat = _get_mock_thermostat_schedule_hold_unavailable()
+ mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
- self.api = Mock()
- self.api.get_thermostat.return_value = thermostat
+ with patch(
+ "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
- self.hass = get_test_home_assistant()
- self.thermostat = nuheat.NuHeatThermostat(
- self.api, serial_number, temperature_unit
- )
+ state = hass.states.get("climate.guest_bathroom")
- def tearDown(self): # pylint: disable=invalid-name
- """Stop hass."""
- self.hass.stop()
+ assert state.state == "unavailable"
+ expected_attributes = {
+ "friendly_name": "Guest bathroom",
+ "hvac_modes": ["auto", "heat"],
+ "max_temp": 180.6,
+ "min_temp": -6.1,
+ "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
+ "supported_features": 17,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
- @patch("homeassistant.components.nuheat.climate.NuHeatThermostat")
- def test_setup_platform(self, mocked_thermostat):
- """Test setup_platform."""
- mocked_thermostat.return_value = self.thermostat
- thermostat = mocked_thermostat(self.api, "12345", "F")
- thermostats = [thermostat]
- self.hass.data[nuheat.DOMAIN] = (self.api, ["12345"])
+async def test_climate_thermostat_schedule_hold_available(hass):
+ """Test a thermostat with the schedule hold that is online."""
+ mock_thermostat = _get_mock_thermostat_schedule_hold_available()
+ mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
- config = {}
- add_entities = Mock()
- discovery_info = {}
+ with patch(
+ "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
- nuheat.setup_platform(self.hass, config, add_entities, discovery_info)
- add_entities.assert_called_once_with(thermostats, True)
+ state = hass.states.get("climate.available_bathroom")
- @patch("homeassistant.components.nuheat.climate.NuHeatThermostat")
- def test_resume_program_service(self, mocked_thermostat):
- """Test resume program service."""
- mocked_thermostat.return_value = self.thermostat
- thermostat = mocked_thermostat(self.api, "12345", "F")
- thermostat.resume_program = Mock()
- thermostat.schedule_update_ha_state = Mock()
- thermostat.entity_id = "climate.master_bathroom"
+ assert state.state == "auto"
+ expected_attributes = {
+ "current_temperature": 38.9,
+ "friendly_name": "Available bathroom",
+ "hvac_action": "idle",
+ "hvac_modes": ["auto", "heat"],
+ "max_temp": 180.6,
+ "min_temp": -6.1,
+ "preset_mode": "Run Schedule",
+ "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
+ "supported_features": 17,
+ "temperature": 26.1,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
- self.hass.data[nuheat.DOMAIN] = (self.api, ["12345"])
- nuheat.setup_platform(self.hass, {}, Mock(), {})
- # Explicit entity
- self.hass.services.call(
- nuheat.DOMAIN,
- nuheat.SERVICE_RESUME_PROGRAM,
- {"entity_id": "climate.master_bathroom"},
- True,
- )
+async def test_climate_thermostat_schedule_temporary_hold(hass):
+ """Test a thermostat with the temporary schedule hold that is online."""
+ mock_thermostat = _get_mock_thermostat_schedule_temporary_hold()
+ mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
- thermostat.resume_program.assert_called_with()
- thermostat.schedule_update_ha_state.assert_called_with(True)
+ with patch(
+ "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
- thermostat.resume_program.reset_mock()
- thermostat.schedule_update_ha_state.reset_mock()
+ state = hass.states.get("climate.temp_bathroom")
- # All entities
- self.hass.services.call(nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, {}, True)
-
- thermostat.resume_program.assert_called_with()
- thermostat.schedule_update_ha_state.assert_called_with(True)
-
- def test_name(self):
- """Test name property."""
- assert self.thermostat.name == "Master bathroom"
-
- def test_supported_features(self):
- """Test name property."""
- features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
- assert self.thermostat.supported_features == features
-
- def test_temperature_unit(self):
- """Test temperature unit."""
- assert self.thermostat.temperature_unit == TEMP_FAHRENHEIT
- self.thermostat._temperature_unit = "C"
- assert self.thermostat.temperature_unit == TEMP_CELSIUS
-
- def test_current_temperature(self):
- """Test current temperature."""
- assert self.thermostat.current_temperature == 72
- self.thermostat._temperature_unit = "C"
- assert self.thermostat.current_temperature == 22
-
- def test_current_operation(self):
- """Test current operation."""
- assert self.thermostat.hvac_mode == HVAC_MODE_HEAT
- self.thermostat._thermostat.heating = False
- assert self.thermostat.hvac_mode == HVAC_MODE_OFF
-
- def test_min_temp(self):
- """Test min temp."""
- assert self.thermostat.min_temp == 41
- self.thermostat._temperature_unit = "C"
- assert self.thermostat.min_temp == 5
-
- def test_max_temp(self):
- """Test max temp."""
- assert self.thermostat.max_temp == 157
- self.thermostat._temperature_unit = "C"
- assert self.thermostat.max_temp == 69
-
- def test_target_temperature(self):
- """Test target temperature."""
- assert self.thermostat.target_temperature == 72
- self.thermostat._temperature_unit = "C"
- assert self.thermostat.target_temperature == 22
-
- def test_operation_list(self):
- """Test the operation list."""
- assert self.thermostat.hvac_modes == [HVAC_MODE_HEAT, HVAC_MODE_OFF]
-
- def test_resume_program(self):
- """Test resume schedule."""
- self.thermostat.resume_program()
- self.thermostat._thermostat.resume_schedule.assert_called_once_with()
- assert self.thermostat._force_update
-
- def test_set_temperature(self):
- """Test set temperature."""
- self.thermostat.set_temperature(temperature=85)
- assert self.thermostat._thermostat.target_fahrenheit == 85
- assert self.thermostat._force_update
-
- self.thermostat._temperature_unit = "C"
- self.thermostat.set_temperature(temperature=23)
- assert self.thermostat._thermostat.target_celsius == 23
- assert self.thermostat._force_update
-
- @patch.object(nuheat.NuHeatThermostat, "_throttled_update")
- def test_update_without_throttle(self, throttled_update):
- """Test update without throttle."""
- self.thermostat._force_update = True
- self.thermostat.update()
- throttled_update.assert_called_once_with(no_throttle=True)
- assert not self.thermostat._force_update
-
- @patch.object(nuheat.NuHeatThermostat, "_throttled_update")
- def test_update_with_throttle(self, throttled_update):
- """Test update with throttle."""
- self.thermostat._force_update = False
- self.thermostat.update()
- throttled_update.assert_called_once_with()
- assert not self.thermostat._force_update
-
- def test_throttled_update(self):
- """Test update with throttle."""
- self.thermostat._throttled_update()
- self.thermostat._thermostat.get_data.assert_called_once_with()
+ assert state.state == "auto"
+ expected_attributes = {
+ "current_temperature": 94.4,
+ "friendly_name": "Temp bathroom",
+ "hvac_action": "idle",
+ "hvac_modes": ["auto", "heat"],
+ "max_temp": 180.6,
+ "min_temp": -0.6,
+ "preset_mode": "Run Schedule",
+ "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"],
+ "supported_features": 17,
+ "temperature": 37.2,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
diff --git a/tests/components/nuheat/test_config_flow.py b/tests/components/nuheat/test_config_flow.py
new file mode 100644
index 00000000000..d6e10e1dc7c
--- /dev/null
+++ b/tests/components/nuheat/test_config_flow.py
@@ -0,0 +1,184 @@
+"""Test the NuHeat config flow."""
+from asynctest import MagicMock, patch
+import requests
+
+from homeassistant import config_entries, setup
+from homeassistant.components.nuheat.const import CONF_SERIAL_NUMBER, DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .mocks import _get_mock_thermostat_run
+
+
+async def test_form_user(hass):
+ """Test we get the form with user source."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ mock_thermostat = _get_mock_thermostat_run()
+
+ with patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat",
+ return_value=mock_thermostat,
+ ), patch(
+ "homeassistant.components.nuheat.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.nuheat.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Master bathroom"
+ assert result2["data"] == {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_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_import(hass):
+ """Test we get the form with import source."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ mock_thermostat = _get_mock_thermostat_run()
+
+ with patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat",
+ return_value=mock_thermostat,
+ ), patch(
+ "homeassistant.components.nuheat.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.nuheat.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={
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "Master bathroom"
+ assert result["data"] == {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
+ side_effect=Exception,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result["type"] == "form"
+ assert result["errors"] == {"base": "invalid_auth"}
+
+ response_mock = MagicMock()
+ type(response_mock).status_code = 401
+ with patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
+ side_effect=requests.HTTPError(response=response_mock),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_invalid_thermostat(hass):
+ """Test we handle invalid thermostats."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ response_mock = MagicMock()
+ type(response_mock).status_code = 500
+
+ with patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.nuheat.config_flow.nuheat.NuHeat.get_thermostat",
+ side_effect=requests.HTTPError(response=response_mock),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_thermostat"}
+
+
+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.nuheat.config_flow.nuheat.NuHeat.authenticate",
+ side_effect=requests.exceptions.Timeout,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_SERIAL_NUMBER: "12345",
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/nuheat/test_init.py b/tests/components/nuheat/test_init.py
index 90a209fd897..01128610462 100644
--- a/tests/components/nuheat/test_init.py
+++ b/tests/components/nuheat/test_init.py
@@ -1,43 +1,23 @@
"""NuHeat component tests."""
-import unittest
from unittest.mock import patch
-from homeassistant.components import nuheat
+from homeassistant.components.nuheat.const import DOMAIN
+from homeassistant.setup import async_setup_component
-from tests.common import MockDependency, get_test_home_assistant
+from .mocks import _get_mock_nuheat
VALID_CONFIG = {
"nuheat": {"username": "warm", "password": "feet", "devices": "thermostat123"}
}
+INVALID_CONFIG = {"nuheat": {"username": "warm", "password": "feet"}}
-class TestNuHeat(unittest.TestCase):
- """Test the NuHeat component."""
+async def test_init_success(hass):
+ """Test that we can setup with valid config."""
+ mock_nuheat = _get_mock_nuheat()
- def setUp(self): # pylint: disable=invalid-name
- """Initialize the values for this test class."""
- self.hass = get_test_home_assistant()
- self.config = VALID_CONFIG
-
- def tearDown(self): # pylint: disable=invalid-name
- """Teardown this test class. Stop hass."""
- self.hass.stop()
-
- @MockDependency("nuheat")
- @patch("homeassistant.helpers.discovery.load_platform")
- def test_setup(self, mocked_nuheat, mocked_load):
- """Test setting up the NuHeat component."""
- with patch.object(nuheat, "nuheat", mocked_nuheat):
- nuheat.setup(self.hass, self.config)
-
- mocked_nuheat.NuHeat.assert_called_with("warm", "feet")
- assert nuheat.DOMAIN in self.hass.data
- assert len(self.hass.data[nuheat.DOMAIN]) == 2
- assert isinstance(
- self.hass.data[nuheat.DOMAIN][0], type(mocked_nuheat.NuHeat())
- )
- assert self.hass.data[nuheat.DOMAIN][1] == "thermostat123"
-
- mocked_load.assert_called_with(
- self.hass, "climate", nuheat.DOMAIN, {}, self.config
- )
+ with patch(
+ "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ ):
+ assert await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
diff --git a/tests/components/nut/__init__.py b/tests/components/nut/__init__.py
new file mode 100644
index 00000000000..61ddfb4c07a
--- /dev/null
+++ b/tests/components/nut/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Network UPS Tools (NUT) integration."""
diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py
new file mode 100644
index 00000000000..362f6c0b2ba
--- /dev/null
+++ b/tests/components/nut/test_config_flow.py
@@ -0,0 +1,122 @@
+"""Test the Network UPS Tools (NUT) config flow."""
+from asynctest import MagicMock, patch
+
+from homeassistant import config_entries, setup
+from homeassistant.components.nut.const import DOMAIN
+
+
+def _get_mock_pynutclient(list_vars=None):
+ pynutclient = MagicMock()
+ type(pynutclient).list_ups = MagicMock(return_value=["ups1"])
+ type(pynutclient).list_vars = MagicMock(return_value=list_vars)
+ return pynutclient
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ mock_pynut = _get_mock_pynutclient(list_vars={"battery.voltage": "voltage"})
+
+ with patch(
+ "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ ), patch(
+ "homeassistant.components.nut.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.nut.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ "port": 2222,
+ "alias": "ups1",
+ "resources": ["battery.charge"],
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "ups1@1.1.1.1:2222"
+ assert result2["data"] == {
+ "alias": "ups1",
+ "host": "1.1.1.1",
+ "name": "NUT UPS",
+ "password": "test-password",
+ "port": 2222,
+ "resources": ["battery.charge"],
+ "username": "test-username",
+ }
+ 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_import(hass):
+ """Test we get the form with import source."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ mock_pynut = _get_mock_pynutclient(list_vars={"battery.voltage": "serial"})
+
+ with patch(
+ "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ ), patch(
+ "homeassistant.components.nut.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.nut.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={
+ "host": "localhost",
+ "port": 123,
+ "name": "name",
+ "resources": ["battery.charge"],
+ },
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "localhost:123"
+ assert result["data"] == {
+ "host": "localhost",
+ "port": 123,
+ "name": "name",
+ "resources": ["battery.charge"],
+ }
+ 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_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ mock_pynut = _get_mock_pynutclient()
+
+ with patch(
+ "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ "host": "1.1.1.1",
+ "username": "test-username",
+ "password": "test-password",
+ "port": 2222,
+ "alias": "ups1",
+ "resources": ["battery.charge"],
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/plex/common.py b/tests/components/plex/common.py
new file mode 100644
index 00000000000..adc6f4e0299
--- /dev/null
+++ b/tests/components/plex/common.py
@@ -0,0 +1,20 @@
+"""Common fixtures and functions for Plex tests."""
+from datetime import timedelta
+
+from homeassistant.components.plex.const import (
+ DEBOUNCE_TIMEOUT,
+ PLEX_UPDATE_PLATFORMS_SIGNAL,
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_fire_time_changed
+
+
+async def trigger_plex_update(hass, server_id):
+ """Update Plex by sending signal and jumping ahead by debounce timeout."""
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+ next_update = dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py
index d839ccc674b..bd5d45c0246 100644
--- a/tests/components/plex/test_config_flow.py
+++ b/tests/components/plex/test_config_flow.py
@@ -15,14 +15,13 @@ from homeassistant.components.plex.const import (
CONF_USE_EPISODE_ART,
DOMAIN,
PLEX_SERVER_CONFIG,
- PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS,
)
from homeassistant.config_entries import ENTRY_STATE_LOADED
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, CONF_URL
-from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component
+from .common import trigger_plex_update
from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
from .mock_classes import MockPlexAccount, MockPlexServer
@@ -416,8 +415,7 @@ async def test_option_flow_new_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier
- async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py
index 3358ac1c2cb..cd1ea8725bd 100644
--- a/tests/components/plex/test_init.py
+++ b/tests/components/plex/test_init.py
@@ -1,6 +1,7 @@
"""Tests for Plex setup."""
import copy
from datetime import timedelta
+import ssl
from asynctest import patch
import plexapi
@@ -19,14 +20,15 @@ from homeassistant.const import (
CONF_PORT,
CONF_SSL,
CONF_TOKEN,
+ CONF_URL,
CONF_VERIFY_SSL,
)
-from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
+from .common import trigger_plex_update
from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
-from .mock_classes import MockPlexServer
+from .mock_classes import MockPlexAccount, MockPlexServer
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -72,7 +74,7 @@ async def test_setup_with_config(hass):
)
-async def test_setup_with_config_entry(hass):
+async def test_setup_with_config_entry(hass, caplog):
"""Test setup component with config."""
mock_plex_server = MockPlexServer()
@@ -107,30 +109,28 @@ async def test_setup_with_config_entry(hass):
hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS
)
- async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts))
- async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
- with patch.object(
- mock_plex_server, "clients", side_effect=plexapi.exceptions.BadRequest
+ for test_exception in (
+ plexapi.exceptions.BadRequest,
+ requests.exceptions.RequestException,
):
- async_dispatcher_send(
- hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)
- )
- await hass.async_block_till_done()
+ with patch.object(
+ mock_plex_server, "clients", side_effect=test_exception
+ ) as patched_clients_bad_request:
+ await trigger_plex_update(hass, server_id)
- with patch.object(
- mock_plex_server, "clients", side_effect=requests.exceptions.RequestException
- ):
- async_dispatcher_send(
- hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)
+ assert patched_clients_bad_request.called
+ assert (
+ f"Could not connect to Plex server: {mock_plex_server.friendlyName}"
+ in caplog.text
)
- await hass.async_block_till_done()
+ caplog.clear()
async def test_set_config_entry_unique_id(hass):
@@ -292,11 +292,52 @@ async def test_setup_with_photo_session(hass):
server_id = mock_plex_server.machineIdentifier
- async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
media_player = hass.states.get("media_player.plex_product_title")
assert media_player.state == "idle"
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts))
+
+
+async def test_setup_when_certificate_changed(hass):
+ """Test setup component when the Plex certificate has changed."""
+
+ old_domain = "1-2-3-4.1234567890abcdef1234567890abcdef.plex.direct"
+ old_url = f"https://{old_domain}:32400"
+
+ OLD_HOSTNAME_DATA = copy.deepcopy(DEFAULT_DATA)
+ OLD_HOSTNAME_DATA[const.PLEX_SERVER_CONFIG][CONF_URL] = old_url
+
+ class WrongCertHostnameException(requests.exceptions.SSLError):
+ """Mock the exception showing a mismatched hostname."""
+
+ def __init__(self):
+ self.__context__ = ssl.SSLCertVerificationError(
+ f"hostname '{old_domain}' doesn't match"
+ )
+
+ old_entry = MockConfigEntry(
+ domain=const.DOMAIN,
+ data=OLD_HOSTNAME_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ new_entry = MockConfigEntry(domain=const.DOMAIN, data=DEFAULT_DATA)
+
+ with patch(
+ "plexapi.server.PlexServer", side_effect=WrongCertHostnameException
+ ), patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
+ old_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(old_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert old_entry.state == ENTRY_STATE_LOADED
+
+ assert (
+ old_entry.data[const.PLEX_SERVER_CONFIG][CONF_URL]
+ == new_entry.data[const.PLEX_SERVER_CONFIG][CONF_URL]
+ )
diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py
index 646a6ded32e..3b70f30189a 100644
--- a/tests/components/plex/test_server.py
+++ b/tests/components/plex/test_server.py
@@ -1,5 +1,6 @@
"""Tests for Plex server."""
import copy
+from datetime import timedelta
from asynctest import patch
@@ -7,16 +8,19 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.plex.const import (
CONF_IGNORE_NEW_SHARED_USERS,
CONF_MONITORED_USERS,
+ DEBOUNCE_TIMEOUT,
DOMAIN,
PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
+import homeassistant.util.dt as dt_util
+from .common import trigger_plex_update
from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .mock_classes import MockPlexServer
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, async_fire_time_changed
async def test_new_users_available(hass):
@@ -44,8 +48,7 @@ async def test_new_users_available(hass):
server_id = mock_plex_server.machineIdentifier
- async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@@ -83,8 +86,7 @@ async def test_new_ignored_users_available(hass, caplog):
server_id = mock_plex_server.machineIdentifier
- async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
@@ -92,7 +94,7 @@ async def test_new_ignored_users_available(hass, caplog):
assert len(monitored_users) == 1
assert len(ignored_users) == 2
for ignored_user in ignored_users:
- assert f"Ignoring Plex client owned by {ignored_user}" in caplog.text
+ assert f"Ignoring Plex client owned by '{ignored_user}'" in caplog.text
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts))
@@ -118,8 +120,7 @@ async def test_mark_sessions_idle(hass):
server_id = mock_plex_server.machineIdentifier
- async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == str(len(mock_plex_server.accounts))
@@ -127,8 +128,44 @@ async def test_mark_sessions_idle(hass):
mock_plex_server.clear_clients()
mock_plex_server.clear_sessions()
- async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
- await hass.async_block_till_done()
+ await trigger_plex_update(hass, server_id)
sensor = hass.states.get("sensor.plex_plex_server_1")
assert sensor.state == "0"
+
+
+async def test_debouncer(hass, caplog):
+ """Test debouncer decorator logic."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ mock_plex_server = MockPlexServer(config_entry=entry)
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ server_id = mock_plex_server.machineIdentifier
+
+ # First two updates are skipped
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+
+ next_update = dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ assert (
+ caplog.text.count(f"Throttling update of {mock_plex_server.friendlyName}") == 2
+ )
diff --git a/tests/components/powerwall/__init__.py b/tests/components/powerwall/__init__.py
new file mode 100644
index 00000000000..0e43ec085eb
--- /dev/null
+++ b/tests/components/powerwall/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Tesla Powerwall integration."""
diff --git a/tests/components/powerwall/mocks.py b/tests/components/powerwall/mocks.py
new file mode 100644
index 00000000000..aba6ecfeb23
--- /dev/null
+++ b/tests/components/powerwall/mocks.py
@@ -0,0 +1,69 @@
+"""Mocks for powerwall."""
+
+import json
+import os
+
+from asynctest import MagicMock, PropertyMock
+
+from homeassistant.components.powerwall.const import DOMAIN
+from homeassistant.const import CONF_IP_ADDRESS
+
+from tests.common import load_fixture
+
+
+async def _mock_powerwall_with_fixtures(hass):
+ """Mock data used to build powerwall state."""
+ meters = await _async_load_json_fixture(hass, "meters.json")
+ sitemaster = await _async_load_json_fixture(hass, "sitemaster.json")
+ site_info = await _async_load_json_fixture(hass, "site_info.json")
+ status = await _async_load_json_fixture(hass, "status.json")
+ device_type = await _async_load_json_fixture(hass, "device_type.json")
+
+ return _mock_powerwall_return_value(
+ site_info=site_info,
+ charge=47.31993232,
+ sitemaster=sitemaster,
+ meters=meters,
+ grid_status="SystemGridConnected",
+ status=status,
+ device_type=device_type,
+ )
+
+
+def _mock_powerwall_return_value(
+ site_info=None,
+ charge=None,
+ sitemaster=None,
+ meters=None,
+ grid_status=None,
+ status=None,
+ device_type=None,
+):
+ powerwall_mock = MagicMock()
+ type(powerwall_mock).site_info = PropertyMock(return_value=site_info)
+ type(powerwall_mock).charge = PropertyMock(return_value=charge)
+ type(powerwall_mock).sitemaster = PropertyMock(return_value=sitemaster)
+ type(powerwall_mock).meters = PropertyMock(return_value=meters)
+ type(powerwall_mock).grid_status = PropertyMock(return_value=grid_status)
+ type(powerwall_mock).status = PropertyMock(return_value=status)
+ type(powerwall_mock).device_type = PropertyMock(return_value=device_type)
+
+ return powerwall_mock
+
+
+def _mock_powerwall_side_effect(site_info=None):
+ powerwall_mock = MagicMock()
+ type(powerwall_mock).site_info = PropertyMock(side_effect=site_info)
+ return powerwall_mock
+
+
+async def _async_load_json_fixture(hass, path):
+ fixture = await hass.async_add_executor_job(
+ load_fixture, os.path.join("powerwall", path)
+ )
+ return json.loads(fixture)
+
+
+def _mock_get_config():
+ """Return a default powerwall config."""
+ return {DOMAIN: {CONF_IP_ADDRESS: "1.2.3.4"}}
diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py
new file mode 100644
index 00000000000..621304793ab
--- /dev/null
+++ b/tests/components/powerwall/test_binary_sensor.py
@@ -0,0 +1,54 @@
+"""The binary sensor tests for the powerwall platform."""
+
+from asynctest import patch
+
+from homeassistant.components.powerwall.const import DOMAIN
+from homeassistant.const import STATE_ON
+from homeassistant.setup import async_setup_component
+
+from .mocks import _mock_get_config, _mock_powerwall_with_fixtures
+
+
+async def test_sensors(hass):
+ """Test creation of the binary sensors."""
+
+ mock_powerwall = await _mock_powerwall_with_fixtures(hass)
+
+ with patch(
+ "homeassistant.components.powerwall.config_flow.PowerWall",
+ return_value=mock_powerwall,
+ ), patch(
+ "homeassistant.components.powerwall.PowerWall", return_value=mock_powerwall,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
+
+ state = hass.states.get("binary_sensor.grid_status")
+ assert state.state == STATE_ON
+ expected_attributes = {"friendly_name": "Grid Status", "device_class": "power"}
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("binary_sensor.powerwall_status")
+ assert state.state == STATE_ON
+ expected_attributes = {
+ "region": "IEEE1547a:2014",
+ "grid_code": "60Hz_240V_s_IEEE1547a_2014",
+ "nominal_system_power_kW": 25,
+ "friendly_name": "Powerwall Status",
+ "device_class": "power",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("binary_sensor.powerwall_connected_to_tesla")
+ assert state.state == STATE_ON
+ expected_attributes = {
+ "friendly_name": "Powerwall Connected to Tesla",
+ "device_class": "connectivity",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py
new file mode 100644
index 00000000000..f27d7e1f41b
--- /dev/null
+++ b/tests/components/powerwall/test_config_flow.py
@@ -0,0 +1,93 @@
+"""Test the Powerwall config flow."""
+
+from asynctest import patch
+from tesla_powerwall import PowerWallUnreachableError
+
+from homeassistant import config_entries, setup
+from homeassistant.components.powerwall.const import DOMAIN, POWERWALL_SITE_NAME
+from homeassistant.const import CONF_IP_ADDRESS
+
+from .mocks import _mock_powerwall_return_value, _mock_powerwall_side_effect
+
+
+async def test_form_source_user(hass):
+ """Test we get config flow setup form as a user."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ mock_powerwall = _mock_powerwall_return_value(
+ site_info={POWERWALL_SITE_NAME: "My site"}
+ )
+
+ with patch(
+ "homeassistant.components.powerwall.config_flow.PowerWall",
+ return_value=mock_powerwall,
+ ), patch(
+ "homeassistant.components.powerwall.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.powerwall.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "My site"
+ assert result2["data"] == {CONF_IP_ADDRESS: "1.2.3.4"}
+ 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_source_import(hass):
+ """Test we setup the config entry via import."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ mock_powerwall = _mock_powerwall_return_value(
+ site_info={POWERWALL_SITE_NAME: "Imported site"}
+ )
+
+ with patch(
+ "homeassistant.components.powerwall.config_flow.PowerWall",
+ return_value=mock_powerwall,
+ ), patch(
+ "homeassistant.components.powerwall.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.powerwall.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={CONF_IP_ADDRESS: "1.2.3.4"},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "Imported site"
+ assert result["data"] == {CONF_IP_ADDRESS: "1.2.3.4"}
+ 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_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ mock_powerwall = _mock_powerwall_side_effect(site_info=PowerWallUnreachableError)
+
+ with patch(
+ "homeassistant.components.powerwall.config_flow.PowerWall",
+ return_value=mock_powerwall,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py
new file mode 100644
index 00000000000..7f092683b7c
--- /dev/null
+++ b/tests/components/powerwall/test_sensor.py
@@ -0,0 +1,104 @@
+"""The sensor tests for the powerwall platform."""
+
+from asynctest import patch
+
+from homeassistant.components.powerwall.const import DOMAIN
+from homeassistant.setup import async_setup_component
+
+from .mocks import _mock_get_config, _mock_powerwall_with_fixtures
+
+
+async def test_sensors(hass):
+ """Test creation of the sensors."""
+
+ mock_powerwall = await _mock_powerwall_with_fixtures(hass)
+
+ with patch(
+ "homeassistant.components.powerwall.config_flow.PowerWall",
+ return_value=mock_powerwall,
+ ), patch(
+ "homeassistant.components.powerwall.PowerWall", return_value=mock_powerwall,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ reg_device = device_registry.async_get_device(
+ identifiers={("powerwall", "Wom Energy_60Hz_240V_s_IEEE1547a_2014_13.5")},
+ connections=set(),
+ )
+ assert reg_device.model == "PowerWall 2 (hec)"
+ assert reg_device.sw_version == "1.45.1"
+ assert reg_device.manufacturer == "Tesla"
+ assert reg_device.name == "MySite"
+
+ state = hass.states.get("sensor.powerwall_site_now")
+ assert state.state == "0.032"
+ expected_attributes = {
+ "frequency": 60,
+ "energy_exported": 10429451.9916853,
+ "energy_imported": 4824191.60668611,
+ "instant_average_voltage": 120.650001525879,
+ "unit_of_measurement": "kWh",
+ "friendly_name": "Powerwall Site Now",
+ "device_class": "power",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("sensor.powerwall_load_now")
+ assert state.state == "1.971"
+ expected_attributes = {
+ "frequency": 60,
+ "energy_exported": 1056797.48917483,
+ "energy_imported": 4692987.91889705,
+ "instant_average_voltage": 120.650001525879,
+ "unit_of_measurement": "kWh",
+ "friendly_name": "Powerwall Load Now",
+ "device_class": "power",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("sensor.powerwall_battery_now")
+ assert state.state == "-8.55"
+ expected_attributes = {
+ "frequency": 60.014,
+ "energy_exported": 3620010,
+ "energy_imported": 4216170,
+ "instant_average_voltage": 240.56,
+ "unit_of_measurement": "kWh",
+ "friendly_name": "Powerwall Battery Now",
+ "device_class": "power",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("sensor.powerwall_solar_now")
+ assert state.state == "10.49"
+ expected_attributes = {
+ "frequency": 60,
+ "energy_exported": 9864205.82222448,
+ "energy_imported": 28177.5358355867,
+ "instant_average_voltage": 120.685001373291,
+ "unit_of_measurement": "kWh",
+ "friendly_name": "Powerwall Solar Now",
+ "device_class": "power",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("sensor.powerwall_charge")
+ assert state.state == "47.32"
+ expected_attributes = {
+ "unit_of_measurement": "%",
+ "friendly_name": "Powerwall Charge",
+ "device_class": "battery",
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
diff --git a/tests/components/pvpc_hourly_pricing/__init__.py b/tests/components/pvpc_hourly_pricing/__init__.py
new file mode 100644
index 00000000000..f36b721bc11
--- /dev/null
+++ b/tests/components/pvpc_hourly_pricing/__init__.py
@@ -0,0 +1 @@
+"""Tests for the pvpc_hourly_pricing integration."""
diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py
new file mode 100644
index 00000000000..a2cfefb1200
--- /dev/null
+++ b/tests/components/pvpc_hourly_pricing/conftest.py
@@ -0,0 +1,56 @@
+"""Tests for the pvpc_hourly_pricing integration."""
+import pytest
+
+from homeassistant.components.pvpc_hourly_pricing import ATTR_TARIFF, DOMAIN
+from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
+
+from tests.common import load_fixture
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+FIXTURE_JSON_DATA_2019_10_26 = "PVPC_CURV_DD_2019_10_26.json"
+FIXTURE_JSON_DATA_2019_10_27 = "PVPC_CURV_DD_2019_10_27.json"
+FIXTURE_JSON_DATA_2019_10_29 = "PVPC_CURV_DD_2019_10_29.json"
+
+
+def check_valid_state(state, tariff: str, value=None, key_attr=None):
+ """Ensure that sensor has a valid state and attributes."""
+ assert state
+ assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "€/kWh"
+ try:
+ _ = float(state.state)
+ # safety margins for current electricity price (it shouldn't be out of [0, 0.2])
+ assert -0.1 < float(state.state) < 0.3
+ assert state.attributes[ATTR_TARIFF] == tariff
+ except ValueError:
+ pass
+
+ if value is not None and isinstance(value, str):
+ assert state.state == value
+ elif value is not None:
+ assert abs(float(state.state) - value) < 1e-6
+ if key_attr is not None:
+ assert abs(float(state.state) - state.attributes[key_attr]) < 1e-6
+
+
+@pytest.fixture
+def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker):
+ """Create a mock config entry."""
+ aioclient_mock.get(
+ "https://api.esios.ree.es/archives/70/download_json?locale=es&date=2019-10-26",
+ text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_DATA_2019_10_26}"),
+ )
+ aioclient_mock.get(
+ "https://api.esios.ree.es/archives/70/download_json?locale=es&date=2019-10-27",
+ text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_DATA_2019_10_27}"),
+ )
+ # missing day
+ aioclient_mock.get(
+ "https://api.esios.ree.es/archives/70/download_json?locale=es&date=2019-10-28",
+ text='{"message":"No values for specified archive"}',
+ )
+ aioclient_mock.get(
+ "https://api.esios.ree.es/archives/70/download_json?locale=es&date=2019-10-29",
+ text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_DATA_2019_10_29}"),
+ )
+
+ return aioclient_mock
diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py
new file mode 100644
index 00000000000..fbbe87fee5f
--- /dev/null
+++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py
@@ -0,0 +1,79 @@
+"""Tests for the pvpc_hourly_pricing config_flow."""
+from datetime import datetime
+from unittest.mock import patch
+
+from pytz import timezone
+
+from homeassistant import data_entry_flow
+from homeassistant.components.pvpc_hourly_pricing import ATTR_TARIFF, DOMAIN
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers import entity_registry
+
+from .conftest import check_valid_state
+
+from tests.common import date_util
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+async def test_config_flow(hass, pvpc_aioclient_mock: AiohttpClientMocker):
+ """
+ Test config flow for pvpc_hourly_pricing.
+
+ - Create a new entry with tariff "normal"
+ - Check state and attributes
+ - Check abort when trying to config another with same tariff
+ - Check removal and add again to check state restoration
+ """
+ hass.config.time_zone = timezone("Europe/Madrid")
+ mock_data = {"return_time": datetime(2019, 10, 26, 14, 0, tzinfo=date_util.UTC)}
+
+ def mock_now():
+ return mock_data["return_time"]
+
+ with patch("homeassistant.util.dt.utcnow", new=mock_now):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_NAME: "test", ATTR_TARIFF: "normal"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+ state = hass.states.get("sensor.test")
+ check_valid_state(state, tariff="normal")
+ assert pvpc_aioclient_mock.call_count == 1
+
+ # Check abort when configuring another with same tariff
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_NAME: "test", ATTR_TARIFF: "normal"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert pvpc_aioclient_mock.call_count == 1
+
+ # Check removal
+ registry = await entity_registry.async_get_registry(hass)
+ registry_entity = registry.async_get("sensor.test")
+ assert await hass.config_entries.async_remove(registry_entity.config_entry_id)
+
+ # and add it again with UI
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_NAME: "test", ATTR_TARIFF: "normal"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+ state = hass.states.get("sensor.test")
+ check_valid_state(state, tariff="normal")
+ assert pvpc_aioclient_mock.call_count == 2
diff --git a/tests/components/pvpc_hourly_pricing/test_sensor.py b/tests/components/pvpc_hourly_pricing/test_sensor.py
new file mode 100644
index 00000000000..fdab7fd1008
--- /dev/null
+++ b/tests/components/pvpc_hourly_pricing/test_sensor.py
@@ -0,0 +1,87 @@
+"""Tests for the pvpc_hourly_pricing sensor component."""
+from datetime import datetime, timedelta
+import logging
+from unittest.mock import patch
+
+from pytz import timezone
+
+from homeassistant.components.pvpc_hourly_pricing import ATTR_TARIFF, DOMAIN
+from homeassistant.const import CONF_NAME
+from homeassistant.core import ATTR_NOW, EVENT_TIME_CHANGED
+
+from .conftest import check_valid_state
+
+from tests.common import async_setup_component, date_util
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+async def _process_time_step(
+ hass, mock_data, key_state=None, value=None, tariff="discrimination", delta_min=60
+):
+ state = hass.states.get("sensor.test_dst")
+ check_valid_state(state, tariff=tariff, value=value, key_attr=key_state)
+
+ mock_data["return_time"] += timedelta(minutes=delta_min)
+ hass.bus.async_fire(EVENT_TIME_CHANGED, {ATTR_NOW: mock_data["return_time"]})
+ await hass.async_block_till_done()
+ return state
+
+
+async def test_sensor_availability(
+ hass, caplog, pvpc_aioclient_mock: AiohttpClientMocker
+):
+ """Test sensor availability and handling of cloud access."""
+ hass.config.time_zone = timezone("Europe/Madrid")
+ config = {DOMAIN: [{CONF_NAME: "test_dst", ATTR_TARIFF: "discrimination"}]}
+ mock_data = {"return_time": datetime(2019, 10, 27, 20, 0, 0, tzinfo=date_util.UTC)}
+
+ def mock_now():
+ return mock_data["return_time"]
+
+ with patch("homeassistant.util.dt.utcnow", new=mock_now):
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.async_block_till_done()
+ caplog.clear()
+ assert pvpc_aioclient_mock.call_count == 2
+
+ await _process_time_step(hass, mock_data, "price_21h", 0.13896)
+ await _process_time_step(hass, mock_data, "price_22h", 0.06893)
+ assert pvpc_aioclient_mock.call_count == 4
+ await _process_time_step(hass, mock_data, "price_23h", 0.06935)
+ assert pvpc_aioclient_mock.call_count == 5
+
+ # sensor has no more prices, state is "unavailable" from now on
+ await _process_time_step(hass, mock_data, value="unavailable")
+ await _process_time_step(hass, mock_data, value="unavailable")
+ num_errors = sum(
+ 1 for x in caplog.get_records("call") if x.levelno == logging.ERROR
+ )
+ num_warnings = sum(
+ 1 for x in caplog.get_records("call") if x.levelno == logging.WARNING
+ )
+ assert num_warnings == 1
+ assert num_errors == 0
+ assert pvpc_aioclient_mock.call_count == 9
+
+ # check that it is silent until it becomes available again
+ caplog.clear()
+ with caplog.at_level(logging.WARNING):
+ # silent mode
+ for _ in range(21):
+ await _process_time_step(hass, mock_data, value="unavailable")
+ assert pvpc_aioclient_mock.call_count == 30
+ assert len(caplog.messages) == 0
+
+ # warning about data access recovered
+ await _process_time_step(hass, mock_data, value="unavailable")
+ assert pvpc_aioclient_mock.call_count == 31
+ assert len(caplog.messages) == 1
+ assert caplog.records[0].levelno == logging.WARNING
+
+ # working ok again
+ await _process_time_step(hass, mock_data, "price_00h", value=0.06821)
+ assert pvpc_aioclient_mock.call_count == 32
+ await _process_time_step(hass, mock_data, "price_01h", value=0.06627)
+ assert pvpc_aioclient_mock.call_count == 33
+ assert len(caplog.messages) == 1
+ assert caplog.records[0].levelno == logging.WARNING
diff --git a/tests/components/rachio/__init__.py b/tests/components/rachio/__init__.py
new file mode 100644
index 00000000000..64fdec71144
--- /dev/null
+++ b/tests/components/rachio/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Rachio integration."""
diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py
new file mode 100644
index 00000000000..57575fe5501
--- /dev/null
+++ b/tests/components/rachio/test_config_flow.py
@@ -0,0 +1,126 @@
+"""Test the Rachio config flow."""
+from asynctest import patch
+from asynctest.mock import MagicMock
+
+from homeassistant import config_entries, setup
+from homeassistant.components.rachio.const import (
+ CONF_CUSTOM_URL,
+ CONF_MANUAL_RUN_MINS,
+ DOMAIN,
+)
+from homeassistant.const import CONF_API_KEY
+
+from tests.common import MockConfigEntry
+
+
+def _mock_rachio_return_value(get=None, getInfo=None):
+ rachio_mock = MagicMock()
+ person_mock = MagicMock()
+ type(person_mock).get = MagicMock(return_value=get)
+ type(person_mock).getInfo = MagicMock(return_value=getInfo)
+ type(rachio_mock).person = person_mock
+ return rachio_mock
+
+
+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"] == {}
+
+ rachio_mock = _mock_rachio_return_value(
+ get=({"status": 200}, {"username": "myusername"}),
+ getInfo=({"status": 200}, {"id": "myid"}),
+ )
+
+ with patch(
+ "homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
+ ), patch(
+ "homeassistant.components.rachio.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.rachio.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_API_KEY: "api_key",
+ CONF_CUSTOM_URL: "http://custom.url",
+ CONF_MANUAL_RUN_MINS: 5,
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "myusername"
+ assert result2["data"] == {
+ CONF_API_KEY: "api_key",
+ CONF_CUSTOM_URL: "http://custom.url",
+ CONF_MANUAL_RUN_MINS: 5,
+ }
+ 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}
+ )
+
+ rachio_mock = _mock_rachio_return_value(
+ get=({"status": 200}, {"username": "myusername"}),
+ getInfo=({"status": 412}, {"error": "auth fail"}),
+ )
+ with patch(
+ "homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_API_KEY: "api_key"},
+ )
+
+ 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}
+ )
+
+ rachio_mock = _mock_rachio_return_value(
+ get=({"status": 599}, {"username": "myusername"}),
+ getInfo=({"status": 200}, {"id": "myid"}),
+ )
+ with patch(
+ "homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_API_KEY: "api_key"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_homekit(hass):
+ """Test that we abort from homekit if rachio is already setup."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "homekit"}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "api_key"})
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "homekit"}
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py
new file mode 100644
index 00000000000..638a37b193a
--- /dev/null
+++ b/tests/components/roku/__init__.py
@@ -0,0 +1,50 @@
+"""Tests for the Roku component."""
+from homeassistant.components.roku.const import DOMAIN
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.common import MockConfigEntry
+
+HOST = "1.2.3.4"
+NAME = "Roku 3"
+SSDP_LOCATION = "http://1.2.3.4/"
+UPNP_FRIENDLY_NAME = "My Roku 3"
+UPNP_SERIAL = "1GU48T017973"
+
+
+class MockDeviceInfo(object):
+ """Mock DeviceInfo for Roku."""
+
+ model_name = NAME
+ model_num = "4200X"
+ software_version = "7.5.0.09021"
+ serial_num = UPNP_SERIAL
+ user_device_name = UPNP_FRIENDLY_NAME
+ roku_type = "Box"
+
+ def __repr__(self):
+ """Return the object representation of DeviceInfo."""
+ return "" % (
+ self.model_name,
+ self.model_num,
+ self.software_version,
+ self.serial_num,
+ self.roku_type,
+ )
+
+
+async def setup_integration(
+ hass: HomeAssistantType, skip_entry_setup: bool = False
+) -> MockConfigEntry:
+ """Set up the Roku integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN, unique_id=UPNP_SERIAL, data={CONF_HOST: HOST}
+ )
+
+ entry.add_to_hass(hass)
+
+ if not skip_entry_setup:
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py
new file mode 100644
index 00000000000..9aa60d8594c
--- /dev/null
+++ b/tests/components/roku/test_config_flow.py
@@ -0,0 +1,254 @@
+"""Test the Roku config flow."""
+from socket import gaierror as SocketGIAError
+from typing import Any, Dict, Optional
+
+from asynctest import patch
+from requests.exceptions import RequestException
+from roku import RokuException
+
+from homeassistant.components.roku.const import DOMAIN
+from homeassistant.components.ssdp import (
+ ATTR_SSDP_LOCATION,
+ ATTR_UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL,
+)
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
+from homeassistant.data_entry_flow import (
+ RESULT_TYPE_ABORT,
+ RESULT_TYPE_CREATE_ENTRY,
+ RESULT_TYPE_FORM,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.setup import async_setup_component
+
+from tests.components.roku import (
+ HOST,
+ SSDP_LOCATION,
+ UPNP_FRIENDLY_NAME,
+ UPNP_SERIAL,
+ MockDeviceInfo,
+ setup_integration,
+)
+
+
+async def async_configure_flow(
+ hass: HomeAssistantType, flow_id: str, user_input: Optional[Dict] = None
+) -> Any:
+ """Set up mock Roku integration flow."""
+ with patch(
+ "homeassistant.components.roku.config_flow.Roku.device_info",
+ new=MockDeviceInfo,
+ ):
+ return await hass.config_entries.flow.async_configure(
+ flow_id=flow_id, user_input=user_input
+ )
+
+
+async def async_init_flow(
+ hass: HomeAssistantType,
+ handler: str = DOMAIN,
+ context: Optional[Dict] = None,
+ data: Any = None,
+) -> Any:
+ """Set up mock Roku integration flow."""
+ with patch(
+ "homeassistant.components.roku.config_flow.Roku.device_info",
+ new=MockDeviceInfo,
+ ):
+ return await hass.config_entries.flow.async_init(
+ handler=handler, context=context, data=data
+ )
+
+
+async def test_duplicate_error(hass: HomeAssistantType) -> None:
+ """Test that errors are shown when duplicates are added."""
+ await setup_integration(hass, skip_entry_setup=True)
+
+ result = await async_init_flow(
+ hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+ result = await async_init_flow(
+ hass, context={CONF_SOURCE: SOURCE_USER}, data={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+ result = await async_init_flow(
+ hass,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data={
+ ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
+ ATTR_SSDP_LOCATION: SSDP_LOCATION,
+ ATTR_UPNP_SERIAL: UPNP_SERIAL,
+ },
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_form(hass: HomeAssistantType) -> None:
+ """Test the user step."""
+ await async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}
+ )
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.roku.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.roku.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST})
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
+ assert result["data"] == {CONF_HOST: HOST}
+ 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_cannot_connect(hass: HomeAssistantType) -> None:
+ """Test we handle cannot connect roku error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.roku.config_flow.Roku._call",
+ side_effect=RokuException,
+ ) as mock_validate_input:
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ await hass.async_block_till_done()
+ assert len(mock_validate_input.mock_calls) == 1
+
+
+async def test_form_cannot_connect_request(hass: HomeAssistantType) -> None:
+ """Test we handle cannot connect request error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.roku.config_flow.Roku._call",
+ side_effect=RequestException,
+ ) as mock_validate_input:
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ await hass.async_block_till_done()
+ assert len(mock_validate_input.mock_calls) == 1
+
+
+async def test_form_cannot_connect_socket(hass: HomeAssistantType) -> None:
+ """Test we handle cannot connect socket error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.roku.config_flow.Roku._call",
+ side_effect=SocketGIAError,
+ ) as mock_validate_input:
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ await hass.async_block_till_done()
+ assert len(mock_validate_input.mock_calls) == 1
+
+
+async def test_form_unknown_error(hass: HomeAssistantType) -> None:
+ """Test we handle unknown error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.roku.config_flow.Roku._call", side_effect=Exception,
+ ) as mock_validate_input:
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "unknown"
+
+ await hass.async_block_till_done()
+ assert len(mock_validate_input.mock_calls) == 1
+
+
+async def test_import(hass: HomeAssistantType) -> None:
+ """Test the import step."""
+ with patch(
+ "homeassistant.components.roku.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.roku.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await async_init_flow(
+ hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
+ assert result["data"] == {CONF_HOST: HOST}
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_ssdp_discovery(hass: HomeAssistantType) -> None:
+ """Test the ssdp discovery step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data={
+ ATTR_SSDP_LOCATION: SSDP_LOCATION,
+ ATTR_UPNP_FRIENDLY_NAME: UPNP_FRIENDLY_NAME,
+ ATTR_UPNP_SERIAL: UPNP_SERIAL,
+ },
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "ssdp_confirm"
+ assert result["description_placeholders"] == {CONF_NAME: UPNP_FRIENDLY_NAME}
+
+ with patch(
+ "homeassistant.components.roku.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.roku.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result = await async_configure_flow(hass, result["flow_id"], {})
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == UPNP_FRIENDLY_NAME
+ assert result["data"] == {
+ CONF_HOST: HOST,
+ CONF_NAME: UPNP_FRIENDLY_NAME,
+ }
+ 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/roku/test_init.py b/tests/components/roku/test_init.py
new file mode 100644
index 00000000000..c9eff43c858
--- /dev/null
+++ b/tests/components/roku/test_init.py
@@ -0,0 +1,68 @@
+"""Tests for the Roku integration."""
+from socket import gaierror as SocketGIAError
+
+from asynctest import patch
+from requests.exceptions import RequestException
+from roku import RokuException
+
+from homeassistant.components.roku.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.components.roku import MockDeviceInfo, setup_integration
+
+
+async def test_config_entry_not_ready(hass: HomeAssistantType) -> None:
+ """Test the Roku configuration entry not ready."""
+ with patch(
+ "homeassistant.components.roku.Roku._call", side_effect=RokuException,
+ ):
+ entry = await setup_integration(hass)
+
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_config_entry_not_ready_request(hass: HomeAssistantType) -> None:
+ """Test the Roku configuration entry not ready."""
+ with patch(
+ "homeassistant.components.roku.Roku._call", side_effect=RequestException,
+ ):
+ entry = await setup_integration(hass)
+
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_config_entry_not_ready_socket(hass: HomeAssistantType) -> None:
+ """Test the Roku configuration entry not ready."""
+ with patch(
+ "homeassistant.components.roku.Roku._call", side_effect=SocketGIAError,
+ ):
+ entry = await setup_integration(hass)
+
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_config_entry(hass: HomeAssistantType) -> None:
+ """Test the Roku configuration entry unloading."""
+ with patch(
+ "homeassistant.components.roku.Roku.device_info", return_value=MockDeviceInfo,
+ ), patch(
+ "homeassistant.components.roku.media_player.async_setup_entry",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.roku.remote.async_setup_entry", return_value=True,
+ ):
+ entry = await setup_integration(hass)
+
+ assert hass.data[DOMAIN][entry.entry_id]
+ assert entry.state == ENTRY_STATE_LOADED
+
+ await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.entry_id not in hass.data[DOMAIN]
+ assert entry.state == ENTRY_STATE_NOT_LOADED
diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py
index 496c6d88954..f53636fc440 100644
--- a/tests/components/simplisafe/test_config_flow.py
+++ b/tests/components/simplisafe/test_config_flow.py
@@ -1,15 +1,16 @@
"""Define tests for the SimpliSafe config flow."""
import json
-from unittest.mock import MagicMock, PropertyMock, mock_open, patch
+from unittest.mock import MagicMock, PropertyMock, mock_open
+from asynctest import 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 homeassistant.components.simplisafe import DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
-from tests.common import MockConfigEntry, mock_coro
+from tests.common import MockConfigEntry
def mock_api():
@@ -39,55 +40,83 @@ async def test_invalid_credentials(hass):
"""Test that invalid credentials throws an error."""
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", side_effect=SimplipyError,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+ assert result["errors"] == {"base": "invalid_credentials"}
+
+
+async def test_options_flow(hass):
+ """Test config flow options."""
+ conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, unique_id="abcde12345", data=conf, options={CONF_CODE: "1234"},
+ )
+ config_entry.add_to_hass(hass)
with patch(
- "simplipy.API.login_via_credentials",
- return_value=mock_coro(exception=SimplipyError),
+ "homeassistant.components.simplisafe.async_setup_entry", return_value=True
):
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {"base": "invalid_credentials"}
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_CODE: "4321"}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options == {CONF_CODE: "4321"}
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}
+ with patch(
+ "homeassistant.components.simplisafe.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}
+ )
- result = await flow.async_step_user(user_input=None)
-
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["step_id"] == "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_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
-
- flow = config_flow.SimpliSafeFlowHandler()
- flow.hass = hass
- flow.context = {"source": SOURCE_USER}
+ conf = {
+ CONF_USERNAME: "user@email.com",
+ CONF_PASSWORD: "password",
+ CONF_CODE: "1234",
+ }
mop = mock_open(read_data=json.dumps({"refresh_token": "12345"}))
with patch(
- "simplipy.API.login_via_credentials",
- return_value=mock_coro(return_value=mock_api()),
+ "homeassistant.components.simplisafe.async_setup_entry", return_value=True
+ ), patch("simplipy.API.login_via_credentials", return_value=mock_api()), patch(
+ "homeassistant.util.json.open", mop, create=True
+ ), patch(
+ "homeassistant.util.json.os.open", return_value=0
+ ), patch(
+ "homeassistant.util.json.os.replace"
):
- with patch("homeassistant.util.json.open", mop, create=True):
- with patch("homeassistant.util.json.os.open", return_value=0):
- with patch("homeassistant.util.json.os.replace"):
- result = await flow.async_step_import(import_config=conf)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == "user@email.com"
- assert result["data"] == {
- CONF_USERNAME: "user@email.com",
- CONF_TOKEN: "12345abc",
- }
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "user@email.com"
+ assert result["data"] == {
+ CONF_USERNAME: "user@email.com",
+ CONF_TOKEN: "12345abc",
+ CONF_CODE: "1234",
+ }
async def test_step_user(hass):
@@ -95,26 +124,28 @@ async def test_step_user(hass):
conf = {
CONF_USERNAME: "user@email.com",
CONF_PASSWORD: "password",
+ CONF_CODE: "1234",
}
- flow = config_flow.SimpliSafeFlowHandler()
- flow.hass = hass
- flow.context = {"source": SOURCE_USER}
-
mop = mock_open(read_data=json.dumps({"refresh_token": "12345"}))
with patch(
- "simplipy.API.login_via_credentials",
- return_value=mock_coro(return_value=mock_api()),
+ "homeassistant.components.simplisafe.async_setup_entry", return_value=True
+ ), patch("simplipy.API.login_via_credentials", return_value=mock_api()), patch(
+ "homeassistant.util.json.open", mop, create=True
+ ), patch(
+ "homeassistant.util.json.os.open", return_value=0
+ ), patch(
+ "homeassistant.util.json.os.replace"
):
- with patch("homeassistant.util.json.open", mop, create=True):
- with patch("homeassistant.util.json.os.open", return_value=0):
- with patch("homeassistant.util.json.os.replace"):
- result = await flow.async_step_user(user_input=conf)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == "user@email.com"
- assert result["data"] == {
- CONF_USERNAME: "user@email.com",
- CONF_TOKEN: "12345abc",
- }
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "user@email.com"
+ assert result["data"] == {
+ CONF_USERNAME: "user@email.com",
+ CONF_TOKEN: "12345abc",
+ CONF_CODE: "1234",
+ }
diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py
index e69cec12ba3..ea580656b24 100644
--- a/tests/components/soundtouch/test_media_player.py
+++ b/tests/components/soundtouch/test_media_player.py
@@ -33,6 +33,8 @@ from homeassistant.setup import async_setup_component
DEVICE_1_IP = "192.168.0.1"
DEVICE_2_IP = "192.168.0.2"
+DEVICE_1_ID = 1
+DEVICE_2_ID = 2
def get_config(host=DEVICE_1_IP, port=8090, name="soundtouch"):
@@ -60,20 +62,22 @@ def one_device_fixture():
def two_zones_fixture():
"""Mock one master and one slave."""
device_1 = MockDevice(
+ DEVICE_1_ID,
MockZoneStatus(
is_master=True,
- master_id=1,
+ master_id=DEVICE_1_ID,
master_ip=DEVICE_1_IP,
slaves=[MockZoneSlave(DEVICE_2_IP)],
- )
+ ),
)
device_2 = MockDevice(
+ DEVICE_2_ID,
MockZoneStatus(
is_master=False,
- master_id=1,
+ master_id=DEVICE_1_ID,
master_ip=DEVICE_1_IP,
slaves=[MockZoneSlave(DEVICE_2_IP)],
- )
+ ),
)
devices = {DEVICE_1_IP: device_1, DEVICE_2_IP: device_2}
device_patch = patch(
@@ -112,9 +116,9 @@ async def setup_soundtouch(hass, config):
class MockDevice(STD):
"""Mock device."""
- def __init__(self, zone_status=None):
+ def __init__(self, id=None, zone_status=None):
"""Init the class."""
- self._config = MockConfig()
+ self._config = MockConfig(id)
self._zone_status = zone_status or MockZoneStatus()
def zone_status(self, refresh=True):
@@ -125,9 +129,10 @@ class MockDevice(STD):
class MockConfig(Config):
"""Mock config."""
- def __init__(self):
+ def __init__(self, id=None):
"""Init class."""
self._name = "name"
+ self._id = id or DEVICE_1_ID
class MockZoneStatus(ZoneStatus):
diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py
new file mode 100644
index 00000000000..11d199f01a1
--- /dev/null
+++ b/tests/components/tado/__init__.py
@@ -0,0 +1 @@
+"""Tests for the tado integration."""
diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py
new file mode 100644
index 00000000000..dfb2973f4cb
--- /dev/null
+++ b/tests/components/tado/test_climate.py
@@ -0,0 +1,88 @@
+"""The sensor tests for the tado platform."""
+
+from .util import async_init_integration
+
+
+async def test_air_con(hass):
+ """Test creation of aircon climate."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("climate.air_conditioning")
+ assert state.state == "cool"
+
+ expected_attributes = {
+ "current_humidity": 60.9,
+ "current_temperature": 24.8,
+ "fan_mode": "auto",
+ "fan_modes": ["auto", "high", "medium", "low"],
+ "friendly_name": "Air Conditioning",
+ "hvac_action": "cooling",
+ "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"],
+ "max_temp": 31.0,
+ "min_temp": 16.0,
+ "preset_mode": "home",
+ "preset_modes": ["away", "home"],
+ "supported_features": 25,
+ "target_temp_step": 1,
+ "temperature": 17.8,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+
+async def test_heater(hass):
+ """Test creation of heater climate."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("climate.baseboard_heater")
+ assert state.state == "heat"
+
+ expected_attributes = {
+ "current_humidity": 45.2,
+ "current_temperature": 20.6,
+ "friendly_name": "Baseboard Heater",
+ "hvac_action": "idle",
+ "hvac_modes": ["off", "auto", "heat"],
+ "max_temp": 31.0,
+ "min_temp": 16.0,
+ "preset_mode": "home",
+ "preset_modes": ["away", "home"],
+ "supported_features": 17,
+ "target_temp_step": 1,
+ "temperature": 20.5,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+
+async def test_smartac_with_swing(hass):
+ """Test creation of smart ac with swing climate."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("climate.air_conditioning_with_swing")
+ assert state.state == "auto"
+
+ expected_attributes = {
+ "current_humidity": 42.3,
+ "current_temperature": 20.9,
+ "fan_mode": "auto",
+ "fan_modes": ["auto", "high", "medium", "low"],
+ "friendly_name": "Air Conditioning with swing",
+ "hvac_action": "heating",
+ "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"],
+ "max_temp": 30.0,
+ "min_temp": 16.0,
+ "preset_mode": "home",
+ "preset_modes": ["away", "home"],
+ "supported_features": 57,
+ "target_temp_step": 1.0,
+ "temperature": 20.0,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
diff --git a/tests/components/tado/test_sensor.py b/tests/components/tado/test_sensor.py
new file mode 100644
index 00000000000..2ea2c0508ee
--- /dev/null
+++ b/tests/components/tado/test_sensor.py
@@ -0,0 +1,96 @@
+"""The sensor tests for the tado platform."""
+
+from .util import async_init_integration
+
+
+async def test_air_con_create_sensors(hass):
+ """Test creation of aircon sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("sensor.air_conditioning_power")
+ assert state.state == "ON"
+
+ state = hass.states.get("sensor.air_conditioning_link")
+ assert state.state == "ONLINE"
+
+ state = hass.states.get("sensor.air_conditioning_link")
+ assert state.state == "ONLINE"
+
+ state = hass.states.get("sensor.air_conditioning_tado_mode")
+ assert state.state == "HOME"
+
+ state = hass.states.get("sensor.air_conditioning_temperature")
+ assert state.state == "24.76"
+
+ state = hass.states.get("sensor.air_conditioning_ac")
+ assert state.state == "ON"
+
+ state = hass.states.get("sensor.air_conditioning_overlay")
+ assert state.state == "True"
+
+ state = hass.states.get("sensor.air_conditioning_humidity")
+ assert state.state == "60.9"
+
+ state = hass.states.get("sensor.air_conditioning_open_window")
+ assert state.state == "False"
+
+
+async def test_heater_create_sensors(hass):
+ """Test creation of heater sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("sensor.baseboard_heater_power")
+ assert state.state == "ON"
+
+ state = hass.states.get("sensor.baseboard_heater_link")
+ assert state.state == "ONLINE"
+
+ state = hass.states.get("sensor.baseboard_heater_link")
+ assert state.state == "ONLINE"
+
+ state = hass.states.get("sensor.baseboard_heater_tado_mode")
+ assert state.state == "HOME"
+
+ state = hass.states.get("sensor.baseboard_heater_temperature")
+ assert state.state == "20.65"
+
+ state = hass.states.get("sensor.baseboard_heater_early_start")
+ assert state.state == "False"
+
+ state = hass.states.get("sensor.baseboard_heater_overlay")
+ assert state.state == "True"
+
+ state = hass.states.get("sensor.baseboard_heater_humidity")
+ assert state.state == "45.2"
+
+ state = hass.states.get("sensor.baseboard_heater_open_window")
+ assert state.state == "False"
+
+
+async def test_water_heater_create_sensors(hass):
+ """Test creation of water heater sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("sensor.water_heater_tado_mode")
+ assert state.state == "HOME"
+
+ state = hass.states.get("sensor.water_heater_link")
+ assert state.state == "ONLINE"
+
+ state = hass.states.get("sensor.water_heater_overlay")
+ assert state.state == "False"
+
+ state = hass.states.get("sensor.water_heater_power")
+ assert state.state == "ON"
+
+
+async def test_home_create_sensors(hass):
+ """Test creation of home sensors."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("sensor.home_name_tado_bridge_status")
+ assert state.state == "True"
diff --git a/tests/components/tado/test_water_heater.py b/tests/components/tado/test_water_heater.py
new file mode 100644
index 00000000000..03dfaaef0df
--- /dev/null
+++ b/tests/components/tado/test_water_heater.py
@@ -0,0 +1,47 @@
+"""The sensor tests for the tado platform."""
+
+from .util import async_init_integration
+
+
+async def test_water_heater_create_sensors(hass):
+ """Test creation of water heater."""
+
+ await async_init_integration(hass)
+
+ state = hass.states.get("water_heater.water_heater")
+ assert state.state == "auto"
+
+ expected_attributes = {
+ "current_temperature": None,
+ "friendly_name": "Water Heater",
+ "max_temp": 31.0,
+ "min_temp": 16.0,
+ "operation_list": ["auto", "heat", "off"],
+ "operation_mode": "auto",
+ "supported_features": 3,
+ "target_temp_high": None,
+ "target_temp_low": None,
+ "temperature": 65.0,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
+
+ state = hass.states.get("water_heater.second_water_heater")
+ assert state.state == "heat"
+
+ expected_attributes = {
+ "current_temperature": None,
+ "friendly_name": "Second Water Heater",
+ "max_temp": 31.0,
+ "min_temp": 16.0,
+ "operation_list": ["auto", "heat", "off"],
+ "operation_mode": "heat",
+ "supported_features": 3,
+ "target_temp_high": None,
+ "target_temp_low": None,
+ "temperature": 30.0,
+ }
+ # Only test for a subset of attributes in case
+ # HA changes the implementation and a new one appears
+ assert all(item in state.attributes.items() for item in expected_attributes.items())
diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py
new file mode 100644
index 00000000000..1b7e1ad888e
--- /dev/null
+++ b/tests/components/tado/util.py
@@ -0,0 +1,100 @@
+"""Tests for the tado integration."""
+
+import requests_mock
+
+from homeassistant.components.tado import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from tests.common import load_fixture
+
+
+async def async_init_integration(
+ hass: HomeAssistant, skip_setup: bool = False,
+):
+ """Set up the tado integration in Home Assistant."""
+
+ token_fixture = "tado/token.json"
+ devices_fixture = "tado/devices.json"
+ me_fixture = "tado/me.json"
+ zones_fixture = "tado/zones.json"
+
+ # Smart AC with Swing
+ zone_5_state_fixture = "tado/smartac3.with_swing.json"
+ zone_5_capabilities_fixture = "tado/zone_with_swing_capabilities.json"
+
+ # Water Heater 2
+ zone_4_state_fixture = "tado/tadov2.water_heater.heating.json"
+ zone_4_capabilities_fixture = "tado/water_heater_zone_capabilities.json"
+
+ # Smart AC
+ zone_3_state_fixture = "tado/smartac3.cool_mode.json"
+ zone_3_capabilities_fixture = "tado/zone_capabilities.json"
+
+ # Water Heater
+ zone_2_state_fixture = "tado/tadov2.water_heater.auto_mode.json"
+ zone_2_capabilities_fixture = "tado/water_heater_zone_capabilities.json"
+
+ # Tado V2 with manual heating
+ zone_1_state_fixture = "tado/tadov2.heating.manual_mode.json"
+ zone_1_capabilities_fixture = "tado/tadov2.zone_capabilities.json"
+
+ with requests_mock.mock() as m:
+ m.post("https://auth.tado.com/oauth/token", text=load_fixture(token_fixture))
+ m.get(
+ "https://my.tado.com/api/v2/me", text=load_fixture(me_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/devices",
+ text=load_fixture(devices_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones",
+ text=load_fixture(zones_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/5/capabilities",
+ text=load_fixture(zone_5_capabilities_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/4/capabilities",
+ text=load_fixture(zone_4_capabilities_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/3/capabilities",
+ text=load_fixture(zone_3_capabilities_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/2/capabilities",
+ text=load_fixture(zone_2_capabilities_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/1/capabilities",
+ text=load_fixture(zone_1_capabilities_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/5/state",
+ text=load_fixture(zone_5_state_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/4/state",
+ text=load_fixture(zone_4_state_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/3/state",
+ text=load_fixture(zone_3_state_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/2/state",
+ text=load_fixture(zone_2_state_fixture),
+ )
+ m.get(
+ "https://my.tado.com/api/v2/homes/1/zones/1/state",
+ text=load_fixture(zone_1_state_fixture),
+ )
+ if not skip_setup:
+ assert await async_setup_component(
+ hass, DOMAIN, {DOMAIN: {CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}}
+ )
+ await hass.async_block_till_done()
diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py
index 36c639bc95b..6d2e4e48279 100644
--- a/tests/components/template/test_alarm_control_panel.py
+++ b/tests/components/template/test_alarm_control_panel.py
@@ -7,6 +7,8 @@ from homeassistant.const import (
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING,
+ STATE_ALARM_TRIGGERED,
)
from tests.common import async_mock_service
@@ -79,6 +81,24 @@ async def test_template_state_text(hass):
state = hass.states.get("alarm_control_panel.test_template_panel")
assert state.state == STATE_ALARM_DISARMED
+ hass.states.async_set("alarm_control_panel.test", STATE_ALARM_PENDING)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("alarm_control_panel.test_template_panel")
+ assert state.state == STATE_ALARM_PENDING
+
+ hass.states.async_set("alarm_control_panel.test", STATE_ALARM_TRIGGERED)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("alarm_control_panel.test_template_panel")
+ assert state.state == STATE_ALARM_TRIGGERED
+
+ hass.states.async_set("alarm_control_panel.test", "invalid_state")
+ await hass.async_block_till_done()
+
+ state = hass.states.get("alarm_control_panel.test_template_panel")
+ assert state.state == "unknown"
+
async def test_optimistic_states(hass):
"""Test the optimistic state."""
diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py
index 477583f23fb..00e7ba78cc1 100644
--- a/tests/components/tesla/test_config_flow.py
+++ b/tests/components/tesla/test_config_flow.py
@@ -4,7 +4,13 @@ from unittest.mock import patch
from teslajsonpy import TeslaException
from homeassistant import config_entries, data_entry_flow, setup
-from homeassistant.components.tesla.const import DOMAIN, MIN_SCAN_INTERVAL
+from homeassistant.components.tesla.const import (
+ CONF_WAKE_ON_START,
+ DEFAULT_SCAN_INTERVAL,
+ DEFAULT_WAKE_ON_START,
+ DOMAIN,
+ MIN_SCAN_INTERVAL,
+)
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_PASSWORD,
@@ -137,10 +143,34 @@ async def test_option_flow(hass):
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONF_SCAN_INTERVAL: 350}
+ result["flow_id"],
+ user_input={CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True},
)
assert result["type"] == "create_entry"
- assert result["data"] == {CONF_SCAN_INTERVAL: 350}
+ assert result["data"] == {
+ CONF_SCAN_INTERVAL: 350,
+ CONF_WAKE_ON_START: True,
+ }
+
+
+async def test_option_flow_defaults(hass):
+ """Test config flow options."""
+ entry = MockConfigEntry(domain=DOMAIN, data={}, options=None)
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={}
+ )
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
+ CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START,
+ }
async def test_option_flow_input_floor(hass):
@@ -157,4 +187,7 @@ 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: MIN_SCAN_INTERVAL}
+ assert result["data"] == {
+ CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL,
+ CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START,
+ }
diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py
index 8e5a2a775b9..e13870b8ee2 100644
--- a/tests/components/tplink/test_light.py
+++ b/tests/components/tplink/test_light.py
@@ -26,20 +26,19 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-LightMockData = NamedTuple(
- "LightMockData",
- (
- ("sys_info", dict),
- ("light_state", dict),
- ("set_light_state", Callable[[dict], None]),
- ("set_light_state_mock", Mock),
- ("get_light_state_mock", Mock),
- ("current_consumption_mock", Mock),
- ("get_sysinfo_mock", Mock),
- ("get_emeter_daily_mock", Mock),
- ("get_emeter_monthly_mock", Mock),
- ),
-)
+
+class LightMockData(NamedTuple):
+ """Mock light data."""
+
+ sys_info: dict
+ light_state: dict
+ set_light_state: Callable[[dict], None]
+ set_light_state_mock: Mock
+ get_light_state_mock: Mock
+ current_consumption_mock: Mock
+ get_sysinfo_mock: Mock
+ get_emeter_daily_mock: Mock
+ get_emeter_monthly_mock: Mock
@pytest.fixture(name="light_mock_data")
diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py
index 62c4bc3a065..ab5d562ffc8 100644
--- a/tests/components/tts/test_init.py
+++ b/tests/components/tts/test_init.py
@@ -1,14 +1,12 @@
"""The tests for the TTS component."""
import ctypes
import os
-import shutil
from unittest.mock import PropertyMock, patch
import pytest
-import requests
+import yarl
from homeassistant.components.demo.tts import DemoProvider
-import homeassistant.components.http as http
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -17,15 +15,52 @@ from homeassistant.components.media_player.const import (
SERVICE_PLAY_MEDIA,
)
import homeassistant.components.tts as tts
-from homeassistant.setup import async_setup_component, setup_component
+from homeassistant.components.tts import _get_cache_files
+from homeassistant.setup import async_setup_component
-from tests.common import (
- assert_setup_component,
- get_test_home_assistant,
- get_test_instance_port,
- mock_service,
- mock_storage,
-)
+from tests.common import assert_setup_component, async_mock_service
+
+
+def relative_url(url):
+ """Convert an absolute url to a relative one."""
+ return str(yarl.URL(url).relative())
+
+
+@pytest.fixture
+def demo_provider():
+ """Demo TTS provider."""
+ return DemoProvider("en")
+
+
+@pytest.fixture(autouse=True)
+def mock_get_cache_files():
+ """Mock the list TTS cache function."""
+ with patch(
+ "homeassistant.components.tts._get_cache_files", return_value={}
+ ) as mock_cache_files:
+ yield mock_cache_files
+
+
+@pytest.fixture(autouse=True)
+def mock_init_cache_dir():
+ """Mock the TTS cache dir in memory."""
+ with patch(
+ "homeassistant.components.tts._init_tts_cache_dir",
+ side_effect=lambda hass, cache_dir: hass.config.path(cache_dir),
+ ) as mock_cache_dir:
+ yield mock_cache_dir
+
+
+@pytest.fixture
+def empty_cache_dir(tmp_path, mock_init_cache_dir, mock_get_cache_files):
+ """Mock the TTS cache dir with empty dir."""
+ mock_init_cache_dir.side_effect = None
+ mock_init_cache_dir.return_value = str(tmp_path)
+
+ # Restore original get cache files behavior, we're working with a real dir.
+ mock_get_cache_files.side_effect = _get_cache_files
+
+ return tmp_path
@pytest.fixture(autouse=True)
@@ -38,239 +73,209 @@ def mutagen_mock():
yield
-class TestTTS:
- """Test the Google speech component."""
+async def test_setup_component_demo(hass):
+ """Set up the demo platform with defaults."""
+ config = {tts.DOMAIN: {"platform": "demo"}}
- def setup_method(self):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.demo_provider = DemoProvider("en")
- self.default_tts_cache = self.hass.config.path(tts.DEFAULT_CACHE_DIR)
- self.mock_storage = mock_storage()
- self.mock_storage.__enter__()
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- setup_component(
- self.hass,
- http.DOMAIN,
- {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}},
- )
+ assert hass.services.has_service(tts.DOMAIN, "demo_say")
+ assert hass.services.has_service(tts.DOMAIN, "clear_cache")
- def teardown_method(self):
- """Stop everything that was started."""
- self.hass.stop()
- self.mock_storage.__exit__(None, None, None)
- if os.path.isdir(self.default_tts_cache):
- shutil.rmtree(self.default_tts_cache)
+async def test_setup_component_demo_no_access_cache_folder(hass, mock_init_cache_dir):
+ """Set up the demo platform with defaults."""
+ config = {tts.DOMAIN: {"platform": "demo"}}
- def test_setup_component_demo(self):
- """Set up the demo platform with defaults."""
- config = {tts.DOMAIN: {"platform": "demo"}}
+ mock_init_cache_dir.side_effect = OSError(2, "No access")
+ assert not await async_setup_component(hass, tts.DOMAIN, config)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ assert not hass.services.has_service(tts.DOMAIN, "demo_say")
+ assert not hass.services.has_service(tts.DOMAIN, "clear_cache")
- assert self.hass.services.has_service(tts.DOMAIN, "demo_say")
- assert self.hass.services.has_service(tts.DOMAIN, "clear_cache")
- @patch("os.mkdir", side_effect=OSError(2, "No access"))
- def test_setup_component_demo_no_access_cache_folder(self, mock_mkdir):
- """Set up the demo platform with defaults."""
- config = {tts.DOMAIN: {"platform": "demo"}}
+async def test_setup_component_and_test_service(hass, empty_cache_dir):
+ """Set up the demo platform and call service."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- assert not setup_component(self.hass, tts.DOMAIN, config)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- assert not self.hass.services.has_service(tts.DOMAIN, "demo_say")
- assert not self.hass.services.has_service(tts.DOMAIN, "clear_cache")
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- def test_setup_component_and_test_service(self):
- """Set up the demo platform and call service."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
- config = {tts.DOMAIN: {"platform": "demo"}}
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[
+ ATTR_MEDIA_CONTENT_ID
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format(
+ hass.config.api.base_url
+ )
+ assert (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ ).is_file()
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+async def test_setup_component_and_test_service_with_config_language(
+ hass, empty_cache_dir
+):
+ """Set up the demo platform and call service."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- assert len(calls) == 1
- assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
- assert calls[0].data[
- ATTR_MEDIA_CONTENT_ID
- ] == "{}/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,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
- )
+ config = {tts.DOMAIN: {"platform": "demo", "language": "de"}}
- def test_setup_component_and_test_service_with_config_language(self):
- """Set up the demo platform and call service."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- config = {tts.DOMAIN: {"platform": "demo", "language": "de"}}
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[
+ ATTR_MEDIA_CONTENT_ID
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format(
+ hass.config.api.base_url
+ )
+ assert (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3"
+ ).is_file()
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+async def test_setup_component_and_test_service_with_wrong_conf_language(hass):
+ """Set up the demo platform and call service with wrong config."""
+ config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}}
- assert len(calls) == 1
- assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
- assert calls[0].data[
- ATTR_MEDIA_CONTENT_ID
- ] == "{}/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,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
- )
- )
+ with assert_setup_component(0, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- def test_setup_component_and_test_service_with_wrong_conf_language(self):
- """Set up the demo platform and call service with wrong config."""
- config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}}
- with assert_setup_component(0, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+async def test_setup_component_and_test_service_with_service_language(
+ hass, empty_cache_dir
+):
+ """Set up the demo platform and call service."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- def test_setup_component_and_test_service_with_service_language(self):
- """Set up the demo platform and call service."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- config = {tts.DOMAIN: {"platform": "demo"}}
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ tts.ATTR_LANGUAGE: "de",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[
+ ATTR_MEDIA_CONTENT_ID
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format(
+ hass.config.api.base_url
+ )
+ assert (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3"
+ ).is_file()
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- tts.ATTR_LANGUAGE: "de",
- },
- )
- self.hass.block_till_done()
- assert len(calls) == 1
- assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
- assert calls[0].data[
- ATTR_MEDIA_CONTENT_ID
- ] == "{}/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,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
- )
- )
+async def test_setup_component_test_service_with_wrong_service_language(
+ hass, empty_cache_dir
+):
+ """Set up the demo platform and call service."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- def test_setup_component_test_service_with_wrong_service_language(self):
- """Set up the demo platform and call service."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- config = {tts.DOMAIN: {"platform": "demo"}}
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ tts.ATTR_LANGUAGE: "lang",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 0
+ assert not (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3"
+ ).is_file()
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- tts.ATTR_LANGUAGE: "lang",
- },
- )
- self.hass.block_till_done()
- assert len(calls) == 0
- assert not os.path.isfile(
- os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3",
- )
- )
+async def test_setup_component_and_test_service_with_service_options(
+ hass, empty_cache_dir
+):
+ """Set up the demo platform and call service with options."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- def test_setup_component_and_test_service_with_service_options(self):
- """Set up the demo platform and call service with options."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- config = {tts.DOMAIN: {"platform": "demo"}}
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ tts.ATTR_LANGUAGE: "de",
+ tts.ATTR_OPTIONS: {"voice": "alex"},
+ },
+ blocking=True,
+ )
+ opt_hash = ctypes.c_size_t(hash(frozenset({"voice": "alex"}))).value
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- tts.ATTR_LANGUAGE: "de",
- tts.ATTR_OPTIONS: {"voice": "alex"},
- },
- )
- self.hass.block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[
+ ATTR_MEDIA_CONTENT_ID
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format(
+ hass.config.api.base_url, opt_hash
+ )
+ assert (
+ empty_cache_dir
+ / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3"
+ ).is_file()
- opt_hash = ctypes.c_size_t(hash(frozenset({"voice": "alex"}))).value
- assert len(calls) == 1
- assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
- assert calls[0].data[
- ATTR_MEDIA_CONTENT_ID
- ] == "{}/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,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format(
- opt_hash
- ),
- )
- )
+async def test_setup_component_and_test_with_service_options_def(hass, empty_cache_dir):
+ """Set up the demo platform and call service with default options."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- @patch(
+ config = {tts.DOMAIN: {"platform": "demo"}}
+
+ with assert_setup_component(1, tts.DOMAIN), patch(
"homeassistant.components.demo.tts.DemoProvider.default_options",
new_callable=PropertyMock(return_value={"voice": "alex"}),
- )
- def test_setup_component_and_test_with_service_options_def(self, def_mock):
- """Set up the demo platform and call service with default options."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ ):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- config = {tts.DOMAIN: {"platform": "demo"}}
-
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
-
- self.hass.services.call(
+ await hass.services.async_call(
tts.DOMAIN,
"demo_say",
{
@@ -278,9 +283,8 @@ class TestTTS:
tts.ATTR_MESSAGE: "There is someone at the door.",
tts.ATTR_LANGUAGE: "de",
},
+ blocking=True,
)
- self.hass.block_till_done()
-
opt_hash = ctypes.c_size_t(hash(frozenset({"voice": "alex"}))).value
assert len(calls) == 1
@@ -288,362 +292,341 @@ class TestTTS:
assert calls[0].data[
ATTR_MEDIA_CONTENT_ID
] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format(
- self.hass.config.api.base_url, opt_hash
+ hass.config.api.base_url, opt_hash
)
assert os.path.isfile(
os.path.join(
- self.default_tts_cache,
+ empty_cache_dir,
"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format(
opt_hash
),
)
)
- def test_setup_component_and_test_service_with_service_options_wrong(self):
- """Set up the demo platform and call service with wrong options."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- config = {tts.DOMAIN: {"platform": "demo"}}
+async def test_setup_component_and_test_service_with_service_options_wrong(
+ hass, empty_cache_dir
+):
+ """Set up the demo platform and call service with wrong options."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- tts.ATTR_LANGUAGE: "de",
- tts.ATTR_OPTIONS: {"speed": 1},
- },
- )
- self.hass.block_till_done()
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- opt_hash = ctypes.c_size_t(hash(frozenset({"speed": 1}))).value
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ tts.ATTR_LANGUAGE: "de",
+ tts.ATTR_OPTIONS: {"speed": 1},
+ },
+ blocking=True,
+ )
+ opt_hash = ctypes.c_size_t(hash(frozenset({"speed": 1}))).value
- assert len(calls) == 0
- assert not os.path.isfile(
- os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format(
- opt_hash
- ),
- )
- )
+ assert len(calls) == 0
+ assert not (
+ empty_cache_dir
+ / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3"
+ ).is_file()
- def test_setup_component_and_test_service_with_base_url_set(self):
- """Set up the demo platform with ``base_url`` set and call service."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}}
+async def test_setup_component_and_test_service_with_base_url_set(hass):
+ """Set up the demo platform with ``base_url`` set and call service."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}}
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- assert len(calls) == 1
- assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
- assert (
- calls[0].data[ATTR_MEDIA_CONTENT_ID] == "http://fnord"
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- "_en_-_demo.mp3"
- )
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert (
+ calls[0].data[ATTR_MEDIA_CONTENT_ID] == "http://fnord"
+ "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
+ "_en_-_demo.mp3"
+ )
- def test_setup_component_and_test_service_clear_cache(self):
- """Set up the demo platform and call service clear cache."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- config = {tts.DOMAIN: {"platform": "demo"}}
+async def test_setup_component_and_test_service_clear_cache(hass, empty_cache_dir):
+ """Set up the demo platform and call service clear cache."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- assert len(calls) == 1
- assert os.path.isfile(
- os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
- )
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
+ # To make sure the file is persisted
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ ).is_file()
- self.hass.services.call(tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {})
- self.hass.block_till_done()
+ await hass.services.async_call(
+ tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {}, blocking=True
+ )
- assert not os.path.isfile(
- os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
- )
+ assert not (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ ).is_file()
- def test_setup_component_and_test_service_with_receive_voice(self):
- """Set up the demo platform and call service and receive voice."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- config = {tts.DOMAIN: {"platform": "demo"}}
+async def test_setup_component_and_test_service_with_receive_voice(
+ hass, demo_provider, hass_client
+):
+ """Set up the demo platform and call service and receive voice."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ config = {tts.DOMAIN: {"platform": "demo"}}
- self.hass.start()
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+ client = await hass_client()
- assert len(calls) == 1
- 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(
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- demo_data,
- self.demo_provider,
- "AI person is in front of your door.",
- "en",
- None,
- )
- assert req.status_code == 200
- assert req.content == demo_data
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
- def test_setup_component_and_test_service_with_receive_voice_german(self):
- """Set up the demo platform and call service and receive voice."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ req = await client.get(relative_url(calls[0].data[ATTR_MEDIA_CONTENT_ID]))
+ _, demo_data = demo_provider.get_tts_audio("bla", "en")
+ demo_data = tts.SpeechManager.write_tags(
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
+ demo_data,
+ demo_provider,
+ "AI person is in front of your door.",
+ "en",
+ None,
+ )
+ assert req.status == 200
+ assert await req.read() == demo_data
- config = {tts.DOMAIN: {"platform": "demo", "language": "de"}}
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+async def test_setup_component_and_test_service_with_receive_voice_german(
+ hass, demo_provider, hass_client
+):
+ """Set up the demo platform and call service and receive voice."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- self.hass.start()
+ config = {tts.DOMAIN: {"platform": "demo", "language": "de"}}
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- assert len(calls) == 1
- 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(
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
- demo_data,
- self.demo_provider,
- "There is someone at the door.",
- "de",
- None,
- )
- assert req.status_code == 200
- assert req.content == demo_data
+ client = await hass_client()
- def test_setup_component_and_web_view_wrong_file(self):
- """Set up the demo platform and receive wrong file from web."""
- config = {tts.DOMAIN: {"platform": "demo"}}
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
+ req = await client.get(relative_url(calls[0].data[ATTR_MEDIA_CONTENT_ID]))
+ _, demo_data = demo_provider.get_tts_audio("bla", "de")
+ demo_data = tts.SpeechManager.write_tags(
+ "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3",
+ demo_data,
+ demo_provider,
+ "There is someone at the door.",
+ "de",
+ None,
+ )
+ assert req.status == 200
+ assert await req.read() == demo_data
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
- self.hass.start()
+async def test_setup_component_and_web_view_wrong_file(hass, hass_client):
+ """Set up the demo platform and receive wrong file from web."""
+ config = {tts.DOMAIN: {"platform": "demo"}}
- url = (
- "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
- ).format(self.hass.config.api.base_url)
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- req = requests.get(url)
- assert req.status_code == 404
+ client = await hass_client()
- def test_setup_component_and_web_view_wrong_filename(self):
- """Set up the demo platform and receive wrong filename from web."""
- config = {tts.DOMAIN: {"platform": "demo"}}
+ url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ req = await client.get(url)
+ assert req.status == 404
- self.hass.start()
- url = (
- "{}/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3"
- ).format(self.hass.config.api.base_url)
+async def test_setup_component_and_web_view_wrong_filename(hass, hass_client):
+ """Set up the demo platform and receive wrong filename from web."""
+ config = {tts.DOMAIN: {"platform": "demo"}}
- req = requests.get(url)
- assert req.status_code == 404
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- def test_setup_component_test_without_cache(self):
- """Set up demo platform without cache."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ client = await hass_client()
- config = {tts.DOMAIN: {"platform": "demo", "cache": False}}
+ url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3"
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ req = await client.get(url)
+ assert req.status == 404
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
- assert len(calls) == 1
- assert not os.path.isfile(
- os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
- )
+async def test_setup_component_test_without_cache(hass, empty_cache_dir):
+ """Set up demo platform without cache."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- def test_setup_component_test_with_cache_call_service_without_cache(self):
- """Set up demo platform with cache and call service without cache."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ config = {tts.DOMAIN: {"platform": "demo", "cache": False}}
- config = {tts.DOMAIN: {"platform": "demo", "cache": True}}
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
+ assert not (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ ).is_file()
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- tts.ATTR_CACHE: False,
- },
- )
- self.hass.block_till_done()
- assert len(calls) == 1
- assert not os.path.isfile(
- os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
- )
+async def test_setup_component_test_with_cache_call_service_without_cache(
+ hass, empty_cache_dir
+):
+ """Set up demo platform with cache and call service without cache."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- def test_setup_component_test_with_cache_dir(self):
- """Set up demo platform with cache and call service without cache."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+ config = {tts.DOMAIN: {"platform": "demo", "cache": True}}
- _, demo_data = self.demo_provider.get_tts_audio("bla", "en")
- cache_file = os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- os.mkdir(self.default_tts_cache)
- with open(cache_file, "wb") as voice_file:
- voice_file.write(demo_data)
+ await hass.services.async_call(
+ tts.DOMAIN,
+ "demo_say",
+ {
+ "entity_id": "media_player.something",
+ tts.ATTR_MESSAGE: "There is someone at the door.",
+ tts.ATTR_CACHE: False,
+ },
+ blocking=True,
+ )
+ assert len(calls) == 1
+ assert not (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ ).is_file()
- config = {tts.DOMAIN: {"platform": "demo", "cache": True}}
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+async def test_setup_component_test_with_cache_dir(
+ hass, empty_cache_dir, demo_provider
+):
+ """Set up demo platform with cache and call service without cache."""
+ calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- with patch(
- "homeassistant.components.demo.tts.DemoProvider.get_tts_audio",
- return_value=(None, None),
- ):
- self.hass.services.call(
- tts.DOMAIN,
- "demo_say",
- {
- "entity_id": "media_player.something",
- tts.ATTR_MESSAGE: "There is someone at the door.",
- },
- )
- self.hass.block_till_done()
+ _, demo_data = demo_provider.get_tts_audio("bla", "en")
+ cache_file = (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ )
- assert len(calls) == 1
- assert calls[0].data[
- ATTR_MEDIA_CONTENT_ID
- ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format(
- self.hass.config.api.base_url
- )
+ with open(cache_file, "wb") as voice_file:
+ voice_file.write(demo_data)
- @patch(
+ config = {tts.DOMAIN: {"platform": "demo", "cache": True}}
+
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
+
+ with patch(
"homeassistant.components.demo.tts.DemoProvider.get_tts_audio",
return_value=(None, None),
- )
- def test_setup_component_test_with_error_on_get_tts(self, tts_mock):
- """Set up demo platform with wrong get_tts_audio."""
- calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
-
- config = {tts.DOMAIN: {"platform": "demo"}}
-
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
-
- self.hass.services.call(
+ ):
+ await hass.services.async_call(
tts.DOMAIN,
"demo_say",
{
"entity_id": "media_player.something",
tts.ATTR_MESSAGE: "There is someone at the door.",
},
+ blocking=True,
)
- self.hass.block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data[
+ ATTR_MEDIA_CONTENT_ID
+ ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format(
+ hass.config.api.base_url
+ )
- assert len(calls) == 0
- def test_setup_component_load_cache_retrieve_without_mem_cache(self):
- """Set up component and load cache and get without mem cache."""
- _, demo_data = self.demo_provider.get_tts_audio("bla", "en")
- cache_file = os.path.join(
- self.default_tts_cache,
- "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3",
- )
+async def test_setup_component_test_with_error_on_get_tts(hass):
+ """Set up demo platform with wrong get_tts_audio."""
+ config = {tts.DOMAIN: {"platform": "demo"}}
- os.mkdir(self.default_tts_cache)
- with open(cache_file, "wb") as voice_file:
- voice_file.write(demo_data)
+ with assert_setup_component(1, tts.DOMAIN), patch(
+ "homeassistant.components.demo.tts.DemoProvider.get_tts_audio",
+ return_value=(None, None),
+ ):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
- config = {tts.DOMAIN: {"platform": "demo", "cache": True}}
- with assert_setup_component(1, tts.DOMAIN):
- setup_component(self.hass, tts.DOMAIN, config)
+async def test_setup_component_load_cache_retrieve_without_mem_cache(
+ hass, demo_provider, empty_cache_dir, hass_client
+):
+ """Set up component and load cache and get without mem cache."""
+ _, demo_data = demo_provider.get_tts_audio("bla", "en")
+ cache_file = (
+ empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+ )
- self.hass.start()
+ with open(cache_file, "wb") as voice_file:
+ voice_file.write(demo_data)
- url = (
- "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
- ).format(self.hass.config.api.base_url)
+ config = {tts.DOMAIN: {"platform": "demo", "cache": True}}
- req = requests.get(url)
- assert req.status_code == 200
- assert req.content == demo_data
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(hass, tts.DOMAIN, config)
+
+ client = await hass_client()
+
+ url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3"
+
+ req = await client.get(url)
+ assert req.status == 200
+ assert await req.read() == demo_data
async def test_setup_component_and_web_get_url(hass, hass_client):
@@ -666,10 +649,6 @@ async def test_setup_component_and_web_get_url(hass, hass_client):
)
)
- tts_cache = hass.config.path(tts.DEFAULT_CACHE_DIR)
- if os.path.isdir(tts_cache):
- shutil.rmtree(tts_cache)
-
async def test_setup_component_and_web_get_url_bad_config(hass, hass_client):
"""Set up the demo platform and receive wrong file from web."""
diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py
index 1ccbe7a58a9..7dd19e755f3 100644
--- a/tests/components/twentemilieu/test_config_flow.py
+++ b/tests/components/twentemilieu/test_config_flow.py
@@ -34,7 +34,7 @@ async def test_show_set_form(hass):
async def test_connection_error(hass, aioclient_mock):
"""Test we show user form on Twente Milieu connection error."""
aioclient_mock.post(
- "https://wasteapi.2go-mobile.com/api/FetchAdress", exc=aiohttp.ClientError
+ "https://twentemilieuapi.ximmio.com/api/FetchAdress", exc=aiohttp.ClientError
)
flow = config_flow.TwenteMilieuFlowHandler()
@@ -49,7 +49,7 @@ async def test_connection_error(hass, aioclient_mock):
async def test_invalid_address(hass, aioclient_mock):
"""Test we show user form on Twente Milieu invalid address error."""
aioclient_mock.post(
- "https://wasteapi.2go-mobile.com/api/FetchAdress",
+ "https://twentemilieuapi.ximmio.com/api/FetchAdress",
json={"dataList": []},
headers={"Content-Type": "application/json"},
)
@@ -70,7 +70,7 @@ async def test_address_already_set_up(hass, aioclient_mock):
)
aioclient_mock.post(
- "https://wasteapi.2go-mobile.com/api/FetchAdress",
+ "https://twentemilieuapi.ximmio.com/api/FetchAdress",
json={"dataList": [{"UniqueId": "12345"}]},
headers={"Content-Type": "application/json"},
)
@@ -86,7 +86,7 @@ async def test_address_already_set_up(hass, aioclient_mock):
async def test_full_flow_implementation(hass, aioclient_mock):
"""Test registering an integration and finishing flow works."""
aioclient_mock.post(
- "https://wasteapi.2go-mobile.com/api/FetchAdress",
+ "https://twentemilieuapi.ximmio.com/api/FetchAdress",
json={"dataList": [{"UniqueId": "12345"}]},
headers={"Content-Type": "application/json"},
)
diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py
index 8bf2225d1f1..d3ff905e7b3 100644
--- a/tests/components/unifi/test_controller.py
+++ b/tests/components/unifi/test_controller.py
@@ -189,32 +189,6 @@ async def test_controller_mac(hass):
assert controller.mac == "10:00:00:00:00:01"
-async def test_controller_import_config(hass):
- """Test that import configuration.yaml instructions work."""
- controllers = [
- {
- CONF_HOST: "1.2.3.4",
- CONF_SITE_ID: "Site name",
- unifi.CONF_BLOCK_CLIENT: ["random mac"],
- unifi.CONF_DONT_TRACK_CLIENTS: True,
- unifi.CONF_DONT_TRACK_DEVICES: True,
- unifi.CONF_DONT_TRACK_WIRED_CLIENTS: True,
- unifi.CONF_DETECTION_TIME: 150,
- unifi.CONF_SSID_FILTER: ["SSID"],
- }
- ]
-
- controller = await setup_unifi_integration(hass, controllers=controllers)
-
- assert controller.option_allow_bandwidth_sensors is False
- assert controller.option_block_clients == ["random mac"]
- assert controller.option_track_clients is False
- assert controller.option_track_devices is False
- assert controller.option_track_wired_clients is False
- assert controller.option_detection_time == timedelta(seconds=150)
- assert controller.option_ssid_filter == ["SSID"]
-
-
async def test_controller_not_accessible(hass):
"""Retry to login gets scheduled when connection fails."""
with patch.object(
diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py
index 079bbd7d751..cfb4637a6c4 100644
--- a/tests/components/unifi/test_device_tracker.py
+++ b/tests/components/unifi/test_device_tracker.py
@@ -10,6 +10,7 @@ from homeassistant import config_entries
from homeassistant.components import unifi
import homeassistant.components.device_tracker as device_tracker
from homeassistant.components.unifi.const import (
+ CONF_BLOCK_CLIENT,
CONF_SSID_FILTER,
CONF_TRACK_CLIENTS,
CONF_TRACK_DEVICES,
@@ -123,7 +124,7 @@ async def test_tracked_devices(hass):
devices_response=[DEVICE_1, DEVICE_2],
known_wireless_clients=(CLIENT_4["mac"],),
)
- assert len(hass.states.async_entity_ids("device_tracker")) == 6
+ assert len(hass.states.async_entity_ids("device_tracker")) == 5
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -133,9 +134,9 @@ async def test_tracked_devices(hass):
assert client_2 is not None
assert client_2.state == "not_home"
+ # Client on SSID not in SSID filter
client_3 = hass.states.get("device_tracker.client_3")
- assert client_3 is not None
- assert client_3.state == "not_home"
+ assert not client_3
# Wireless client with wired bug, if bug active on restart mark device away
client_4 = hass.states.get("device_tracker.client_4")
@@ -349,11 +350,11 @@ async def test_option_ssid_filter(hass):
controller = await setup_unifi_integration(
hass, options={CONF_SSID_FILTER: ["ssid"]}, clients_response=[CLIENT_3],
)
- assert len(hass.states.async_entity_ids("device_tracker")) == 1
+ assert len(hass.states.async_entity_ids("device_tracker")) == 0
# SSID filter active
client_3 = hass.states.get("device_tracker.client_3")
- assert client_3.state == "not_home"
+ assert not client_3
client_3_copy = copy(CLIENT_3)
client_3_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
@@ -363,7 +364,7 @@ async def test_option_ssid_filter(hass):
# 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"
+ assert not client_3
# Remove SSID filter
hass.config_entries.async_update_entry(
@@ -456,7 +457,7 @@ async def test_restoring_client(hass):
await setup_unifi_integration(
hass,
- options={unifi.CONF_BLOCK_CLIENT: True},
+ options={CONF_BLOCK_CLIENT: True},
clients_response=[CLIENT_2],
clients_all_response=[CLIENT_1],
)
diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py
index 12f9c1bfd17..0ccc89cdb89 100644
--- a/tests/components/unifi/test_init.py
+++ b/tests/components/unifi/test_init.py
@@ -13,33 +13,6 @@ async def test_setup_with_no_config(hass):
"""Test that we do not discover anything or try to set up a bridge."""
assert await async_setup_component(hass, unifi.DOMAIN, {}) is True
assert unifi.DOMAIN not in hass.data
- assert hass.data[unifi.UNIFI_CONFIG] == []
-
-
-async def test_setup_with_config(hass):
- """Test that we do not discover anything or try to set up a bridge."""
- config = {
- unifi.DOMAIN: {
- unifi.CONF_CONTROLLERS: {
- unifi.CONF_HOST: "1.2.3.4",
- unifi.CONF_SITE_ID: "My site",
- unifi.CONF_BLOCK_CLIENT: ["12:34:56:78:90:AB"],
- unifi.CONF_DETECTION_TIME: 3,
- unifi.CONF_SSID_FILTER: ["ssid"],
- }
- }
- }
- assert await async_setup_component(hass, unifi.DOMAIN, config) is True
- assert unifi.DOMAIN not in hass.data
- assert hass.data[unifi.UNIFI_CONFIG] == [
- {
- unifi.CONF_HOST: "1.2.3.4",
- unifi.CONF_SITE_ID: "My site",
- unifi.CONF_BLOCK_CLIENT: ["12:34:56:78:90:AB"],
- unifi.CONF_DETECTION_TIME: 3,
- unifi.CONF_SSID_FILTER: ["ssid"],
- }
- ]
async def test_successful_config_entry(hass):
diff --git a/tests/components/vera/common.py b/tests/components/vera/common.py
index e99540e675e..649cf9af6a5 100644
--- a/tests/components/vera/common.py
+++ b/tests/components/vera/common.py
@@ -9,7 +9,11 @@ from homeassistant.components.vera import CONF_CONTROLLER, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
-ComponentData = NamedTuple("ComponentData", (("controller", VeraController),))
+
+class ComponentData(NamedTuple):
+ """Component data."""
+
+ controller: VeraController
class ComponentFactory:
diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py
index ab581bdf3c6..868bf44a11b 100644
--- a/tests/components/vizio/conftest.py
+++ b/tests/components/vizio/conftest.py
@@ -7,7 +7,7 @@ from .const import (
ACCESS_TOKEN,
APP_LIST,
CH_TYPE,
- CURRENT_APP,
+ CURRENT_APP_CONFIG,
CURRENT_INPUT,
INPUT_LIST,
INPUT_LIST_WITH_APPS,
@@ -172,7 +172,7 @@ def vizio_update_with_apps_fixture(vizio_update: pytest.fixture):
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
return_value="CAST",
), patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_current_app",
- return_value=CURRENT_APP,
+ "homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
+ return_value=CURRENT_APP_CONFIG,
):
yield
diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py
index 2cb9103c4d9..f1ddc4abba6 100644
--- a/tests/components/vizio/const.py
+++ b/tests/components/vizio/const.py
@@ -68,14 +68,19 @@ CURRENT_INPUT = "HDMI"
INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
CURRENT_APP = "Hulu"
+CURRENT_APP_CONFIG = {CONF_APP_ID: "3", CONF_NAME_SPACE: 4, CONF_MESSAGE: None}
APP_LIST = ["Hulu", "Netflix"]
INPUT_LIST_WITH_APPS = INPUT_LIST + ["CAST"]
-CUSTOM_APP_NAME = "APP3"
CUSTOM_CONFIG = {CONF_APP_ID: "test", CONF_MESSAGE: None, CONF_NAME_SPACE: 10}
ADDITIONAL_APP_CONFIG = {
- "name": CUSTOM_APP_NAME,
+ "name": CURRENT_APP,
CONF_CONFIG: CUSTOM_CONFIG,
}
+UNKNOWN_APP_CONFIG = {
+ "APP_ID": "UNKNOWN",
+ "NAME_SPACE": 10,
+ "MESSAGE": None,
+}
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 e773035447a..a8a760d8ca2 100644
--- a/tests/components/vizio/test_config_flow.py
+++ b/tests/components/vizio/test_config_flow.py
@@ -9,9 +9,7 @@ from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_C
from homeassistant.components.vizio.config_flow import _get_config_schema
from homeassistant.components.vizio.const import (
CONF_APPS,
- CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
CONF_INCLUDE,
- CONF_INCLUDE_OR_EXCLUDE,
CONF_VOLUME_STEP,
DEFAULT_NAME,
DEFAULT_VOLUME_STEP,
@@ -39,6 +37,7 @@ from .const import (
MOCK_PIN_CONFIG,
MOCK_SPEAKER_CONFIG,
MOCK_TV_CONFIG_NO_TOKEN,
+ MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG,
MOCK_TV_WITH_EXCLUDE_CONFIG,
MOCK_USER_VALID_TV_CONFIG,
MOCK_ZEROCONF_SERVICE_INFO,
@@ -95,52 +94,17 @@ async def test_user_flow_all_fields(
result["flow_id"], user_input=MOCK_USER_VALID_TV_CONFIG
)
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["step_id"] == "tv_apps"
-
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=MOCK_INCLUDE_APPS
- )
-
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"][CONF_NAME] == NAME
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN
- assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP]
+ assert CONF_APPS not in result["data"]
-async def test_user_apps_with_tv(
- hass: HomeAssistantType,
- vizio_connect: pytest.fixture,
- vizio_bypass_setup: pytest.fixture,
-) -> None:
- """Test TV can have selected apps during user setup."""
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=MOCK_IMPORT_VALID_TV_CONFIG
- )
-
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["step_id"] == "tv_apps"
-
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=MOCK_INCLUDE_APPS
- )
-
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == NAME
- assert result["data"][CONF_NAME] == NAME
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
- assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN
- assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP]
- assert CONF_APPS_TO_INCLUDE_OR_EXCLUDE not in result["data"]
- assert CONF_INCLUDE_OR_EXCLUDE not in result["data"]
-
-
-async def test_options_flow(hass: HomeAssistantType) -> None:
- """Test options config flow."""
+async def test_speaker_options_flow(hass: HomeAssistantType) -> None:
+ """Test options config flow for speaker."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_SPEAKER_CONFIG)
entry.add_to_hass(hass)
@@ -158,6 +122,58 @@ async def test_options_flow(hass: HomeAssistantType) -> None:
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == ""
assert result["data"][CONF_VOLUME_STEP] == VOLUME_STEP
+ assert CONF_APPS not in result["data"]
+
+
+async def test_tv_options_flow_no_apps(hass: HomeAssistantType) -> None:
+ """Test options config flow for TV without providing apps option."""
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG)
+ entry.add_to_hass(hass)
+
+ assert not entry.options
+
+ result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ options = {CONF_VOLUME_STEP: VOLUME_STEP}
+ options.update(MOCK_INCLUDE_NO_APPS)
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input=options
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == ""
+ assert result["data"][CONF_VOLUME_STEP] == VOLUME_STEP
+ assert CONF_APPS not in result["data"]
+
+
+async def test_tv_options_flow_with_apps(hass: HomeAssistantType) -> None:
+ """Test options config flow for TV with providing apps option."""
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG)
+ entry.add_to_hass(hass)
+
+ assert not entry.options
+
+ result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ options = {CONF_VOLUME_STEP: VOLUME_STEP}
+ options.update(MOCK_INCLUDE_APPS)
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input=options
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == ""
+ assert result["data"][CONF_VOLUME_STEP] == VOLUME_STEP
+ assert CONF_APPS in result["data"]
+ assert result["data"][CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]}
async def test_user_host_already_configured(
@@ -282,11 +298,9 @@ async def test_user_tv_pairing_no_apps(
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["step_id"] == "tv_apps"
+ assert result["step_id"] == "pairing_complete"
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=MOCK_INCLUDE_NO_APPS
- )
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
@@ -427,10 +441,8 @@ async def test_import_flow_update_options(
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "updated_entry"
- assert (
- hass.config_entries.async_get_entry(entry_id).options[CONF_VOLUME_STEP]
- == VOLUME_STEP + 1
- )
+ config_entry = hass.config_entries.async_get_entry(entry_id)
+ assert config_entry.options[CONF_VOLUME_STEP] == VOLUME_STEP + 1
async def test_import_flow_update_name_and_apps(
@@ -461,10 +473,10 @@ async def test_import_flow_update_name_and_apps(
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
- assert hass.config_entries.async_get_entry(entry_id).data[CONF_APPS] == {
- CONF_INCLUDE: [CURRENT_APP]
- }
+ config_entry = hass.config_entries.async_get_entry(entry_id)
+ assert config_entry.data[CONF_NAME] == NAME2
+ assert config_entry.data[CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]}
+ assert config_entry.options[CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]}
async def test_import_flow_update_remove_apps(
@@ -482,7 +494,9 @@ async def test_import_flow_update_remove_apps(
assert result["result"].data[CONF_NAME] == NAME
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- entry_id = result["result"].entry_id
+ config_entry = hass.config_entries.async_get_entry(result["result"].entry_id)
+ assert CONF_APPS in config_entry.data
+ assert CONF_APPS in config_entry.options
updated_config = MOCK_TV_WITH_EXCLUDE_CONFIG.copy()
updated_config.pop(CONF_APPS)
@@ -494,7 +508,8 @@ async def test_import_flow_update_remove_apps(
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "updated_entry"
- assert hass.config_entries.async_get_entry(entry_id).data.get(CONF_APPS) is None
+ assert CONF_APPS not in config_entry.data
+ assert CONF_APPS not in config_entry.options
async def test_import_needs_pairing(
@@ -577,6 +592,26 @@ async def test_import_with_apps_needs_pairing(
assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP]
+async def test_import_flow_additional_configs(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
+ """Test import config flow with additional configs defined in CONF_APPS."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG),
+ )
+ await hass.async_block_till_done()
+
+ assert result["result"].data[CONF_NAME] == NAME
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ config_entry = hass.config_entries.async_get_entry(result["result"].entry_id)
+ assert CONF_APPS in config_entry.data
+ assert CONF_APPS not in config_entry.options
+
+
async def test_import_error(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py
index 68366e8e98b..f860c1cec4f 100644
--- a/tests/components/vizio/test_media_player.py
+++ b/tests/components/vizio/test_media_player.py
@@ -1,13 +1,13 @@
"""Tests for Vizio config flow."""
from datetime import timedelta
import logging
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from unittest.mock import call
from asynctest import patch
import pytest
from pytest import raises
-from pyvizio._api.apps import AppConfig
+from pyvizio.api.apps import AppConfig
from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
@@ -57,8 +57,8 @@ from .const import (
ADDITIONAL_APP_CONFIG,
APP_LIST,
CURRENT_APP,
+ CURRENT_APP_CONFIG,
CURRENT_INPUT,
- CUSTOM_APP_NAME,
CUSTOM_CONFIG,
ENTITY_ID,
INPUT_LIST,
@@ -72,6 +72,7 @@ from .const import (
MOCK_USER_VALID_TV_CONFIG,
NAME,
UNIQUE_ID,
+ UNKNOWN_APP_CONFIG,
VOLUME_STEP,
)
@@ -81,7 +82,7 @@ _LOGGER = logging.getLogger(__name__)
async def _test_setup(
- hass: HomeAssistantType, ha_device_class: str, vizio_power_state: bool
+ hass: HomeAssistantType, ha_device_class: str, vizio_power_state: Optional[bool]
) -> None:
"""Test Vizio Device entity setup."""
if vizio_power_state:
@@ -113,7 +114,7 @@ async def _test_setup(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
return_value=vizio_power_state,
), patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_current_app",
+ "homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
) as service_call:
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -137,7 +138,10 @@ async def _test_setup(
async def _test_setup_with_apps(
- hass: HomeAssistantType, device_config: Dict[str, Any], app: str
+ hass: HomeAssistantType,
+ device_config: Dict[str, Any],
+ app: Optional[str],
+ app_config: Dict[str, Any],
) -> None:
"""Test Vizio Device with apps entity setup."""
config_entry = MockConfigEntry(
@@ -153,12 +157,9 @@ async def _test_setup_with_apps(
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
return_value=True,
- ), patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_current_app",
- return_value=app,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
- return_value=AppConfig(**ADDITIONAL_APP_CONFIG["config"]),
+ return_value=AppConfig(**app_config),
):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -180,21 +181,34 @@ async def _test_setup_with_apps(
+ [
app["name"]
for app in device_config[CONF_APPS][CONF_ADDITIONAL_CONFIGS]
+ if app["name"] not in APP_LIST
]
)
else:
list_to_test = list(INPUT_LIST_WITH_APPS + APP_LIST)
+ if CONF_ADDITIONAL_CONFIGS in device_config.get(CONF_APPS, {}):
+ assert attr["source_list"].count(CURRENT_APP) == 1
+
for app_to_remove in INPUT_APPS:
if app_to_remove in list_to_test:
list_to_test.remove(app_to_remove)
assert attr["source_list"] == list_to_test
- assert app in attr["source_list"] or app == UNKNOWN_APP
- if app == UNKNOWN_APP:
- assert attr["source"] == ADDITIONAL_APP_CONFIG["name"]
- else:
+
+ if app:
+ assert app in attr["source_list"] or app == UNKNOWN_APP
assert attr["source"] == app
+ assert attr["app_name"] == app
+ if app == UNKNOWN_APP:
+ assert attr["app_id"] == app_config
+ else:
+ assert "app_id" not in attr
+ else:
+ assert attr["source"] == "CAST"
+ assert "app_id" not in attr
+ assert "app_name" not in attr
+
assert (
attr["volume_level"]
== float(int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2))
@@ -219,7 +233,7 @@ async def _test_service(
hass: HomeAssistantType,
vizio_func_name: str,
ha_service_name: str,
- additional_service_data: dict,
+ additional_service_data: Optional[Dict[str, Any]],
*args,
**kwargs,
) -> None:
@@ -360,8 +374,8 @@ async def test_options_update(
async def _test_update_availability_switch(
hass: HomeAssistantType,
- initial_power_state: bool,
- final_power_state: bool,
+ initial_power_state: Optional[bool],
+ final_power_state: Optional[bool],
caplog: pytest.fixture,
) -> None:
now = dt_util.utcnow()
@@ -428,7 +442,9 @@ async def test_setup_with_apps(
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps."""
- await _test_setup_with_apps(hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP)
+ await _test_setup_with_apps(
+ hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP, CURRENT_APP_CONFIG
+ )
await _test_service(
hass,
"launch_app",
@@ -445,7 +461,9 @@ async def test_setup_with_apps_include(
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps and apps["include"] in config."""
- await _test_setup_with_apps(hass, MOCK_TV_WITH_INCLUDE_CONFIG, CURRENT_APP)
+ await _test_setup_with_apps(
+ hass, MOCK_TV_WITH_INCLUDE_CONFIG, CURRENT_APP, CURRENT_APP_CONFIG
+ )
async def test_setup_with_apps_exclude(
@@ -455,7 +473,9 @@ async def test_setup_with_apps_exclude(
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps and apps["exclude"] in config."""
- await _test_setup_with_apps(hass, MOCK_TV_WITH_EXCLUDE_CONFIG, CURRENT_APP)
+ await _test_setup_with_apps(
+ hass, MOCK_TV_WITH_EXCLUDE_CONFIG, CURRENT_APP, CURRENT_APP_CONFIG
+ )
async def test_setup_with_apps_additional_apps_config(
@@ -465,20 +485,25 @@ async def test_setup_with_apps_additional_apps_config(
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps and apps["additional_configs"] in config."""
- await _test_setup_with_apps(hass, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, UNKNOWN_APP)
+ await _test_setup_with_apps(
+ hass,
+ MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG,
+ ADDITIONAL_APP_CONFIG["name"],
+ ADDITIONAL_APP_CONFIG["config"],
+ )
await _test_service(
hass,
"launch_app",
SERVICE_SELECT_SOURCE,
- {ATTR_INPUT_SOURCE: CURRENT_APP},
- CURRENT_APP,
+ {ATTR_INPUT_SOURCE: "Netflix"},
+ "Netflix",
)
await _test_service(
hass,
"launch_app_config",
SERVICE_SELECT_SOURCE,
- {ATTR_INPUT_SOURCE: CUSTOM_APP_NAME},
+ {ATTR_INPUT_SOURCE: CURRENT_APP},
**CUSTOM_CONFIG,
)
@@ -505,3 +530,27 @@ def test_invalid_apps_config(hass: HomeAssistantType):
with raises(vol.Invalid):
vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_SPEAKER_APPS_FAILURE)
+
+
+async def test_setup_with_unknown_app_config(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test device setup with apps where app config returned is unknown."""
+ await _test_setup_with_apps(
+ hass, MOCK_USER_VALID_TV_CONFIG, UNKNOWN_APP, UNKNOWN_APP_CONFIG
+ )
+
+
+async def test_setup_with_no_running_app(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test device setup with apps where no app is running."""
+ await _test_setup_with_apps(
+ hass, MOCK_USER_VALID_TV_CONFIG, None, vars(AppConfig())
+ )
diff --git a/tests/components/wled/__init__.py b/tests/components/wled/__init__.py
index 41cbbf01074..f6bd0643450 100644
--- a/tests/components/wled/__init__.py
+++ b/tests/components/wled/__init__.py
@@ -18,19 +18,31 @@ async def init_integration(
fixture = "wled/rgb.json" if not rgbw else "wled/rgbw.json"
aioclient_mock.get(
- "http://example.local:80/json/",
+ "http://192.168.1.123:80/json/",
text=load_fixture(fixture),
headers={"Content-Type": "application/json"},
)
aioclient_mock.post(
- "http://example.local:80/json/state",
- json={"success": True},
+ "http://192.168.1.123:80/json/state",
+ json={},
+ headers={"Content-Type": "application/json"},
+ )
+
+ aioclient_mock.get(
+ "http://192.168.1.123:80/json/info",
+ json={},
+ headers={"Content-Type": "application/json"},
+ )
+
+ aioclient_mock.get(
+ "http://192.168.1.123:80/json/state",
+ json={},
headers={"Content-Type": "application/json"},
)
entry = MockConfigEntry(
- domain=DOMAIN, data={CONF_HOST: "example.local", CONF_MAC: "aabbccddeeff"}
+ domain=DOMAIN, data={CONF_HOST: "192.168.1.123", CONF_MAC: "aabbccddeeff"}
)
entry.add_to_hass(hass)
diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py
index 4a43706dde2..6de14a024d4 100644
--- a/tests/components/wled/test_config_flow.py
+++ b/tests/components/wled/test_config_flow.py
@@ -40,7 +40,7 @@ async def test_show_zerconf_form(
) -> None:
"""Test that the zeroconf confirmation form is served."""
aioclient_mock.get(
- "http://example.local:80/json/",
+ "http://192.168.1.123:80/json/",
text=load_fixture("wled/rgb.json"),
headers={"Content-Type": "application/json"},
)
@@ -48,9 +48,11 @@ async def test_show_zerconf_form(
flow = config_flow.WLEDFlowHandler()
flow.hass = hass
flow.context = {"source": SOURCE_ZEROCONF}
- result = await flow.async_step_zeroconf({"hostname": "example.local."})
+ result = await flow.async_step_zeroconf(
+ {"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}
+ )
- assert flow.context[CONF_HOST] == "example.local"
+ assert flow.context[CONF_HOST] == "192.168.1.123"
assert flow.context[CONF_NAME] == "example"
assert result["description_placeholders"] == {CONF_NAME: "example"}
assert result["step_id"] == "zeroconf_confirm"
@@ -78,12 +80,12 @@ async def test_zeroconf_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on WLED connection error."""
- aioclient_mock.get("http://example.local/json/", exc=aiohttp.ClientError)
+ aioclient_mock.get("http://192.168.1.123/json/", exc=aiohttp.ClientError)
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data={"hostname": "example.local."},
+ data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}},
)
assert result["reason"] == "connection_error"
@@ -94,7 +96,7 @@ async def test_zeroconf_confirm_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on WLED connection error."""
- aioclient_mock.get("http://example.com/json/", exc=aiohttp.ClientError)
+ aioclient_mock.get("http://192.168.1.123:80/json/", exc=aiohttp.ClientError)
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
@@ -103,7 +105,7 @@ async def test_zeroconf_confirm_connection_error(
CONF_HOST: "example.com",
CONF_NAME: "test",
},
- data={"hostname": "example.com."},
+ data={"host": "192.168.1.123", "hostname": "example.com.", "properties": {}},
)
assert result["reason"] == "connection_error"
@@ -131,7 +133,7 @@ async def test_user_device_exists_abort(
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": SOURCE_USER},
- data={CONF_HOST: "example.local"},
+ data={CONF_HOST: "192.168.1.123"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
@@ -147,7 +149,27 @@ async def test_zeroconf_device_exists_abort(
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={"source": SOURCE_ZEROCONF},
- data={"hostname": "example.local."},
+ data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}},
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_zeroconf_with_mac_device_exists_abort(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test we abort zeroconf flow if WLED device already configured."""
+ await init_integration(hass, aioclient_mock)
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "192.168.1.123",
+ "hostname": "example.local.",
+ "properties": {CONF_MAC: "aabbccddeeff"},
+ },
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
@@ -159,7 +181,7 @@ async def test_full_user_flow_implementation(
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.get(
- "http://example.local:80/json/",
+ "http://192.168.1.123:80/json/",
text=load_fixture("wled/rgb.json"),
headers={"Content-Type": "application/json"},
)
@@ -172,12 +194,12 @@ async def test_full_user_flow_implementation(
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_HOST: "example.local"}
+ result["flow_id"], user_input={CONF_HOST: "192.168.1.123"}
)
- assert result["data"][CONF_HOST] == "example.local"
+ assert result["data"][CONF_HOST] == "192.168.1.123"
assert result["data"][CONF_MAC] == "aabbccddeeff"
- assert result["title"] == "example.local"
+ assert result["title"] == "192.168.1.123"
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -186,7 +208,7 @@ async def test_full_zeroconf_flow_implementation(
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.get(
- "http://example.local:80/json/",
+ "http://192.168.1.123:80/json/",
text=load_fixture("wled/rgb.json"),
headers={"Content-Type": "application/json"},
)
@@ -194,18 +216,18 @@ async def test_full_zeroconf_flow_implementation(
flow = config_flow.WLEDFlowHandler()
flow.hass = hass
flow.context = {"source": SOURCE_ZEROCONF}
- result = await flow.async_step_zeroconf({"hostname": "example.local."})
+ result = await flow.async_step_zeroconf(
+ {"host": "192.168.1.123", "hostname": "example.local.", "properties": {}}
+ )
- assert flow.context[CONF_HOST] == "example.local"
+ assert flow.context[CONF_HOST] == "192.168.1.123"
assert flow.context[CONF_NAME] == "example"
assert result["description_placeholders"] == {CONF_NAME: "example"}
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- result = await flow.async_step_zeroconf_confirm(
- user_input={CONF_HOST: "example.local"}
- )
- assert result["data"][CONF_HOST] == "example.local"
+ result = await flow.async_step_zeroconf_confirm(user_input={})
+ assert result["data"][CONF_HOST] == "192.168.1.123"
assert result["data"][CONF_MAC] == "aabbccddeeff"
assert result["title"] == "example"
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py
index 723f96db00d..053c5ebaca0 100644
--- a/tests/components/wled/test_init.py
+++ b/tests/components/wled/test_init.py
@@ -1,10 +1,8 @@
"""Tests for the WLED integration."""
import aiohttp
-from asynctest import patch
from homeassistant.components.wled.const import DOMAIN
from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY
-from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from tests.components.wled import init_integration
@@ -15,7 +13,7 @@ async def test_config_entry_not_ready(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the WLED configuration entry not ready."""
- aioclient_mock.get("http://example.local:80/json/", exc=aiohttp.ClientError)
+ aioclient_mock.get("http://192.168.1.123:80/json/", exc=aiohttp.ClientError)
entry = await init_integration(hass, aioclient_mock)
assert entry.state == ENTRY_STATE_SETUP_RETRY
@@ -39,30 +37,3 @@ async def test_setting_unique_id(hass, aioclient_mock):
assert hass.data[DOMAIN]
assert entry.unique_id == "aabbccddeeff"
-
-
-async def test_interval_update(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
-) -> None:
- """Test the WLED configuration entry unloading."""
- entry = await init_integration(hass, aioclient_mock, skip_setup=True)
-
- interval_action = False
-
- def async_track_time_interval(hass, action, interval):
- nonlocal interval_action
- interval_action = action
-
- with patch(
- "homeassistant.components.wled.async_track_time_interval",
- new=async_track_time_interval,
- ):
- await hass.config_entries.async_setup(entry.entry_id)
- await hass.async_block_till_done()
-
- assert interval_action
- await interval_action() # pylint: disable=not-callable
- await hass.async_block_till_done()
-
- state = hass.states.get("light.wled_rgb_light")
- assert state.state == STATE_ON
diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py
index 3c439e71c90..0009677cf18 100644
--- a/tests/components/wled/test_light.py
+++ b/tests/components/wled/test_light.py
@@ -1,5 +1,6 @@
"""Tests for the WLED light platform."""
import aiohttp
+from asynctest.mock import patch
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -16,14 +17,16 @@ from homeassistant.components.wled.const import (
ATTR_PALETTE,
ATTR_PLAYLIST,
ATTR_PRESET,
+ ATTR_REVERSE,
ATTR_SPEED,
+ DOMAIN,
+ SERVICE_EFFECT,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ICON,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
- STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
@@ -52,6 +55,7 @@ async def test_rgb_light_state(
assert state.attributes.get(ATTR_PALETTE) == "Default"
assert state.attributes.get(ATTR_PLAYLIST) is None
assert state.attributes.get(ATTR_PRESET) is None
+ assert state.attributes.get(ATTR_REVERSE) is False
assert state.attributes.get(ATTR_SPEED) == 32
assert state.state == STATE_ON
@@ -70,6 +74,7 @@ async def test_rgb_light_state(
assert state.attributes.get(ATTR_PALETTE) == "Random Cycle"
assert state.attributes.get(ATTR_PLAYLIST) is None
assert state.attributes.get(ATTR_PRESET) is None
+ assert state.attributes.get(ATTR_REVERSE) is False
assert state.attributes.get(ATTR_SPEED) == 16
assert state.state == STATE_ON
@@ -79,82 +84,98 @@ async def test_rgb_light_state(
async def test_switch_change_state(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
) -> None:
"""Test the change of state of the WLED switches."""
await init_integration(hass, aioclient_mock)
- state = hass.states.get("light.wled_rgb_light")
- assert state.state == STATE_ON
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_TRANSITION: 5},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ on=False, segment_id=0, transition=50,
+ )
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_TRANSITION: 5},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("light.wled_rgb_light")
- assert state.state == STATE_OFF
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {
+ ATTR_BRIGHTNESS: 42,
+ ATTR_EFFECT: "Chase",
+ ATTR_ENTITY_ID: "light.wled_rgb_light",
+ ATTR_RGB_COLOR: [255, 0, 0],
+ ATTR_TRANSITION: 5,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ brightness=42,
+ color_primary=(255, 0, 0),
+ effect="Chase",
+ on=True,
+ segment_id=0,
+ transition=50,
+ )
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {
- ATTR_BRIGHTNESS: 42,
- ATTR_EFFECT: "Chase",
- ATTR_ENTITY_ID: "light.wled_rgb_light",
- ATTR_RGB_COLOR: [255, 0, 0],
- ATTR_TRANSITION: 5,
- },
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("light.wled_rgb_light")
- assert state.state == STATE_ON
- assert state.attributes.get(ATTR_BRIGHTNESS) == 42
- assert state.attributes.get(ATTR_EFFECT) == "Chase"
- assert state.attributes.get(ATTR_HS_COLOR) == (0.0, 100.0)
-
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_COLOR_TEMP: 400},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("light.wled_rgb_light")
- assert state.state == STATE_ON
- assert state.attributes.get(ATTR_HS_COLOR) == (28.874, 72.522)
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_COLOR_TEMP: 400},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ color_primary=(255, 159, 70), on=True, segment_id=0,
+ )
async def test_light_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
+) -> None:
+ """Test error handling of the WLED lights."""
+ aioclient_mock.post("http://192.168.1.123:80/json/state", text="", status=400)
+ await init_integration(hass, aioclient_mock)
+
+ with patch("homeassistant.components.wled.WLEDDataUpdateCoordinator.async_refresh"):
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.wled_rgb_light"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wled_rgb_light")
+ assert state.state == STATE_ON
+ assert "Invalid response from API" in caplog.text
+
+
+async def test_light_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test error handling of the WLED switches."""
- aioclient_mock.post("http://example.local:80/json/state", exc=aiohttp.ClientError)
+ aioclient_mock.post("http://192.168.1.123:80/json/state", exc=aiohttp.ClientError)
await init_integration(hass, aioclient_mock)
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: "light.wled_rgb_light"},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("light.wled_rgb_light")
- assert state.state == STATE_UNAVAILABLE
+ with patch("homeassistant.components.wled.WLEDDataUpdateCoordinator.async_refresh"):
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.wled_rgb_light"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "light.wled_rgb_light_1"},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("light.wled_rgb_light_1")
- assert state.state == STATE_UNAVAILABLE
+ state = hass.states.get("light.wled_rgb_light")
+ assert state.state == STATE_UNAVAILABLE
async def test_rgbw_light(
@@ -168,45 +189,168 @@ async def test_rgbw_light(
assert state.attributes.get(ATTR_HS_COLOR) == (0.0, 100.0)
assert state.attributes.get(ATTR_WHITE_VALUE) == 139
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "light.wled_rgbw_light", ATTR_COLOR_TEMP: 400},
- blocking=True,
- )
- await hass.async_block_till_done()
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.wled_rgbw_light", ATTR_COLOR_TEMP: 400},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ on=True, segment_id=0, color_primary=(255, 159, 70, 139),
+ )
- state = hass.states.get("light.wled_rgbw_light")
- assert state.state == STATE_ON
- assert state.attributes.get(ATTR_HS_COLOR) == (28.874, 72.522)
- assert state.attributes.get(ATTR_WHITE_VALUE) == 139
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.wled_rgbw_light", ATTR_WHITE_VALUE: 100},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ color_primary=(255, 0, 0, 100), on=True, segment_id=0,
+ )
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "light.wled_rgbw_light", ATTR_WHITE_VALUE: 100},
- blocking=True,
- )
- await hass.async_block_till_done()
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {
+ ATTR_ENTITY_ID: "light.wled_rgbw_light",
+ ATTR_RGB_COLOR: (255, 255, 255),
+ ATTR_WHITE_VALUE: 100,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ color_primary=(0, 0, 0, 100), on=True, segment_id=0,
+ )
- state = hass.states.get("light.wled_rgbw_light")
- assert state.state == STATE_ON
- assert state.attributes.get(ATTR_HS_COLOR) == (28.874, 72.522)
- assert state.attributes.get(ATTR_WHITE_VALUE) == 100
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON,
- {
- ATTR_ENTITY_ID: "light.wled_rgbw_light",
- ATTR_RGB_COLOR: (255, 255, 255),
- ATTR_WHITE_VALUE: 100,
- },
- blocking=True,
- )
- await hass.async_block_till_done()
+async def test_effect_service(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test the effect service of a WLED light."""
+ await init_integration(hass, aioclient_mock)
- state = hass.states.get("light.wled_rgbw_light")
- assert state.state == STATE_ON
- assert state.attributes.get(ATTR_HS_COLOR) == (0, 0)
- assert state.attributes.get(ATTR_WHITE_VALUE) == 100
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {
+ ATTR_EFFECT: "Rainbow",
+ ATTR_ENTITY_ID: "light.wled_rgb_light",
+ ATTR_INTENSITY: 200,
+ ATTR_REVERSE: True,
+ ATTR_SPEED: 100,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ effect="Rainbow", intensity=200, reverse=True, segment_id=0, speed=100,
+ )
+
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_EFFECT: 9},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ segment_id=0, effect=9,
+ )
+
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {
+ ATTR_ENTITY_ID: "light.wled_rgb_light",
+ ATTR_INTENSITY: 200,
+ ATTR_REVERSE: True,
+ ATTR_SPEED: 100,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ intensity=200, reverse=True, segment_id=0, speed=100,
+ )
+
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {
+ ATTR_EFFECT: "Rainbow",
+ ATTR_ENTITY_ID: "light.wled_rgb_light",
+ ATTR_REVERSE: True,
+ ATTR_SPEED: 100,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ effect="Rainbow", reverse=True, segment_id=0, speed=100,
+ )
+
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {
+ ATTR_EFFECT: "Rainbow",
+ ATTR_ENTITY_ID: "light.wled_rgb_light",
+ ATTR_INTENSITY: 200,
+ ATTR_SPEED: 100,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ effect="Rainbow", intensity=200, segment_id=0, speed=100,
+ )
+
+ with patch("wled.WLED.light") as light_mock:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {
+ ATTR_EFFECT: "Rainbow",
+ ATTR_ENTITY_ID: "light.wled_rgb_light",
+ ATTR_INTENSITY: 200,
+ ATTR_REVERSE: True,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ light_mock.assert_called_once_with(
+ effect="Rainbow", intensity=200, reverse=True, segment_id=0,
+ )
+
+
+async def test_effect_service_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
+) -> None:
+ """Test error handling of the WLED effect service."""
+ aioclient_mock.post("http://192.168.1.123:80/json/state", text="", status=400)
+ await init_integration(hass, aioclient_mock)
+
+ with patch("homeassistant.components.wled.WLEDDataUpdateCoordinator.async_refresh"):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_EFFECT,
+ {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_EFFECT: 9},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wled_rgb_light")
+ assert state.state == STATE_ON
+ assert "Invalid response from API" in caplog.text
diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py
index 894968f5db4..d77bd99b97c 100644
--- a/tests/components/wled/test_sensor.py
+++ b/tests/components/wled/test_sensor.py
@@ -2,6 +2,7 @@
from datetime import datetime
from asynctest import patch
+import pytest
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.wled.const import (
@@ -9,8 +10,14 @@ from homeassistant.components.wled.const import (
ATTR_MAX_POWER,
CURRENT_MA,
DOMAIN,
+ SIGNAL_DBM,
+)
+from homeassistant.const import (
+ ATTR_ICON,
+ ATTR_UNIT_OF_MEASUREMENT,
+ DATA_BYTES,
+ UNIT_PERCENTAGE,
)
-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
@@ -43,6 +50,38 @@ async def test_sensors(
disabled_by=None,
)
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "aabbccddeeff_wifi_signal",
+ suggested_object_id="wled_rgb_light_wifi_signal",
+ disabled_by=None,
+ )
+
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "aabbccddeeff_wifi_rssi",
+ suggested_object_id="wled_rgb_light_wifi_rssi",
+ disabled_by=None,
+ )
+
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "aabbccddeeff_wifi_channel",
+ suggested_object_id="wled_rgb_light_wifi_channel",
+ disabled_by=None,
+ )
+
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "aabbccddeeff_wifi_bssid",
+ suggested_object_id="wled_rgb_light_wifi_bssid",
+ disabled_by=None,
+ )
+
# Setup
test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=dt_util.UTC)
with patch("homeassistant.components.wled.sensor.utcnow", return_value=test_time):
@@ -81,26 +120,70 @@ async def test_sensors(
assert entry
assert entry.unique_id == "aabbccddeeff_free_heap"
+ state = hass.states.get("sensor.wled_rgb_light_wifi_signal")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:wifi"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.state == "76"
+ entry = registry.async_get("sensor.wled_rgb_light_wifi_signal")
+ assert entry
+ assert entry.unique_id == "aabbccddeeff_wifi_signal"
+
+ state = hass.states.get("sensor.wled_rgb_light_wifi_rssi")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:wifi"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_DBM
+ assert state.state == "-62"
+
+ entry = registry.async_get("sensor.wled_rgb_light_wifi_rssi")
+ assert entry
+ assert entry.unique_id == "aabbccddeeff_wifi_rssi"
+
+ state = hass.states.get("sensor.wled_rgb_light_wifi_channel")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:wifi"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
+ assert state.state == "11"
+
+ entry = registry.async_get("sensor.wled_rgb_light_wifi_channel")
+ assert entry
+ assert entry.unique_id == "aabbccddeeff_wifi_channel"
+
+ state = hass.states.get("sensor.wled_rgb_light_wifi_bssid")
+ assert state
+ assert state.attributes.get(ATTR_ICON) == "mdi:wifi"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
+ assert state.state == "AA:AA:AA:AA:AA:BB"
+
+ entry = registry.async_get("sensor.wled_rgb_light_wifi_bssid")
+ assert entry
+ assert entry.unique_id == "aabbccddeeff_wifi_bssid"
+
+
+@pytest.mark.parametrize(
+ "entity_id",
+ (
+ "sensor.wled_rgb_light_uptime",
+ "sensor.wled_rgb_light_free_memory",
+ "sensor.wled_rgb_light_wi_fi_signal",
+ "sensor.wled_rgb_light_wi_fi_rssi",
+ "sensor.wled_rgb_light_wi_fi_channel",
+ "sensor.wled_rgb_light_wi_fi_bssid",
+ ),
+)
async def test_disabled_by_default_sensors(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, entity_id: str
) -> None:
"""Test the disabled by default WLED sensors."""
await init_integration(hass, aioclient_mock)
registry = await hass.helpers.entity_registry.async_get_registry()
+ print(registry.entities)
- state = hass.states.get("sensor.wled_rgb_light_uptime")
+ state = hass.states.get(entity_id)
assert state is None
- entry = registry.async_get("sensor.wled_rgb_light_uptime")
- assert entry
- assert entry.disabled
- assert entry.disabled_by == "integration"
-
- state = hass.states.get("sensor.wled_rgb_light_free_memory")
- assert state is None
-
- entry = registry.async_get("sensor.wled_rgb_light_free_memory")
+ entry = registry.async_get(entity_id)
assert entry
assert entry.disabled
assert entry.disabled_by == "integration"
diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py
index 2dc11801712..d140953b948 100644
--- a/tests/components/wled/test_switch.py
+++ b/tests/components/wled/test_switch.py
@@ -1,5 +1,6 @@
"""Tests for the WLED switch platform."""
import aiohttp
+from asynctest.mock import patch
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.wled.const import (
@@ -71,117 +72,105 @@ async def test_switch_change_state(
await init_integration(hass, aioclient_mock)
# Nightlight
- state = hass.states.get("switch.wled_rgb_light_nightlight")
- assert state.state == STATE_OFF
+ with patch("wled.WLED.nightlight") as nightlight_mock:
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ nightlight_mock.assert_called_once_with(on=True)
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.wled_rgb_light_nightlight")
- assert state.state == STATE_ON
-
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.wled_rgb_light_nightlight")
- assert state.state == STATE_OFF
+ with patch("wled.WLED.nightlight") as nightlight_mock:
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ nightlight_mock.assert_called_once_with(on=False)
# Sync send
- state = hass.states.get("switch.wled_rgb_light_sync_send")
- assert state.state == STATE_OFF
+ with patch("wled.WLED.sync") as sync_mock:
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ sync_mock.assert_called_once_with(send=True)
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.wled_rgb_light_sync_send")
- assert state.state == STATE_ON
-
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.wled_rgb_light_sync_send")
- assert state.state == STATE_OFF
+ with patch("wled.WLED.sync") as sync_mock:
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ sync_mock.assert_called_once_with(send=False)
# Sync receive
- state = hass.states.get("switch.wled_rgb_light_sync_receive")
- assert state.state == STATE_ON
+ with patch("wled.WLED.sync") as sync_mock:
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ sync_mock.assert_called_once_with(receive=False)
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.wled_rgb_light_sync_receive")
- assert state.state == STATE_OFF
-
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.wled_rgb_light_sync_receive")
- assert state.state == STATE_ON
+ with patch("wled.WLED.sync") as sync_mock:
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ sync_mock.assert_called_once_with(receive=True)
async def test_switch_error(
+ hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
+) -> None:
+ """Test error handling of the WLED switches."""
+ aioclient_mock.post("http://192.168.1.123:80/json/state", text="", status=400)
+ await init_integration(hass, aioclient_mock)
+
+ with patch("homeassistant.components.wled.WLEDDataUpdateCoordinator.async_refresh"):
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("switch.wled_rgb_light_nightlight")
+ assert state.state == STATE_OFF
+ assert "Invalid response from API" in caplog.text
+
+
+async def test_switch_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test error handling of the WLED switches."""
- aioclient_mock.post("http://example.local:80/json/state", exc=aiohttp.ClientError)
+ aioclient_mock.post("http://192.168.1.123:80/json/state", exc=aiohttp.ClientError)
await init_integration(hass, aioclient_mock)
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("switch.wled_rgb_light_nightlight")
- assert state.state == STATE_UNAVAILABLE
+ with patch("homeassistant.components.wled.WLEDDataUpdateCoordinator.async_refresh"):
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("switch.wled_rgb_light_sync_send")
- assert state.state == STATE_UNAVAILABLE
-
- await hass.services.async_call(
- SWITCH_DOMAIN,
- SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"},
- blocking=True,
- )
- await hass.async_block_till_done()
- state = hass.states.get("switch.wled_rgb_light_sync_receive")
- assert state.state == STATE_UNAVAILABLE
+ state = hass.states.get("switch.wled_rgb_light_nightlight")
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py
index c5790dc718c..4e086978be1 100644
--- a/tests/components/zeroconf/test_init.py
+++ b/tests/components/zeroconf/test_init.py
@@ -62,10 +62,13 @@ async def test_setup(hass, mock_zeroconf):
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF)
- assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2
+ expected_flow_calls = 0
+ for matching_components in zc_gen.ZEROCONF.values():
+ expected_flow_calls += len(matching_components)
+ assert len(mock_config_flow.mock_calls) == expected_flow_calls * 2
-async def test_homekit_match_partial(hass, mock_zeroconf):
+async def test_homekit_match_partial_space(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry."""
with patch.dict(
zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
@@ -80,6 +83,23 @@ async def test_homekit_match_partial(hass, mock_zeroconf):
assert mock_config_flow.mock_calls[0][1][0] == "lifx"
+async def test_homekit_match_partial_dash(hass, mock_zeroconf):
+ """Test configured options for a device are loaded via config entry."""
+ with patch.dict(
+ zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ ), patch.object(hass.config_entries, "flow") as mock_config_flow, patch.object(
+ zeroconf, "ServiceBrowser", side_effect=service_update_mock
+ ) as mock_service_browser:
+ mock_zeroconf.get_service_info.side_effect = get_homekit_info_mock(
+ "Rachio-fa46ba"
+ )
+ assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+
+ assert len(mock_service_browser.mock_calls) == 1
+ assert len(mock_config_flow.mock_calls) == 2
+ assert mock_config_flow.mock_calls[0][1][0] == "rachio"
+
+
async def test_homekit_match_full(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry."""
with patch.dict(
diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py
index 3753136d59d..2f0966ae739 100644
--- a/tests/components/zha/common.py
+++ b/tests/components/zha/common.py
@@ -3,6 +3,8 @@ import time
from unittest.mock import Mock
from asynctest import CoroutineMock
+from zigpy.device import Device as zigpy_dev
+from zigpy.endpoint import Endpoint as zigpy_ep
import zigpy.profiles.zha
import zigpy.types
import zigpy.zcl
@@ -24,6 +26,7 @@ class FakeEndpoint:
self.in_clusters = {}
self.out_clusters = {}
self._cluster_attr = {}
+ self.member_of = {}
self.status = 1
self.manufacturer = manufacturer
self.model = model
@@ -45,6 +48,19 @@ class FakeEndpoint:
patch_cluster(cluster)
self.out_clusters[cluster_id] = cluster
+ @property
+ def __class__(self):
+ """Fake being Zigpy endpoint."""
+ return zigpy_ep
+
+ @property
+ def unique_id(self):
+ """Return the unique id for the endpoint."""
+ return self.device.ieee, self.endpoint_id
+
+
+FakeEndpoint.add_to_group = zigpy_ep.add_to_group
+
def patch_cluster(cluster):
"""Patch a cluster for testing."""
@@ -56,17 +72,19 @@ def patch_cluster(cluster):
cluster.read_attributes_raw = Mock()
cluster.unbind = CoroutineMock(return_value=[0])
cluster.write_attributes = CoroutineMock(return_value=[0])
+ if cluster.cluster_id == 4:
+ cluster.add = CoroutineMock(return_value=[0])
class FakeDevice:
"""Fake device for mocking zigpy."""
- def __init__(self, app, ieee, manufacturer, model, node_desc=None):
+ def __init__(self, app, ieee, manufacturer, model, node_desc=None, nwk=0xB79C):
"""Init fake device."""
self._application = app
self.application = app
self.ieee = zigpy.types.EUI64.convert(ieee)
- self.nwk = 0xB79C
+ self.nwk = nwk
self.zdo = Mock()
self.endpoints = {0: self.zdo}
self.lqi = 255
@@ -78,13 +96,15 @@ class FakeDevice:
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]
+FakeDevice.add_to_group = zigpy_dev.add_to_group
+
+
def get_zha_gateway(hass):
"""Return ZHA gateway from hass.data."""
try:
@@ -137,6 +157,17 @@ async def find_entity_id(domain, zha_device, hass):
return None
+def async_find_group_entity_id(hass, domain, group):
+ """Find the group entity id under test."""
+ entity_id = f"{domain}.{group.name.lower().replace(' ','_')}_zha_group_0x{group.group_id:04x}"
+
+ entity_ids = hass.states.async_entity_ids(domain)
+
+ if entity_id in entity_ids:
+ return entity_id
+ return None
+
+
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:
diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py
index e6056428db6..b83db53533c 100644
--- a/tests/components/zha/conftest.py
+++ b/tests/components/zha/conftest.py
@@ -110,10 +110,11 @@ def zigpy_device_mock(zigpy_app_controller):
manufacturer="FakeManufacturer",
model="FakeModel",
node_descriptor=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ nwk=0xB79C,
):
"""Make a fake device using the specified cluster classes."""
device = FakeDevice(
- zigpy_app_controller, ieee, manufacturer, model, node_descriptor
+ zigpy_app_controller, ieee, manufacturer, model, node_descriptor, nwk=nwk
)
for epid, ep in endpoints.items():
endpoint = FakeEndpoint(manufacturer, model, epid)
diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py
index ec9c172430c..1196cdc3b40 100644
--- a/tests/components/zha/test_channels.py
+++ b/tests/components/zha/test_channels.py
@@ -274,7 +274,9 @@ def test_epch_claim_channels(channel):
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.channels.ChannelPool.add_client_channels"
+)
@mock.patch(
"homeassistant.components.zha.core.discovery.PROBE.discover_entities",
mock.MagicMock(),
@@ -319,7 +321,9 @@ def test_ep_channels_all_channels(m1, zha_device_mock):
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.channels.ChannelPool.add_client_channels"
+)
@mock.patch(
"homeassistant.components.zha.core.discovery.PROBE.discover_entities",
mock.MagicMock(),
@@ -387,14 +391,14 @@ async def test_ep_channels_configure(channel):
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}
+ client_chans = {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):
+ with mock.patch.dict(ep_channels.client_channels, client_chans, clear=True):
await ep_channels.async_configure()
await ep_channels.async_initialize(mock.sentinel.from_cache)
- for ch in [*claimed.values(), *relay.values()]:
+ for ch in [*claimed.values(), *client_chans.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
diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py
index edfab1d11d1..c92f574825d 100644
--- a/tests/components/zha/test_device.py
+++ b/tests/components/zha/test_device.py
@@ -8,9 +8,10 @@ import pytest
import zigpy.zcl.clusters.general as general
import homeassistant.components.zha.core.device as zha_core_device
+import homeassistant.helpers.device_registry as ha_dev_reg
import homeassistant.util.dt as dt_util
-from .common import async_enable_traffic
+from .common import async_enable_traffic, make_zcl_header
from tests.common import async_fire_time_changed
@@ -63,6 +64,26 @@ def device_without_basic_channel(zigpy_device):
return zigpy_device(with_basic_channel=False)
+@pytest.fixture
+async def ota_zha_device(zha_device_restored, zigpy_device_mock):
+ """ZHA device with OTA cluster fixture."""
+ zigpy_dev = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.Basic.cluster_id],
+ "out_clusters": [general.Ota.cluster_id],
+ "device_type": 0x1234,
+ }
+ },
+ "00:11:22:33:44:55:66:77",
+ "test manufacturer",
+ "test model",
+ )
+
+ zha_device = await zha_device_restored(zigpy_dev)
+ return zha_device
+
+
def _send_time_changed(hass, seconds):
"""Send a time changed event."""
now = dt_util.utcnow() + timedelta(seconds=seconds)
@@ -190,3 +211,20 @@ async def test_check_available_no_basic_channel(
await hass.async_block_till_done()
assert zha_device.available is False
assert "does not have a mandatory basic cluster" in caplog.text
+
+
+async def test_ota_sw_version(hass, ota_zha_device):
+ """Test device entry gets sw_version updated via OTA channel."""
+
+ ota_ch = ota_zha_device.channels.pools[0].client_channels["1:0x0019"]
+ dev_registry = await ha_dev_reg.async_get_registry(hass)
+ entry = dev_registry.async_get(ota_zha_device.device_id)
+ assert entry.sw_version is None
+
+ cluster = ota_ch.cluster
+ hdr = make_zcl_header(1, global_command=False)
+ sw_version = 0x2345
+ cluster.handle_message(hdr, [1, 2, 3, sw_version, None])
+ await hass.async_block_till_done()
+ entry = dev_registry.async_get(ota_zha_device.device_id)
+ assert int(entry.sw_version, base=16) == sw_version
diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py
index c779dda6cf8..40e64934f89 100644
--- a/tests/components/zha/test_device_action.py
+++ b/tests/components/zha/test_device_action.py
@@ -103,7 +103,7 @@ async def test_action(hass, device_ias):
await hass.async_block_till_done()
calls = async_mock_service(hass, DOMAIN, "warning_device_warn")
- channel = zha_device.channels.pools[0].relay_channels["1:0x0006"]
+ channel = zha_device.channels.pools[0].client_channels["1:0x0006"]
channel.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py
index 9b69ba06e4f..266094963f2 100644
--- a/tests/components/zha/test_device_trigger.py
+++ b/tests/components/zha/test_device_trigger.py
@@ -172,7 +172,7 @@ async def test_if_fires_on_event(hass, mock_devices, calls):
await hass.async_block_till_done()
- channel = zha_device.channels.pools[0].relay_channels["1:0x0006"]
+ channel = zha_device.channels.pools[0].client_channels["1:0x0006"]
channel.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py
index e1733ac44bd..e58fd740655 100644
--- a/tests/components/zha/test_discover.py
+++ b/tests/components/zha/test_discover.py
@@ -111,7 +111,7 @@ async def test_devices(
)
event_channels = {
- ch.id for pool in zha_dev.channels.pools for ch in pool.relay_channels.values()
+ ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values()
}
entity_map = device["entity_map"]
@@ -266,7 +266,7 @@ async def test_discover_endpoint(device_info, channels_mock, hass):
)
assert device_info["event_channels"] == sorted(
- [ch.id for pool in channels.pools for ch in pool.relay_channels.values()]
+ [ch.id for pool in channels.pools for ch in pool.client_channels.values()]
)
assert new_ent.call_count == len(
[
diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py
index 5011a847a4e..399982df37a 100644
--- a/tests/components/zha/test_fan.py
+++ b/tests/components/zha/test_fan.py
@@ -2,10 +2,21 @@
from unittest.mock import call
import pytest
+import zigpy.profiles.zha as zha
+import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.hvac as hvac
from homeassistant.components import fan
-from homeassistant.components.fan import ATTR_SPEED, DOMAIN, SERVICE_SET_SPEED
+from homeassistant.components.fan import (
+ ATTR_SPEED,
+ DOMAIN,
+ SERVICE_SET_SPEED,
+ SPEED_HIGH,
+ SPEED_MEDIUM,
+ SPEED_OFF,
+)
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+from homeassistant.components.zha.core.discovery import GROUP_PROBE
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
@@ -17,11 +28,16 @@ from homeassistant.const import (
from .common import (
async_enable_traffic,
+ async_find_group_entity_id,
async_test_rejoin,
find_entity_id,
+ get_zha_gateway,
send_attributes_report,
)
+IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
+IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
+
@pytest.fixture
def zigpy_device(zigpy_device_mock):
@@ -32,6 +48,66 @@ def zigpy_device(zigpy_device_mock):
return zigpy_device_mock(endpoints)
+@pytest.fixture
+async def coordinator(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha fan platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee="00:15:8d:00:02:32:4f:32",
+ nwk=0x0000,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_fan_1(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha fan platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.OnOff.cluster_id, hvac.Fan.cluster_id],
+ "out_clusters": [],
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_fan_2(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha fan platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ hvac.Fan.cluster_id,
+ general.LevelControl.cluster_id,
+ ],
+ "out_clusters": [],
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE2,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
async def test_fan(hass, zha_device_joined_restored, zigpy_device):
"""Test zha fan platform."""
@@ -106,3 +182,87 @@ async def async_set_speed(hass, entity_id, speed=None):
}
await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True)
+
+
+async def async_test_zha_group_fan_entity(
+ hass, device_fan_1, device_fan_2, coordinator
+):
+ """Test the fan entity for a ZHA group."""
+ zha_gateway = get_zha_gateway(hass)
+ assert zha_gateway is not None
+ zha_gateway.coordinator_zha_device = coordinator
+ coordinator._zha_gateway = zha_gateway
+ device_fan_1._zha_gateway = zha_gateway
+ device_fan_2._zha_gateway = zha_gateway
+ member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee]
+
+ # test creating a group with 2 members
+ zha_group = await zha_gateway.async_create_zigpy_group(
+ "Test Group", member_ieee_addresses
+ )
+ await hass.async_block_till_done()
+
+ assert zha_group is not None
+ assert len(zha_group.members) == 2
+ for member in zha_group.members:
+ assert member.ieee in member_ieee_addresses
+
+ entity_domains = GROUP_PROBE.determine_entity_domains(zha_group)
+ assert len(entity_domains) == 2
+
+ assert LIGHT_DOMAIN in entity_domains
+ assert DOMAIN in entity_domains
+
+ entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group)
+ assert hass.states.get(entity_id) is not None
+
+ group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id]
+ dev1_fan_cluster = device_fan_1.endpoints[1].fan
+ dev2_fan_cluster = device_fan_2.endpoints[1].fan
+
+ # test that the lights were created and that they are unavailable
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_group.members)
+
+ # test that the fan group entity was created and is off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # turn on from HA
+ group_fan_cluster.write_attributes.reset_mock()
+ await async_turn_on(hass, entity_id)
+ assert len(group_fan_cluster.write_attributes.mock_calls) == 1
+ assert group_fan_cluster.write_attributes.call_args == call({"fan_mode": 2})
+ assert hass.states.get(entity_id).state == SPEED_MEDIUM
+
+ # turn off from HA
+ group_fan_cluster.write_attributes.reset_mock()
+ await async_turn_off(hass, entity_id)
+ assert len(group_fan_cluster.write_attributes.mock_calls) == 1
+ assert group_fan_cluster.write_attributes.call_args == call({"fan_mode": 0})
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # change speed from HA
+ group_fan_cluster.write_attributes.reset_mock()
+ await async_set_speed(hass, entity_id, speed=fan.SPEED_HIGH)
+ assert len(group_fan_cluster.write_attributes.mock_calls) == 1
+ assert group_fan_cluster.write_attributes.call_args == call({"fan_mode": 3})
+ assert hass.states.get(entity_id).state == SPEED_HIGH
+
+ # test some of the group logic to make sure we key off states correctly
+ await dev1_fan_cluster.async_set_speed(SPEED_OFF)
+ await dev2_fan_cluster.async_set_speed(SPEED_OFF)
+
+ # test that group fan is off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ await dev1_fan_cluster.async_set_speed(SPEED_MEDIUM)
+
+ # test that group fan is speed medium
+ assert hass.states.get(entity_id).state == SPEED_MEDIUM
+
+ await dev1_fan_cluster.async_set_speed(SPEED_OFF)
+
+ # test that group fan is now off
+ assert hass.states.get(entity_id).state == STATE_OFF
diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py
index 74aed6f5872..c5ae9142ff0 100644
--- a/tests/components/zha/test_gateway.py
+++ b/tests/components/zha/test_gateway.py
@@ -1,8 +1,19 @@
"""Test ZHA Gateway."""
-import pytest
-import zigpy.zcl.clusters.general as general
+import logging
+import time
-from .common import async_enable_traffic, get_zha_gateway
+import pytest
+import zigpy.profiles.zha as zha
+import zigpy.zcl.clusters.general as general
+import zigpy.zcl.clusters.lighting as lighting
+
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+
+from .common import async_enable_traffic, async_find_group_entity_id, get_zha_gateway
+
+IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
+IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
+_LOGGER = logging.getLogger(__name__)
@pytest.fixture
@@ -15,7 +26,7 @@ def zigpy_dev_basic(zigpy_device_mock):
"out_clusters": [],
"device_type": 0,
}
- },
+ }
)
@@ -27,6 +38,74 @@ async def zha_dev_basic(hass, zha_device_restored, zigpy_dev_basic):
return zha_device
+@pytest.fixture
+async def coordinator(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee="00:15:8d:00:02:32:4f:32",
+ nwk=0x0000,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_light_1(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ general.LevelControl.cluster_id,
+ lighting.Color.cluster_id,
+ general.Groups.cluster_id,
+ ],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_light_2(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ general.LevelControl.cluster_id,
+ lighting.Color.cluster_id,
+ general.Groups.cluster_id,
+ ],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE2,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
async def test_device_left(hass, zigpy_dev_basic, zha_dev_basic):
"""Device leaving the network should become unavailable."""
@@ -37,3 +116,92 @@ async def test_device_left(hass, zigpy_dev_basic, zha_dev_basic):
get_zha_gateway(hass).device_left(zigpy_dev_basic)
assert zha_dev_basic.available is False
+
+
+async def test_gateway_group_methods(hass, device_light_1, device_light_2, coordinator):
+ """Test creating a group with 2 members."""
+ zha_gateway = get_zha_gateway(hass)
+ assert zha_gateway is not None
+ zha_gateway.coordinator_zha_device = coordinator
+ coordinator._zha_gateway = zha_gateway
+ device_light_1._zha_gateway = zha_gateway
+ device_light_2._zha_gateway = zha_gateway
+ member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee]
+
+ # test creating a group with 2 members
+ zha_group = await zha_gateway.async_create_zigpy_group(
+ "Test Group", member_ieee_addresses
+ )
+ await hass.async_block_till_done()
+
+ assert zha_group is not None
+ assert len(zha_group.members) == 2
+ for member in zha_group.members:
+ assert member.ieee in member_ieee_addresses
+
+ entity_id = async_find_group_entity_id(hass, LIGHT_DOMAIN, zha_group)
+ assert hass.states.get(entity_id) is not None
+
+ # test get group by name
+ assert zha_group == zha_gateway.async_get_group_by_name(zha_group.name)
+
+ # test removing a group
+ await zha_gateway.async_remove_zigpy_group(zha_group.group_id)
+ await hass.async_block_till_done()
+
+ # we shouldn't have the group anymore
+ assert zha_gateway.async_get_group_by_name(zha_group.name) is None
+
+ # the group entity should be cleaned up
+ assert entity_id not in hass.states.async_entity_ids(LIGHT_DOMAIN)
+
+ # test creating a group with 1 member
+ zha_group = await zha_gateway.async_create_zigpy_group(
+ "Test Group", [device_light_1.ieee]
+ )
+ await hass.async_block_till_done()
+
+ assert zha_group is not None
+ assert len(zha_group.members) == 1
+ for member in zha_group.members:
+ assert member.ieee in [device_light_1.ieee]
+
+ # the group entity should not have been cleaned up
+ assert entity_id not in hass.states.async_entity_ids(LIGHT_DOMAIN)
+
+
+async def test_updating_device_store(hass, zigpy_dev_basic, zha_dev_basic):
+ """Test saving data after a delay."""
+ zha_gateway = get_zha_gateway(hass)
+ assert zha_gateway is not None
+ await async_enable_traffic(hass, [zha_dev_basic])
+
+ assert zha_dev_basic.last_seen is not None
+ entry = zha_gateway.zha_storage.async_get_or_create_device(zha_dev_basic)
+ assert entry.last_seen == zha_dev_basic.last_seen
+
+ assert zha_dev_basic.last_seen is not None
+ last_seen = zha_dev_basic.last_seen
+
+ # test that we can't set None as last seen any more
+ zha_dev_basic.async_update_last_seen(None)
+ assert last_seen == zha_dev_basic.last_seen
+
+ # test that we won't put None in storage
+ zigpy_dev_basic.last_seen = None
+ assert zha_dev_basic.last_seen is None
+ await zha_gateway.async_update_device_storage()
+ await hass.async_block_till_done()
+ entry = zha_gateway.zha_storage.async_get_or_create_device(zha_dev_basic)
+ assert entry.last_seen == last_seen
+
+ # test that we can still set a good last_seen
+ last_seen = time.time()
+ zha_dev_basic.async_update_last_seen(last_seen)
+ assert last_seen == zha_dev_basic.last_seen
+
+ # test that we still put good values in storage
+ await zha_gateway.async_update_device_storage()
+ await hass.async_block_till_done()
+ entry = zha_gateway.zha_storage.async_get_or_create_device(zha_dev_basic)
+ assert entry.last_seen == last_seen
diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py
index f27bd329bdb..9bdd4966a4a 100644
--- a/tests/components/zha/test_light.py
+++ b/tests/components/zha/test_light.py
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, call, sentinel
from asynctest import CoroutineMock, patch
import pytest
-import zigpy.profiles.zha
+import zigpy.profiles.zha as zha
import zigpy.types
import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.lighting as lighting
@@ -17,8 +17,10 @@ import homeassistant.util.dt as dt_util
from .common import (
async_enable_traffic,
+ async_find_group_entity_id,
async_test_rejoin,
find_entity_id,
+ get_zha_gateway,
send_attributes_report,
)
@@ -26,6 +28,9 @@ from tests.common import async_fire_time_changed
ON = 1
OFF = 0
+IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
+IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
+IEEE_GROUPABLE_DEVICE3 = "03:2d:6f:00:0a:90:69:e8"
LIGHT_ON_OFF = {
1: {
@@ -66,6 +71,101 @@ LIGHT_COLOR = {
}
+@pytest.fixture
+async def coordinator(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee="00:15:8d:00:02:32:4f:32",
+ nwk=0x0000,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_light_1(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ general.LevelControl.cluster_id,
+ lighting.Color.cluster_id,
+ general.Groups.cluster_id,
+ general.Identify.cluster_id,
+ ],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_light_2(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ general.LevelControl.cluster_id,
+ lighting.Color.cluster_id,
+ general.Groups.cluster_id,
+ general.Identify.cluster_id,
+ ],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE2,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_light_3(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [
+ general.OnOff.cluster_id,
+ general.LevelControl.cluster_id,
+ lighting.Color.cluster_id,
+ general.Groups.cluster_id,
+ general.Identify.cluster_id,
+ ],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE3,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
@patch("zigpy.zcl.clusters.general.OnOff.read_attributes", new=MagicMock())
async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored):
"""Test zha light platform refresh."""
@@ -337,3 +437,105 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash):
manufacturer=None,
tsn=None,
)
+
+
+async def async_test_zha_group_light_entity(
+ hass, device_light_1, device_light_2, device_light_3, coordinator
+):
+ """Test the light entity for a ZHA group."""
+ zha_gateway = get_zha_gateway(hass)
+ assert zha_gateway is not None
+ zha_gateway.coordinator_zha_device = coordinator
+ coordinator._zha_gateway = zha_gateway
+ device_light_1._zha_gateway = zha_gateway
+ device_light_2._zha_gateway = zha_gateway
+ member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee]
+
+ # test creating a group with 2 members
+ zha_group = await zha_gateway.async_create_zigpy_group(
+ "Test Group", member_ieee_addresses
+ )
+ await hass.async_block_till_done()
+
+ assert zha_group is not None
+ assert len(zha_group.members) == 2
+ for member in zha_group.members:
+ assert member.ieee in member_ieee_addresses
+
+ entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group)
+ assert hass.states.get(entity_id) is not None
+
+ group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id]
+ group_cluster_level = zha_group.endpoint[general.LevelControl.cluster_id]
+ group_cluster_identify = zha_group.endpoint[general.Identify.cluster_id]
+
+ dev1_cluster_on_off = device_light_1.endpoints[1].on_off
+ dev2_cluster_on_off = device_light_2.endpoints[1].on_off
+ dev3_cluster_on_off = device_light_3.endpoints[1].on_off
+
+ # test that the lights were created and that they are unavailable
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_group.members)
+
+ # test that the lights were created and are 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, group_cluster_on_off, entity_id)
+
+ # test turning the lights on and off from the HA
+ await async_test_on_off_from_hass(hass, group_cluster_on_off, entity_id)
+
+ # test short flashing the lights from the HA
+ await async_test_flash_from_hass(
+ hass, group_cluster_identify, entity_id, FLASH_SHORT
+ )
+
+ # test turning the lights on and off from the HA
+ await async_test_level_on_off_from_hass(
+ hass, group_cluster_on_off, group_cluster_level, entity_id
+ )
+
+ # test getting a brightness change from the network
+ await async_test_on_from_light(hass, group_cluster_on_off, entity_id)
+ await async_test_dimmer_from_light(
+ hass, group_cluster_level, entity_id, 150, STATE_ON
+ )
+
+ # test long flashing the lights from the HA
+ await async_test_flash_from_hass(
+ hass, group_cluster_identify, entity_id, FLASH_LONG
+ )
+
+ # test some of the group logic to make sure we key off states correctly
+ await dev1_cluster_on_off.on()
+ await dev2_cluster_on_off.on()
+
+ # test that group light is on
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ await dev1_cluster_on_off.off()
+
+ # test that group light is still on
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ await dev2_cluster_on_off.off()
+
+ # test that group light is now off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ await dev1_cluster_on_off.on()
+
+ # test that group light is now back on
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # test that group light is now off
+ await group_cluster_on_off.off()
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # add a new member and test that his state is also tracked
+ await zha_group.async_add_members([device_light_3.ieee])
+ await dev3_cluster_on_off.on()
+ assert hass.states.get(entity_id).state == STATE_ON
diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py
index fc41a409518..2612019f6fe 100644
--- a/tests/components/zha/test_registries.py
+++ b/tests/components/zha/test_registries.py
@@ -254,3 +254,63 @@ def test_match_rule_claim_channels(rule, match, channel, channels):
claimed = rule.claim_channels(channels)
assert match == set([ch.name for ch in claimed])
+
+
+@pytest.fixture
+def entity_registry():
+ """Registry fixture."""
+ return registries.ZHAEntityRegistry()
+
+
+@pytest.mark.parametrize(
+ "manufacturer, model, match_name",
+ (
+ ("random manufacturer", "random model", "OnOff"),
+ ("random manufacturer", MODEL, "OnOffModel"),
+ (MANUFACTURER, "random model", "OnOffManufacturer"),
+ (MANUFACTURER, MODEL, "OnOffModelManufacturer"),
+ (MANUFACTURER, "some model", "OnOffMultimodel"),
+ ),
+)
+def test_weighted_match(channel, entity_registry, manufacturer, model, match_name):
+ """Test weightedd match."""
+
+ s = mock.sentinel
+
+ @entity_registry.strict_match(
+ s.component,
+ channel_names="on_off",
+ models={MODEL, "another model", "some model"},
+ )
+ class OnOffMultimodel:
+ pass
+
+ @entity_registry.strict_match(s.component, channel_names="on_off")
+ class OnOff:
+ pass
+
+ @entity_registry.strict_match(
+ s.component, channel_names="on_off", manufacturers=MANUFACTURER
+ )
+ class OnOffManufacturer:
+ pass
+
+ @entity_registry.strict_match(s.component, channel_names="on_off", models=MODEL)
+ class OnOffModel:
+ pass
+
+ @entity_registry.strict_match(
+ s.component, channel_names="on_off", models=MODEL, manufacturers=MANUFACTURER
+ )
+ class OnOffModelManufacturer:
+ pass
+
+ ch_on_off = channel("on_off", 6)
+ ch_level = channel("level", 8)
+
+ match, claimed = entity_registry.get_entity(
+ s.component, manufacturer, model, [ch_on_off, ch_level]
+ )
+
+ assert match.__name__ == match_name
+ assert claimed == [ch_on_off]
diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py
index 98f661cc1ab..ed5d228ab88 100644
--- a/tests/components/zha/test_switch.py
+++ b/tests/components/zha/test_switch.py
@@ -2,6 +2,7 @@
from unittest.mock import call, patch
import pytest
+import zigpy.profiles.zha as zha
import zigpy.zcl.clusters.general as general
import zigpy.zcl.foundation as zcl_f
@@ -10,8 +11,10 @@ from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from .common import (
async_enable_traffic,
+ async_find_group_entity_id,
async_test_rejoin,
find_entity_id,
+ get_zha_gateway,
send_attributes_report,
)
@@ -19,6 +22,8 @@ from tests.common import mock_coro
ON = 1
OFF = 0
+IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
+IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
@pytest.fixture
@@ -34,6 +39,64 @@ def zigpy_device(zigpy_device_mock):
return zigpy_device_mock(endpoints)
+@pytest.fixture
+async def coordinator(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha light platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee="00:15:8d:00:02:32:4f:32",
+ nwk=0x0000,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_switch_1(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha switch platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.OnOff.cluster_id],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
+@pytest.fixture
+async def device_switch_2(hass, zigpy_device_mock, zha_device_joined):
+ """Test zha switch platform."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.OnOff.cluster_id],
+ "out_clusters": [],
+ "device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
+ }
+ },
+ ieee=IEEE_GROUPABLE_DEVICE2,
+ )
+ zha_device = await zha_device_joined(zigpy_device)
+ zha_device.set_available(True)
+ return zha_device
+
+
async def test_switch(hass, zha_device_joined_restored, zigpy_device):
"""Test zha switch platform."""
@@ -89,3 +152,95 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device):
# test joining a new switch to the network and HA
await async_test_rejoin(hass, zigpy_device, [cluster], (1,))
+
+
+async def async_test_zha_group_switch_entity(
+ hass, device_switch_1, device_switch_2, coordinator
+):
+ """Test the switch entity for a ZHA group."""
+ zha_gateway = get_zha_gateway(hass)
+ assert zha_gateway is not None
+ zha_gateway.coordinator_zha_device = coordinator
+ coordinator._zha_gateway = zha_gateway
+ device_switch_1._zha_gateway = zha_gateway
+ device_switch_2._zha_gateway = zha_gateway
+ member_ieee_addresses = [device_switch_1.ieee, device_switch_2.ieee]
+
+ # test creating a group with 2 members
+ zha_group = await zha_gateway.async_create_zigpy_group(
+ "Test Group", member_ieee_addresses
+ )
+ await hass.async_block_till_done()
+
+ assert zha_group is not None
+ assert len(zha_group.members) == 2
+ for member in zha_group.members:
+ assert member.ieee in member_ieee_addresses
+
+ entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group)
+ assert hass.states.get(entity_id) is not None
+
+ group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id]
+ dev1_cluster_on_off = device_switch_1.endpoints[1].on_off
+ dev2_cluster_on_off = device_switch_2.endpoints[1].on_off
+
+ # test that the lights were created and that they are unavailable
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_group.members)
+
+ # test that the lights were created and are off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # turn on from HA
+ with patch(
+ "zigpy.zcl.Cluster.request",
+ return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]),
+ ):
+ # turn on via UI
+ await hass.services.async_call(
+ DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(group_cluster_on_off.request.mock_calls) == 1
+ assert group_cluster_on_off.request.call_args == call(
+ False, ON, (), expect_reply=True, manufacturer=None, tsn=None
+ )
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # turn off from HA
+ with patch(
+ "zigpy.zcl.Cluster.request",
+ return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]),
+ ):
+ # turn off via UI
+ await hass.services.async_call(
+ DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
+ )
+ assert len(group_cluster_on_off.request.mock_calls) == 1
+ assert group_cluster_on_off.request.call_args == call(
+ False, OFF, (), expect_reply=True, manufacturer=None, tsn=None
+ )
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # test some of the group logic to make sure we key off states correctly
+ await dev1_cluster_on_off.on()
+ await dev2_cluster_on_off.on()
+
+ # test that group light is on
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ await dev1_cluster_on_off.off()
+
+ # test that group light is still on
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ await dev2_cluster_on_off.off()
+
+ # test that group light is now off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ await dev1_cluster_on_off.on()
+
+ # test that group light is now back on
+ assert hass.states.get(entity_id).state == STATE_ON
diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py
index b92fc64dee2..1d88ba69e8d 100644
--- a/tests/components/zha/zha_devices_list.py
+++ b/tests/components/zha/zha_devices_list.py
@@ -53,7 +53,7 @@ DEVICES = [
"entity_id": "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["5:0x0019"],
"manufacturer": "Bosch",
"model": "ISW-ZPR1-WP13",
"node_descriptor": b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00",
@@ -77,7 +77,7 @@ DEVICES = [
"entity_id": "sensor.centralite_3130_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "CentraLite",
"model": "3130",
"node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00",
@@ -116,7 +116,7 @@ DEVICES = [
"entity_id": "sensor.centralite_3210_l_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "CentraLite",
"model": "3210-L",
"node_descriptor": b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00",
@@ -154,7 +154,7 @@ DEVICES = [
"entity_id": "sensor.centralite_3310_s_77665544_manufacturer_specific",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "CentraLite",
"model": "3310-S",
"node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
@@ -200,7 +200,7 @@ DEVICES = [
"entity_id": "binary_sensor.centralite_3315_s_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "CentraLite",
"model": "3315-S",
"node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
@@ -246,7 +246,7 @@ DEVICES = [
"entity_id": "binary_sensor.centralite_3320_l_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "CentraLite",
"model": "3320-L",
"node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
@@ -292,7 +292,7 @@ DEVICES = [
"entity_id": "binary_sensor.centralite_3326_l_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "CentraLite",
"model": "3326-L",
"node_descriptor": b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00",
@@ -344,7 +344,7 @@ DEVICES = [
"entity_id": "binary_sensor.centralite_motion_sensor_a_77665544_occupancy",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "CentraLite",
"model": "Motion Sensor-A",
"node_descriptor": b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00",
@@ -384,7 +384,7 @@ DEVICES = [
"entity_id": "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering",
},
},
- "event_channels": [],
+ "event_channels": ["4:0x0019"],
"manufacturer": "ClimaxTechnology",
"model": "PSMP5_00.00.02.02TC",
"node_descriptor": b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00",
@@ -501,7 +501,7 @@ DEVICES = [
"entity_id": "binary_sensor.heiman_smokesensor_em_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "HEIMAN",
"model": "SmokeSensor-EM",
"node_descriptor": b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00",
@@ -525,7 +525,7 @@ DEVICES = [
"entity_id": "binary_sensor.heiman_co_v16_77665544_ias_zone",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Heiman",
"model": "CO_V16",
"node_descriptor": b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
@@ -549,7 +549,7 @@ DEVICES = [
"entity_id": "binary_sensor.heiman_warningdevice_77665544_ias_zone",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Heiman",
"model": "WarningDevice",
"node_descriptor": b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00",
@@ -593,7 +593,7 @@ DEVICES = [
"entity_id": "binary_sensor.hivehome_com_mot003_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["6:0x0019"],
"manufacturer": "HiveHome.com",
"model": "MOT003",
"node_descriptor": b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00",
@@ -627,7 +627,7 @@ DEVICES = [
"entity_id": "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off",
}
},
- "event_channels": ["1:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E12 WS opal 600lm",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00",
@@ -653,7 +653,7 @@ DEVICES = [
"entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off",
}
},
- "event_channels": ["1:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 CWS opal 600lm",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
@@ -679,7 +679,7 @@ DEVICES = [
"entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off",
}
},
- "event_channels": ["1:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 W opal 1000lm",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
@@ -705,7 +705,7 @@ DEVICES = [
"entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off",
}
},
- "event_channels": ["1:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 WS opal 980lm",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
@@ -731,7 +731,7 @@ DEVICES = [
"entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off",
}
},
- "event_channels": ["1:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI bulb E26 opal 1000lm",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00",
@@ -755,7 +755,7 @@ DEVICES = [
"entity_id": "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off",
}
},
- "event_channels": ["1:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI control outlet",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00",
@@ -788,7 +788,7 @@ DEVICES = [
"entity_id": "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off",
},
},
- "event_channels": ["1:0x0006"],
+ "event_channels": ["1:0x0006", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI motion sensor",
"node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
@@ -813,7 +813,7 @@ DEVICES = [
"entity_id": "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI on/off switch",
"node_descriptor": b"\x02@\x80|\x11RR\x00\x00,R\x00\x00",
@@ -838,7 +838,7 @@ DEVICES = [
"entity_id": "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power",
}
},
- "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI remote control",
"node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
@@ -864,7 +864,7 @@ DEVICES = [
},
"entities": [],
"entity_map": {},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI signal repeater",
"node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00",
@@ -888,7 +888,7 @@ DEVICES = [
"entity_id": "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI wireless dimmer",
"node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
@@ -927,7 +927,7 @@ DEVICES = [
"entity_id": "sensor.jasco_products_45852_77665544_smartenergy_metering",
},
},
- "event_channels": ["2:0x0006", "2:0x0008"],
+ "event_channels": ["1:0x0019", "2:0x0006", "2:0x0008"],
"manufacturer": "Jasco Products",
"model": "45852",
"node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00",
@@ -966,7 +966,7 @@ DEVICES = [
"entity_id": "sensor.jasco_products_45856_77665544_smartenergy_metering",
},
},
- "event_channels": ["2:0x0006"],
+ "event_channels": ["1:0x0019", "2:0x0006"],
"manufacturer": "Jasco Products",
"model": "45856",
"node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00",
@@ -1005,7 +1005,7 @@ DEVICES = [
"entity_id": "sensor.jasco_products_45857_77665544_smartenergy_metering",
},
},
- "event_channels": ["2:0x0006", "2:0x0008"],
+ "event_channels": ["1:0x0019", "2:0x0006", "2:0x0008"],
"manufacturer": "Jasco Products",
"model": "45857",
"node_descriptor": b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00",
@@ -1063,7 +1063,7 @@ DEVICES = [
"entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Keen Home Inc",
"model": "SV02-610-MP-1.3",
"node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00",
@@ -1121,7 +1121,7 @@ DEVICES = [
"entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Keen Home Inc",
"model": "SV02-612-MP-1.2",
"node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00",
@@ -1179,7 +1179,7 @@ DEVICES = [
"entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Keen Home Inc",
"model": "SV02-612-MP-1.3",
"node_descriptor": b"\x02@\x80[\x11RR\x00\x00*R\x00\x00",
@@ -1212,7 +1212,7 @@ DEVICES = [
"entity_id": "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "King Of Fans, Inc.",
"model": "HBUniversalCFRemote",
"node_descriptor": b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
@@ -1237,7 +1237,7 @@ DEVICES = [
"entity_id": "sensor.lds_zbt_cctswitch_d0001_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008", "1:0x0300"],
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"],
"manufacturer": "LDS",
"model": "ZBT-CCTSwitch-D0001",
"node_descriptor": b"\x02@\x80h\x11RR\x00\x00,R\x00\x00",
@@ -1262,7 +1262,7 @@ DEVICES = [
"entity_id": "light.ledvance_a19_rgbw_77665544_level_light_color_on_off",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LEDVANCE",
"model": "A19 RGBW",
"node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
@@ -1286,7 +1286,7 @@ DEVICES = [
"entity_id": "light.ledvance_flex_rgbw_77665544_level_light_color_on_off",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LEDVANCE",
"model": "FLEX RGBW",
"node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
@@ -1310,7 +1310,7 @@ DEVICES = [
"entity_id": "switch.ledvance_plug_77665544_on_off",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LEDVANCE",
"model": "PLUG",
"node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
@@ -1334,7 +1334,7 @@ DEVICES = [
"entity_id": "light.ledvance_rt_rgbw_77665544_level_light_color_on_off",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LEDVANCE",
"model": "RT RGBW",
"node_descriptor": b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00",
@@ -1399,7 +1399,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.plug.maus01",
"node_descriptor": b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00",
@@ -1451,7 +1451,7 @@ DEVICES = [
"entity_id": "light.lumi_lumi_relay_c2acn01_77665544_on_off_2",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.relay.c2acn01",
"node_descriptor": b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1510,7 +1510,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input",
},
},
- "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.remote.b186acn01",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1569,7 +1569,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input",
},
},
- "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.remote.b286acn01",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1925,7 +1925,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input",
},
},
- "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.sensor_86sw1",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1978,7 +1978,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_analog_input",
},
},
- "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.sensor_cube.aqgl01",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2031,7 +2031,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_ht_77665544_humidity",
},
},
- "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.sensor_ht",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2064,7 +2064,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off",
},
},
- "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.sensor_magnet",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2142,7 +2142,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.sensor_motion.aq2",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2187,7 +2187,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.sensor_smoke",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2212,7 +2212,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_switch_77665544_power",
}
},
- "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.sensor_switch",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2303,7 +2303,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "LUMI",
"model": "lumi.sensor_wleak.aq1",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2349,7 +2349,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone",
},
},
- "event_channels": ["1:0x0005", "2:0x0005"],
+ "event_channels": ["1:0x0005", "1:0x0019", "2:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.vibration.aq1",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2482,7 +2482,7 @@ DEVICES = [
"profile_id": 41440,
},
},
- "entities": [],
+ "entities": ["1:0x0019"],
"entity_map": {},
"event_channels": [],
"manufacturer": None,
@@ -2526,7 +2526,7 @@ DEVICES = [
"entity_id": "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off",
}
},
- "event_channels": [],
+ "event_channels": ["3:0x0019"],
"manufacturer": "OSRAM",
"model": "LIGHTIFY A19 RGBW",
"node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
@@ -2551,7 +2551,7 @@ DEVICES = [
"entity_id": "sensor.osram_lightify_dimming_switch_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0006", "1:0x0008", "1:0x0019"],
"manufacturer": "OSRAM",
"model": "LIGHTIFY Dimming Switch",
"node_descriptor": b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00",
@@ -2578,7 +2578,7 @@ DEVICES = [
"entity_id": "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off",
}
},
- "event_channels": [],
+ "event_channels": ["3:0x0019"],
"manufacturer": "OSRAM",
"model": "LIGHTIFY Flex RGBW",
"node_descriptor": b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
@@ -2611,7 +2611,7 @@ DEVICES = [
"entity_id": "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["3:0x0019"],
"manufacturer": "OSRAM",
"model": "LIGHTIFY RT Tunable White",
"node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
@@ -2644,7 +2644,7 @@ DEVICES = [
"entity_id": "sensor.osram_plug_01_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["3:0x0019"],
"manufacturer": "OSRAM",
"model": "Plug 01",
"node_descriptor": b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03",
@@ -2707,6 +2707,7 @@ DEVICES = [
"1:0x0005",
"1:0x0006",
"1:0x0008",
+ "1:0x0019",
"1:0x0300",
"2:0x0005",
"2:0x0006",
@@ -2760,7 +2761,7 @@ DEVICES = [
"entity_id": "sensor.philips_rwl020_77665544_power",
}
},
- "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"],
"manufacturer": "Philips",
"model": "RWL020",
"node_descriptor": b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00",
@@ -2799,7 +2800,7 @@ DEVICES = [
"entity_id": "binary_sensor.samjin_button_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Samjin",
"model": "button",
"node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00",
@@ -2845,7 +2846,7 @@ DEVICES = [
"default_match": True,
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Samjin",
"model": "multi",
"node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00",
@@ -2884,7 +2885,7 @@ DEVICES = [
"entity_id": "binary_sensor.samjin_water_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Samjin",
"model": "water",
"node_descriptor": b"\x02@\x80A\x12RR\x00\x00,R\x00\x00",
@@ -2916,7 +2917,7 @@ DEVICES = [
"entity_id": "sensor.securifi_ltd_unk_model_77665544_electrical_measurement",
},
},
- "event_channels": ["1:0x0005", "1:0x0006"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0019"],
"manufacturer": "Securifi Ltd.",
"model": None,
"node_descriptor": b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00",
@@ -2954,7 +2955,7 @@ DEVICES = [
"entity_id": "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Sercomm Corp.",
"model": "SZ-DWS04N_SF",
"node_descriptor": b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00",
@@ -2999,7 +3000,7 @@ DEVICES = [
"entity_id": "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement",
},
},
- "event_channels": ["2:0x0006"],
+ "event_channels": ["1:0x0019", "2:0x0006"],
"manufacturer": "Sercomm Corp.",
"model": "SZ-ESW01",
"node_descriptor": b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00",
@@ -3043,7 +3044,7 @@ DEVICES = [
"entity_id": "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Sercomm Corp.",
"model": "SZ-PIR04",
"node_descriptor": b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00",
@@ -3075,7 +3076,7 @@ DEVICES = [
"entity_id": "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Sinope Technologies",
"model": "RM3250ZB",
"node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00",
@@ -3114,7 +3115,7 @@ DEVICES = [
"entity_id": "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Sinope Technologies",
"model": "TH1123ZB",
"node_descriptor": b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00",
@@ -3154,7 +3155,7 @@ DEVICES = [
"entity_id": "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Sinope Technologies",
"model": "TH1124ZB",
"node_descriptor": b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00",
@@ -3187,7 +3188,7 @@ DEVICES = [
"entity_id": "sensor.smartthings_outletv4_77665544_electrical_measurement",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "SmartThings",
"model": "outletv4",
"node_descriptor": b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00",
@@ -3211,7 +3212,7 @@ DEVICES = [
"entity_id": "device_tracker.smartthings_tagv4_77665544_power",
}
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "SmartThings",
"model": "tagv4",
"node_descriptor": b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00",
@@ -3307,7 +3308,7 @@ DEVICES = [
"entity_id": "binary_sensor.visonic_mct_340_e_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Visonic",
"model": "MCT-340 E",
"node_descriptor": b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00",
@@ -3340,7 +3341,7 @@ DEVICES = [
"entity_id": "fan.zen_within_zen_01_77665544_fan",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "Zen Within",
"model": "Zen-01",
"node_descriptor": b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00",
@@ -3405,7 +3406,7 @@ DEVICES = [
"entity_id": "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "_TYZB01_ns1ndbww",
"model": "TS0004",
"node_descriptor": b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00",
@@ -3470,7 +3471,7 @@ DEVICES = [
"entity_id": "sensor.sengled_e11_g13_77665544_smartenergy_metering",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "sengled",
"model": "E11-G13",
"node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00",
@@ -3502,7 +3503,7 @@ DEVICES = [
"entity_id": "sensor.sengled_e12_n14_77665544_smartenergy_metering",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "sengled",
"model": "E12-N14",
"node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00",
@@ -3534,7 +3535,7 @@ DEVICES = [
"entity_id": "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0019"],
"manufacturer": "sengled",
"model": "Z01-A19NAE26",
"node_descriptor": b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00",
diff --git a/tests/conftest.py b/tests/conftest.py
index 04e584cb158..0963d151490 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,9 +15,12 @@ from homeassistant.components.websocket_api.auth import (
TYPE_AUTH_REQUIRED,
)
from homeassistant.components.websocket_api.http import URL
+from homeassistant.exceptions import ServiceNotFound
from homeassistant.setup import async_setup_component
from homeassistant.util import location
+from tests.ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS
+
pytest.register_assert_rewrite("tests.common")
from tests.common import ( # noqa: E402, isort:skip
@@ -77,13 +80,31 @@ def hass_storage():
@pytest.fixture
-def hass(loop, hass_storage):
+def hass(loop, hass_storage, request):
"""Fixture to provide a test instance of Home Assistant."""
+
+ def exc_handle(loop, context):
+ """Handle exceptions by rethrowing them, which will fail the test."""
+ exceptions.append(context["exception"])
+ orig_exception_handler(loop, context)
+
+ exceptions = []
hass = loop.run_until_complete(async_test_home_assistant(loop))
+ orig_exception_handler = loop.get_exception_handler()
+ loop.set_exception_handler(exc_handle)
yield hass
loop.run_until_complete(hass.async_stop(force=True))
+ for ex in exceptions:
+ if (
+ request.module.__name__,
+ request.function.__name__,
+ ) in IGNORE_UNCAUGHT_EXCEPTIONS:
+ continue
+ if isinstance(ex, ServiceNotFound):
+ continue
+ raise ex
@pytest.fixture
diff --git a/tests/fixtures/abode_automation.json b/tests/fixtures/abode_automation.json
new file mode 100644
index 00000000000..fb1c00faff9
--- /dev/null
+++ b/tests/fixtures/abode_automation.json
@@ -0,0 +1,38 @@
+{
+ "name": "Test Automation",
+ "enabled": "True",
+ "version": 2,
+ "id": "47fae27488f74f55b964a81a066c3a01",
+ "subType": "",
+ "actions": [
+ {
+ "directive": {
+ "trait": "panel.traits.panelMode",
+ "name": "panel.directives.arm",
+ "state": {
+ "panelMode": "AWAY"
+ }
+ }
+ }
+ ],
+ "conditions": {},
+ "triggers": {
+ "operator": "OR",
+ "expressions": [
+ {
+ "mobileDevices": [
+ "89381",
+ "658"
+ ],
+ "property": {
+ "trait": "mobile.traits.location",
+ "name": "location",
+ "rule": {
+ "location": "31675",
+ "equalTo": "LAST_OUT"
+ }
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/abode_automation_changed.json b/tests/fixtures/abode_automation_changed.json
new file mode 100644
index 00000000000..39b874c4dfc
--- /dev/null
+++ b/tests/fixtures/abode_automation_changed.json
@@ -0,0 +1,38 @@
+{
+ "name": "Test Automation",
+ "enabled": "False",
+ "version": 2,
+ "id": "47fae27488f74f55b964a81a066c3a01",
+ "subType": "",
+ "actions": [
+ {
+ "directive": {
+ "trait": "panel.traits.panelMode",
+ "name": "panel.directives.arm",
+ "state": {
+ "panelMode": "AWAY"
+ }
+ }
+ }
+ ],
+ "conditions": {},
+ "triggers": {
+ "operator": "OR",
+ "expressions": [
+ {
+ "mobileDevices": [
+ "89381",
+ "658"
+ ],
+ "property": {
+ "trait": "mobile.traits.location",
+ "name": "location",
+ "rule": {
+ "location": "31675",
+ "equalTo": "LAST_OUT"
+ }
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/abode_devices.json b/tests/fixtures/abode_devices.json
new file mode 100644
index 00000000000..370b264427a
--- /dev/null
+++ b/tests/fixtures/abode_devices.json
@@ -0,0 +1,799 @@
+[
+ {
+ "id": "RF:01430030",
+ "type_tag": "device_type.door_contact",
+ "type": "Door Contact",
+ "name": "Front Door",
+ "area": "1",
+ "zone": "15",
+ "sort_order": "",
+ "is_window": "1",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "3",
+ "sresp_entry_0": "3",
+ "sresp_exit_0": "0",
+ "group_name": "Doors and Windows",
+ "group_id": "397972",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "1",
+ "sresp_entry_1": "1",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "1",
+ "sresp_entry_2": "1",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "1",
+ "uuid": "2834013428b6035fba7d4054aa7b25a3",
+ "sresp_entry_3": "1",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "1",
+ "sresp_entry_4": "1",
+ "sresp_exit_4": "0",
+ "version": "",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "Closed",
+ "status_display": "Closed",
+ "statuses": {
+ "open": "0"
+ },
+ "status_ex": "",
+ "actions": [],
+ "status_icons": {
+ "Open": "assets/icons/WindowOpened.svg",
+ "Closed": "assets/icons/WindowClosed.svg"
+ },
+ "icon": "assets/icons/doorsensor-a.svg",
+ "sresp_trigger": "0",
+ "sresp_restore": "0"
+ },
+ {
+ "id": "RF:01c34a30",
+ "type_tag": "device_type.povs",
+ "type": "Occupancy",
+ "name": "Hallway Motion",
+ "area": "1",
+ "zone": "17",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Ungrouped",
+ "group_id": "1",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "5",
+ "sresp_entry_1": "4",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "4",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "ba2c7e8d4430da8d34c31425a2823fe0",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "Online",
+ "status_display": "Online",
+ "statuses": {
+ "motion": "0"
+ },
+ "status_ex": "",
+ "actions": [],
+ "status_icons": [],
+ "icon": "assets/icons/motioncamera-a.svg",
+ "sresp_trigger": "0",
+ "sresp_restore": "0",
+ "occupancy_timer": null,
+ "sensitivity": null,
+ "model": "L1",
+ "is_motion_sensor": true
+ },
+ {
+ "id": "SR:PIR",
+ "type_tag": "device_type.pir",
+ "type": "Motion Sensor",
+ "name": "Living Room Motion",
+ "area": "1",
+ "zone": "2",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Motion",
+ "group_id": "397973",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "5",
+ "sresp_entry_1": "4",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "4",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "2f1bc34ceadac032af4fc9189ef821a8",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "1",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "Online",
+ "status_display": "Online",
+ "statuses": [],
+ "status_ex": "",
+ "actions": [],
+ "status_icons": [],
+ "icon": "assets/icons/motioncamera-a.svg",
+ "schar_obpir_sens": "15",
+ "schar_obpir_pulse": "2",
+ "sensitivity": "15",
+ "model": "L1"
+ },
+ {
+ "id": "ZB:db5b1a",
+ "type_tag": "device_type.hue",
+ "type": "RGB Dimmer",
+ "name": "Living Room Lamp",
+ "area": "1",
+ "zone": "21",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Ungrouped",
+ "group_id": "1",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "741385f4388b2637df4c6b398fe50581",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "LCT014",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "api/v1/control/light/ZB:db5b1a",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "On",
+ "status_display": "On",
+ "statuses": {
+ "saturation": 100,
+ "hue": 225,
+ "level": "79",
+ "switch": "1",
+ "color_temp": 3571,
+ "color_mode": "0"
+ },
+ "status_ex": "",
+ "actions": [],
+ "status_icons": [],
+ "icon": "assets/icons/bulb-1.svg",
+ "statusEx": "0"
+ },
+ {
+ "id": "ZB:db5b1b",
+ "type_tag": "device_type.hue",
+ "type": "Dimmer",
+ "name": "Test Dimmer Only Device",
+ "area": "1",
+ "zone": "21",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Ungrouped",
+ "group_id": "1",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "641385f4388b2637df4c6b398fe50581",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "LCT014",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "api/v1/control/light/ZB:db5b1b",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "On",
+ "status_display": "On",
+ "statuses": {
+ "saturation": 100,
+ "hue": 225,
+ "level": "100",
+ "switch": "1",
+ "color_temp": 3571,
+ "color_mode": "2"
+ },
+ "status_ex": "",
+ "actions": [],
+ "status_icons": [],
+ "icon": "assets/icons/bulb-1.svg",
+ "statusEx": "0"
+ },
+ {
+ "id": "ZB:db5b1c",
+ "type_tag": "device_type.dimmer",
+ "type": "Light",
+ "name": "Test Non-dimmer Device",
+ "area": "1",
+ "zone": "21",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Ungrouped",
+ "group_id": "1",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "641385f4388b2637df4c6b398fe50583",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "LCT014",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "api/v1/control/light/ZB:db5b1c",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "On",
+ "status_display": "On",
+ "statuses": {
+ "switch": "1"
+ },
+ "status_ex": "",
+ "actions": [],
+ "status_icons": [],
+ "icon": "assets/icons/bulb-1.svg",
+ "statusEx": "0"
+ },
+ {
+ "id": "RF:02148e70",
+ "type_tag": "device_type.lm",
+ "type": "LM",
+ "name": "Environment Sensor",
+ "area": "1",
+ "zone": "24",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Ungrouped",
+ "group_id": "1",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "13545b21f4bdcd33d9abd461f8443e65",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "67 \u00b0F",
+ "status_display": "Online",
+ "statuses": {
+ "temperature": "67 \u00b0F",
+ "temp": "19.5",
+ "lux": "1 lx",
+ "humidity": "32 %"
+ },
+ "status_ex": "",
+ "actions": [
+ {
+ "label": "High Humidity Alarm",
+ "value": "a=1&z=24&trigger=HMH;"
+ },
+ {
+ "label": "Low Humidity Alarm",
+ "value": "a=1&z=24&trigger=HML;"
+ },
+ {
+ "label": "High Temperature Alarm",
+ "value": "a=1&z=24&trigger=TSH;"
+ },
+ {
+ "label": "Low Temperature Alarm",
+ "value": "a=1&z=24&trigger=TSL;"
+ }
+ ],
+ "status_icons": [],
+ "icon": "assets/icons/occupancy-sensor.svg",
+ "statusEx": "1"
+ },
+ {
+ "id": "ZW:0000000b",
+ "type_tag": "device_type.power_switch_sensor",
+ "type": "Power Switch Sensor",
+ "name": "Test Switch",
+ "area": "1",
+ "zone": "23",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Lighting",
+ "group_id": "377075",
+ "default_group_id": "1",
+ "sort_id": "7",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "0012a4d3614cb7e2b8c9abea31d2fb2a",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "006349523032",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "api/v1/control/power_switch/ZW:0000000b",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "Off",
+ "status_display": "OFF",
+ "statuses": {
+ "switch": "0"
+ },
+ "status_ex": "",
+ "actions": [],
+ "status_icons": [],
+ "icon": "assets/icons/plug.svg"
+ },
+ {
+ "id": "XF:b0c5ba27592a",
+ "type_tag": "device_type.ipcam",
+ "type": "IP Cam",
+ "name": "Test Cam",
+ "area": "1",
+ "zone": "1",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "1",
+ "sresp_24hr": "5",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Streaming Camera",
+ "group_id": "397893",
+ "default_group_id": "1",
+ "sort_id": "10000",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "d0a3a1c316891ceb00c20118aae2a133",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "1.0.2.22G_6.8E_homekit_2.0.9_s2 ABODE oz",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "1",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "api/v1/cams/XF:b0c5ba27592a/record",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "Online",
+ "status_display": "Online",
+ "statuses": [],
+ "status_ex": "",
+ "actions": [
+ {
+ "label": "Capture Video",
+ "value": "a=1&z=1&req=vid;"
+ },
+ {
+ "label": "Turn off Live Video",
+ "value": "a=1&z=1&privacy=on;"
+ },
+ {
+ "label": "Turn on Live Video",
+ "value": "a=1&z=1&privacy=off;"
+ }
+ ],
+ "status_icons": [],
+ "icon": "assets/icons/streaming-camaera-new.svg",
+ "control_url_snapshot": "api/v1/cams/XF:b0c5ba27592a/capture",
+ "ptt_supported": true,
+ "is_new_camera": 1,
+ "stream_quality": 2,
+ "camera_mac": "A0:C1:B2:C3:45:6D",
+ "privacy": "1",
+ "enable_audio": "1",
+ "alarm_video": "25",
+ "pre_alarm_video": "5",
+ "mic_volume": "75",
+ "speaker_volume": "75",
+ "mic_default_volume": 40,
+ "speaker_default_volume": 46,
+ "bandwidth": {
+ "slider_labels": [
+ {
+ "name": "High",
+ "value": 3
+ },
+ {
+ "name": "Medium",
+ "value": 2
+ },
+ {
+ "name": "Low",
+ "value": 1
+ }
+ ],
+ "min": 1,
+ "max": 3,
+ "step": 1
+ },
+ "volume": {
+ "min": 0,
+ "max": 100,
+ "step": 1
+ },
+ "video_flip": "0",
+ "hframe": "480P"
+ },
+ {
+ "id": "ZW:00000004",
+ "type_tag": "device_type.door_lock",
+ "type": "Door Lock",
+ "name": "Test Lock",
+ "area": "1",
+ "zone": "16",
+ "sort_order": "",
+ "is_window": "",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "group_name": "Doors/Windows",
+ "group_id": "377028",
+ "default_group_id": "1",
+ "sort_id": "1",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "51cab3b545d2o34ed7fz02731bda5324",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "sresp_mode_4": "0",
+ "sresp_entry_4": "0",
+ "sresp_exit_4": "0",
+ "version": "",
+ "origin": "abode",
+ "has_subscription": null,
+ "onboard": "0",
+ "s2_grnt_keys": "",
+ "s2_dsk": "",
+ "s2_propty": "",
+ "s2_keys_valid": "",
+ "zwave_secure_protocol": "",
+ "control_url": "api/v1/control/lock/ZW:00000004",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0,
+ "jammed": 0,
+ "zwave_fault": 0
+ },
+ "status": "LockClosed",
+ "status_display": "LockClosed",
+ "statuses": {
+ "open": "0"
+ },
+ "status_ex": "",
+ "actions": [
+ {
+ "label": "Lock",
+ "value": "a=1&z=16&sw=on;"
+ },
+ {
+ "label": "Unlock",
+ "value": "a=1&z=16&sw=off;"
+ }
+ ],
+ "status_icons": {
+ "LockOpen": "assets/icons/unlocked-red.svg",
+ "LockClosed": "assets/icons/locked-green.svg"
+ },
+ "icon": "assets/icons/automation-lock.svg",
+ "automation_settings": null
+ },
+ {
+ "id": "ZW:00000007",
+ "type_tag": "device_type.secure_barrier",
+ "type": "Secure Barrier",
+ "name": "Garage Door",
+ "area": "1",
+ "zone": "11",
+ "sort_order": "0",
+ "is_window": "0",
+ "bypass": "0",
+ "schar_24hr": "0",
+ "sresp_mode_0": "0",
+ "sresp_entry_0": "0",
+ "sresp_exit_0": "0",
+ "sresp_mode_1": "0",
+ "sresp_entry_1": "0",
+ "sresp_exit_1": "0",
+ "sresp_mode_2": "0",
+ "sresp_entry_2": "0",
+ "sresp_exit_2": "0",
+ "sresp_mode_3": "0",
+ "uuid": "61cbz3b542d2o33ed2fz02721bda3324",
+ "sresp_entry_3": "0",
+ "sresp_exit_3": "0",
+ "capture_mode": null,
+ "origin": "abode",
+ "control_url": "api/v1/control/power_switch/ZW:00000007",
+ "deep_link": null,
+ "status_color": "#5cb85c",
+ "faults": {
+ "low_battery": 0,
+ "tempered": 0,
+ "supervision": 0,
+ "out_of_order": 0,
+ "no_response": 0
+ },
+ "status": "Closed",
+ "statuses": {
+ "hvac_mode": null
+ },
+ "status_ex": "",
+ "actions": [
+ {
+ "label": "Close",
+ "value": "a=1&z=11&sw=off;"
+ },
+ {
+ "label": "Open",
+ "value": "a=1&z=11&sw=on;"
+ }
+ ],
+ "status_icons": {
+ "Open": "assets/icons/garage-door-red.svg",
+ "Closed": "assets/icons/garage-door-green.svg"
+ },
+ "icon": "assets/icons/garage-door.svg"
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/abode_login.json b/tests/fixtures/abode_login.json
new file mode 100644
index 00000000000..fb0ed1fd4ff
--- /dev/null
+++ b/tests/fixtures/abode_login.json
@@ -0,0 +1,115 @@
+{
+ "token": "web-1eb04ba2236d85f49d4b9b4bb91665f2",
+ "expired_at": "2017-06-05 00:14:12",
+ "initiate_screen": "timeline",
+ "user": {
+ "id": "user@email.com",
+ "email": "user@email.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "phone": "5555551212",
+ "profile_pic": "https://website.com/default-image.svg",
+ "address": "555 None St.",
+ "city": "New York City",
+ "state": "NY",
+ "zip": "10108",
+ "country": "US",
+ "longitude": "0",
+ "latitude": "0",
+ "timezone": "America/New_York_City",
+ "verified": "1",
+ "plan": "Basic",
+ "plan_id": "0",
+ "plan_active": "1",
+ "cms_code": "1111",
+ "cms_active": "0",
+ "cms_started_at": "",
+ "cms_expiry": "",
+ "cms_ondemand": "",
+ "step": "-1",
+ "cms_permit_no": "",
+ "opted_plan_id": "",
+ "stripe_account": "1",
+ "plan_effective_from": "",
+ "agreement": "1",
+ "associate_users": "1",
+ "owner": "1"
+ },
+ "panel": {
+ "version": "ABGW 0.0.2.17F ABGW-L1-XA36J 3.1.2.6.1 Z-Wave 3.95",
+ "report_account": "5555",
+ "online": "1",
+ "initialized": "1",
+ "net_version": "ABGW 0.0.2.17F",
+ "rf_version": "ABGW-L1-XA36J",
+ "zigbee_version": "3.1.2.6.1",
+ "z_wave_version": "Z-Wave 3.95",
+ "timezone": "America/New_York",
+ "ac_fail": "0",
+ "battery": "1",
+ "ip": "192.168.1.1",
+ "jam": "0",
+ "rssi": "2",
+ "setup_zone_1": "1",
+ "setup_zone_2": "1",
+ "setup_zone_3": "1",
+ "setup_zone_4": "1",
+ "setup_zone_5": "1",
+ "setup_zone_6": "1",
+ "setup_zone_7": "1",
+ "setup_zone_8": "1",
+ "setup_zone_9": "1",
+ "setup_zone_10": "1",
+ "setup_gateway": "1",
+ "setup_contacts": "1",
+ "setup_billing": "1",
+ "setup_users": "1",
+ "is_cellular": "False",
+ "plan_set_id": "1",
+ "dealer_id": "0",
+ "tz_diff": "-04:00",
+ "is_demo": "0",
+ "rf51_version": "ABGW-L1-XA36J",
+ "model": "L1",
+ "mac": "00:11:22:33:44:55",
+ "xml_version": "3",
+ "dealer_name": "abode",
+ "id": "0",
+ "dealer_address": "2625 Middlefield Road #900 Palo Alto CA 94306",
+ "dealer_domain": "https://my.goabode.com",
+ "domain_alias": "https://test.goabode.com",
+ "dealer_support_url": "https://support.goabode.com",
+ "app_launch_url": "https://goabode.app.link/abode",
+ "has_wifi": "0",
+ "mode": {
+ "area_1": "standby",
+ "area_2": "standby"
+ }
+ },
+ "permissions": {
+ "premium_streaming": "0",
+ "guest_app": "0",
+ "family_app": "0",
+ "multiple_accounts": "1",
+ "google_voice": "1",
+ "nest": "1",
+ "alexa": "1",
+ "ifttt": "1",
+ "no_associates": "100",
+ "no_contacts": "2",
+ "no_devices": "155",
+ "no_ipcam": "100",
+ "no_quick_action": "25",
+ "no_automation": "75",
+ "media_storage": "3",
+ "cellular_backup": "0",
+ "cms_duration": "",
+ "cms_included": "0"
+ },
+ "integrations": {
+ "nest": {
+ "is_connected": 0,
+ "is_home_selected": 0
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/abode_logout.json b/tests/fixtures/abode_logout.json
new file mode 100644
index 00000000000..53e242fced3
--- /dev/null
+++ b/tests/fixtures/abode_logout.json
@@ -0,0 +1,4 @@
+{
+ "code": 200,
+ "message": "Logout successful."
+}
\ No newline at end of file
diff --git a/tests/fixtures/abode_oauth_claims.json b/tests/fixtures/abode_oauth_claims.json
new file mode 100644
index 00000000000..2b313b9aa3e
--- /dev/null
+++ b/tests/fixtures/abode_oauth_claims.json
@@ -0,0 +1,5 @@
+{
+ "token_type": "Bearer",
+ "access_token": "ohyeahthisisanoauthtoken",
+ "expires_in": 3600
+}
\ No newline at end of file
diff --git a/tests/fixtures/abode_panel.json b/tests/fixtures/abode_panel.json
new file mode 100644
index 00000000000..5a50ffe6fe7
--- /dev/null
+++ b/tests/fixtures/abode_panel.json
@@ -0,0 +1,185 @@
+{
+ "version": "Z3 1.0.2.22G_6.8E_homekit_2.0.9_s2 ABODE oz 19200_UITRF1BD_BL.A30.20181117 4.1.2.6.2 Z-Wave 6.02 Bridge controller library",
+ "report_account": "12345",
+ "online": "1",
+ "initialized": "1",
+ "net_version": "1.0.2.22G_6.8E_homekit_2.0.9_s2 ABODE oz",
+ "rf_version": "19200_UITRF1BD_BL.A30.20181117",
+ "zigbee_version": "4.1.2.6.2",
+ "z_wave_version": "Z-Wave 6.02 Bridge controller library",
+ "timezone": "America/Los_Angeles",
+ "ac_fail": "0",
+ "battery": "0",
+ "ip": "",
+ "jam": "0",
+ "rssi": "1",
+ "setup_zone_1": "1",
+ "setup_zone_2": "1",
+ "setup_zone_3": "1",
+ "setup_zone_4": "1",
+ "setup_zone_5": "1",
+ "setup_zone_6": "1",
+ "setup_zone_7": "1",
+ "setup_zone_8": "1",
+ "setup_zone_9": "1",
+ "setup_zone_10": "1",
+ "setup_gateway": "1",
+ "setup_contacts": "1",
+ "setup_billing": "1",
+ "setup_users": "1",
+ "is_cellular": "0",
+ "plan_set_id": "7",
+ "dealer_id": "0",
+ "tz_diff": "-08:00",
+ "model": "Z3",
+ "has_wifi": "1",
+ "has_s2_support": "1",
+ "mode": {
+ "area_1": "standby",
+ "area_1_label": "Standby",
+ "area_2": "standby",
+ "area_2_label": "Standby"
+ },
+ "areas": {
+ "1": {
+ "mode": "0",
+ "modes": {
+ "0": {
+ "area": "1",
+ "mode": "0",
+ "read_only": "1",
+ "is_set": "1",
+ "name": "standby",
+ "color": null,
+ "icon_id": null,
+ "entry1": null,
+ "entry2": null,
+ "exit": null,
+ "icon_path": null
+ },
+ "1": {
+ "area": "1",
+ "mode": "1",
+ "read_only": "1",
+ "is_set": "1",
+ "name": "away",
+ "color": null,
+ "icon_id": null,
+ "entry1": "30",
+ "entry2": "60",
+ "exit": "30",
+ "icon_path": null
+ },
+ "2": {
+ "area": "1",
+ "mode": "2",
+ "read_only": "1",
+ "is_set": "1",
+ "name": "home",
+ "color": null,
+ "icon_id": null,
+ "entry1": "30",
+ "entry2": "60",
+ "exit": "0",
+ "icon_path": null
+ },
+ "3": {
+ "area": "1",
+ "mode": "3",
+ "read_only": "0",
+ "is_set": "0",
+ "name": null,
+ "color": null,
+ "icon_id": null,
+ "entry1": "60",
+ "entry2": "60",
+ "exit": "60",
+ "icon_path": null
+ },
+ "4": {
+ "area": "1",
+ "mode": "4",
+ "read_only": "0",
+ "is_set": "0",
+ "name": null,
+ "color": null,
+ "icon_id": null,
+ "entry1": "60",
+ "entry2": "60",
+ "exit": "60",
+ "icon_path": null
+ }
+ }
+ },
+ "2": {
+ "mode": "0",
+ "modes": {
+ "0": {
+ "area": "2",
+ "mode": "0",
+ "read_only": "1",
+ "is_set": "1",
+ "name": "standby",
+ "color": null,
+ "icon_id": null,
+ "entry1": null,
+ "entry2": null,
+ "exit": null,
+ "icon_path": null
+ },
+ "1": {
+ "area": "2",
+ "mode": "1",
+ "read_only": "1",
+ "is_set": "1",
+ "name": "away",
+ "color": null,
+ "icon_id": null,
+ "entry1": "60",
+ "entry2": "60",
+ "exit": "60",
+ "icon_path": null
+ },
+ "2": {
+ "area": "2",
+ "mode": "2",
+ "read_only": "1",
+ "is_set": "1",
+ "name": "home",
+ "color": null,
+ "icon_id": null,
+ "entry1": "60",
+ "entry2": "60",
+ "exit": "60",
+ "icon_path": null
+ },
+ "3": {
+ "area": "2",
+ "mode": "3",
+ "read_only": "0",
+ "is_set": "0",
+ "name": null,
+ "color": null,
+ "icon_id": null,
+ "entry1": "60",
+ "entry2": "60",
+ "exit": "60",
+ "icon_path": null
+ },
+ "4": {
+ "area": "2",
+ "mode": "4",
+ "read_only": "0",
+ "is_set": "0",
+ "name": null,
+ "color": null,
+ "icon_id": null,
+ "entry1": "60",
+ "entry2": "60",
+ "exit": "60",
+ "icon_path": null
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/brother_printer_data.json b/tests/fixtures/brother_printer_data.json
index d1b631d7548..a70d87673d0 100644
--- a/tests/fixtures/brother_printer_data.json
+++ b/tests/fixtures/brother_printer_data.json
@@ -1,17 +1,76 @@
{
- "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.10.0": ["000104000003da"],
+ "1.3.6.1.2.1.1.3.0": "413613515",
+ "1.3.6.1.2.1.43.10.2.1.4.1.1": "986",
+ "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.10.0": [
+ "000104000003da",
+ "010104000002c5",
+ "02010400000386",
+ "0601040000021a",
+ "0701040000012d",
+ "080104000000ed"
+ ],
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.17.0": "1.17",
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.8.0": [
- "63010400000001",
"110104000003da",
- "410104000023f0",
"31010400000001",
+ "32010400000001",
+ "33010400000002",
+ "34010400000002",
+ "35010400000001",
+ "410104000023f0",
+ "54010400000001",
+ "55010400000001",
+ "63010400000001",
+ "68010400000001",
+ "690104000025e4",
+ "6a0104000025e4",
+ "6d010400002648",
"6f010400001d4c",
+ "700104000003e8",
+ "71010400000320",
+ "720104000000c8",
+ "7301040000064b",
+ "7401040000064b",
+ "7501040000064b",
+ "76010400000001",
+ "77010400000001",
+ "78010400000001",
+ "790104000023f0",
+ "7a0104000023f0",
+ "7b0104000023f0",
+ "7e01040000064b",
+ "800104000023f0",
"81010400000050",
+ "8201040000000a",
+ "8301040000000a",
+ "8401040000000a",
"8601040000000a"
],
"1.3.6.1.4.1.2435.2.3.9.1.1.7.0": "MFG:Brother;CMD:PJL,HBP,URF;MDL:HL-L2340DW series;CLS:PRINTER;CID:Brother Laser Type1;URF:W8,CP1,IS4-1,MT1-3-4-5-8,OB10,PQ4,RS300-600,V1.3,DM1;",
- "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.11.0": ["82010400002b06"],
+ "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.11.0": [
+ "7301040000bd05",
+ "7701040000be65",
+ "82010400002b06",
+ "8801040000bd34",
+ "a4010400004005",
+ "a5010400004005",
+ "a6010400004005",
+ "a7010400004005"
+ ],
+ "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.21.0": [
+ "00002302000025",
+ "00020016010200",
+ "00210200022202",
+ "020000a1040000"
+ ],
+ "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.20.0": [
+ "00a40100a50100",
+ "0100a301008801",
+ "01017301007701",
+ "870100a10100a2",
+ "a60100a70100a0"
+ ],
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.1.0": "0123456789",
- "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0": "WAITING "
+ "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0": "WAITING ",
+ "1.3.6.1.2.1.43.7.1.1.4.1.1": "2004"
}
\ No newline at end of file
diff --git a/tests/fixtures/directv/info-get-locations.json b/tests/fixtures/directv/info-get-locations.json
new file mode 100644
index 00000000000..5279bcebefc
--- /dev/null
+++ b/tests/fixtures/directv/info-get-locations.json
@@ -0,0 +1,22 @@
+{
+ "locations": [
+ {
+ "clientAddr": "0",
+ "locationName": "Host"
+ },
+ {
+ "clientAddr": "2CA17D1CD30X",
+ "locationName": "Client"
+ },
+ {
+ "clientAddr": "9XXXXXXXXXX9",
+ "locationName": "Unavailable Client"
+ }
+ ],
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/info/getLocations?callback=jsonp"
+ }
+}
diff --git a/tests/fixtures/directv/info-get-version.json b/tests/fixtures/directv/info-get-version.json
new file mode 100644
index 00000000000..074e1b89dd8
--- /dev/null
+++ b/tests/fixtures/directv/info-get-version.json
@@ -0,0 +1,13 @@
+{
+ "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"
+}
diff --git a/tests/fixtures/directv/info-mode-error.json b/tests/fixtures/directv/info-mode-error.json
new file mode 100644
index 00000000000..72bc39b1f5a
--- /dev/null
+++ b/tests/fixtures/directv/info-mode-error.json
@@ -0,0 +1,8 @@
+{
+ "status": {
+ "code": 500,
+ "commandResult": 1,
+ "msg": "Internal Server Error.",
+ "query": "/info/mode"
+ }
+}
diff --git a/tests/fixtures/directv/info-mode.json b/tests/fixtures/directv/info-mode.json
new file mode 100644
index 00000000000..f1c731a07aa
--- /dev/null
+++ b/tests/fixtures/directv/info-mode.json
@@ -0,0 +1,9 @@
+{
+ "mode": 0,
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK",
+ "query": "/info/mode"
+ }
+}
diff --git a/tests/fixtures/directv/remote-process-key.json b/tests/fixtures/directv/remote-process-key.json
new file mode 100644
index 00000000000..7f73e02acc7
--- /dev/null
+++ b/tests/fixtures/directv/remote-process-key.json
@@ -0,0 +1,10 @@
+{
+ "hold": "keyPress",
+ "key": "ANY",
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK",
+ "query": "/remote/processKey?key=ANY&hold=keyPress"
+ }
+}
diff --git a/tests/fixtures/directv/tv-get-tuned-movie.json b/tests/fixtures/directv/tv-get-tuned-movie.json
new file mode 100644
index 00000000000..5411e7c7951
--- /dev/null
+++ b/tests/fixtures/directv/tv-get-tuned-movie.json
@@ -0,0 +1,24 @@
+{
+ "callsign": "HALLHD",
+ "date": "2013",
+ "duration": 7200,
+ "isOffAir": false,
+ "isPclocked": 3,
+ "isPpv": false,
+ "isRecording": false,
+ "isVod": false,
+ "major": 312,
+ "minor": 65535,
+ "offset": 4437,
+ "programId": "17016356",
+ "rating": "TV-G",
+ "startTime": 1584795600,
+ "stationId": 6580971,
+ "title": "Snow Bride",
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/tv/getTuned"
+ }
+}
diff --git a/tests/fixtures/directv/tv-get-tuned.json b/tests/fixtures/directv/tv-get-tuned.json
new file mode 100644
index 00000000000..dc4e4092003
--- /dev/null
+++ b/tests/fixtures/directv/tv-get-tuned.json
@@ -0,0 +1,32 @@
+{
+ "callsign": "FOODHD",
+ "date": "20070324",
+ "duration": 1791,
+ "episodeTitle": "Spaghetti and Clam Sauce",
+ "expiration": "0",
+ "expiryTime": 0,
+ "isOffAir": false,
+ "isPartial": false,
+ "isPclocked": 1,
+ "isPpv": false,
+ "isRecording": false,
+ "isViewed": true,
+ "isVod": false,
+ "keepUntilFull": true,
+ "major": 231,
+ "minor": 65535,
+ "offset": 263,
+ "programId": "4405732",
+ "rating": "No Rating",
+ "recType": 3,
+ "startTime": 1278342008,
+ "stationId": 3900976,
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/tv/getTuned"
+ },
+ "title": "Tyler's Ultimate",
+ "uniqueId": "6728716739474078694"
+}
diff --git a/tests/fixtures/directv/tv-tune.json b/tests/fixtures/directv/tv-tune.json
new file mode 100644
index 00000000000..39af4fe7a4e
--- /dev/null
+++ b/tests/fixtures/directv/tv-tune.json
@@ -0,0 +1,8 @@
+{
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK",
+ "query": "/tv/tune?major=508"
+ }
+}
diff --git a/tests/fixtures/homekit_controller/rainmachine-pro-8.json b/tests/fixtures/homekit_controller/rainmachine-pro-8.json
new file mode 100644
index 00000000000..1b50063006e
--- /dev/null
+++ b/tests/fixtures/homekit_controller/rainmachine-pro-8.json
@@ -0,0 +1,1137 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "iid": 1,
+ "type": "0000003E-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 2,
+ "type": "00000014-0000-1000-8000-0026BB765291",
+ "format": "bool",
+ "perms": [
+ "pw"
+ ]
+ },
+ {
+ "iid": 3,
+ "type": "00000020-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "Green Electronics LLC",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ },
+ {
+ "iid": 4,
+ "type": "00000021-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "SPK5 Pro",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ },
+ {
+ "iid": 5,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "RainMachine-00ce4a",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ },
+ {
+ "iid": 6,
+ "type": "00000030-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "00aa0000aa0a",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ },
+ {
+ "iid": 7,
+ "type": "00000052-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "1.0.4",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ },
+ {
+ "iid": 8,
+ "type": "00000053-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "1",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ },
+ {
+ "iid": 9,
+ "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B",
+ "format": "string",
+ "value": "2.0;16A62",
+ "perms": [
+ "pr",
+ "hd"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 16,
+ "type": "000000A2-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 18,
+ "type": "00000037-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "1.1.0",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 64,
+ "type": "000000CF-0000-1000-8000-0026BB765291",
+ "primary": true,
+ "hidden": false,
+ "linked": [
+ 512,
+ 768,
+ 1024,
+ 1280,
+ 1536,
+ 1792,
+ 2048,
+ 2304
+ ],
+ "characteristics": [
+ {
+ "iid": 67,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 65,
+ "type": "000000D1-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 2,
+ "minStep": 1
+ },
+ {
+ "iid": 68,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 66,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "RainMachine",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 512,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 544,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 560,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 576,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 592,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 608,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 624,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 640,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 528,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 768,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 800,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 816,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 832,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 848,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 864,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 880,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 896,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 2,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 784,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 1024,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 1056,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1072,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1088,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 1104,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1120,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1136,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1152,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 3,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 1040,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 1280,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 1312,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1328,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1344,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 1360,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1376,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1392,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1408,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 4,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 1296,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 1536,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 1568,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1584,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1600,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 1616,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1632,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1648,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1664,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 5,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 1552,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 1792,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 1824,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1840,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1856,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 1872,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 1888,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1904,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 1920,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 6,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 1808,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 2048,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 2080,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 2096,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 2112,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 2128,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 2144,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 2160,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 2176,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 7,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 2064,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ },
+ {
+ "iid": 2304,
+ "type": "000000D0-0000-1000-8000-0026BB765291",
+ "primary": false,
+ "hidden": false,
+ "linked": [],
+ "characteristics": [
+ {
+ "iid": 2336,
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 2352,
+ "type": "000000D2-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 2368,
+ "type": "000000D5-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1
+ },
+ {
+ "iid": 2384,
+ "type": "000000D6-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1
+ },
+ {
+ "iid": 2400,
+ "type": "000000D4-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 2416,
+ "type": "000000D3-0000-1000-8000-0026BB765291",
+ "format": "uint32",
+ "value": 300,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "ev": false,
+ "minValue": 0,
+ "maxValue": 86400,
+ "minStep": 1
+ },
+ {
+ "iid": 2432,
+ "type": "000000CB-0000-1000-8000-0026BB765291",
+ "format": "uint8",
+ "value": 8,
+ "perms": [
+ "pr"
+ ],
+ "ev": false,
+ "minValue": 1,
+ "maxValue": 255,
+ "minStep": 1
+ },
+ {
+ "iid": 2320,
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "format": "string",
+ "value": "",
+ "perms": [
+ "pr"
+ ],
+ "ev": false
+ }
+ ]
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json
index a97b1247e2c..e85401aa1ec 100644
--- a/tests/fixtures/homematicip_cloud.json
+++ b/tests/fixtures/homematicip_cloud.json
@@ -1591,7 +1591,7 @@
"groupIndex": 1,
"groups": [],
"index": 1,
- "label": "",
+ "label": "Treppe CH",
"on": true,
"profileMode": "AUTOMATIC",
"userDesiredProfileMode": "AUTOMATIC"
@@ -1603,7 +1603,7 @@
"groupIndex": 2,
"groups": [],
"index": 2,
- "label": "",
+ "label": "Alarm Status",
"on": null,
"profileMode": "AUTOMATIC",
"simpleRGBColorState": "RED",
@@ -4576,7 +4576,7 @@
"00000000-0000-0000-0000-000000000042"
],
"index": 1,
- "label": "",
+ "label": "SW1",
"on": false,
"profileMode": "AUTOMATIC",
"userDesiredProfileMode": "AUTOMATIC"
@@ -4590,7 +4590,7 @@
"00000000-0000-0000-0000-000000000040"
],
"index": 2,
- "label": "",
+ "label": "SW2",
"on": false,
"profileMode": "AUTOMATIC",
"userDesiredProfileMode": "AUTOMATIC"
diff --git a/tests/fixtures/ipp/get-printer-attributes.bin b/tests/fixtures/ipp/get-printer-attributes.bin
new file mode 100644
index 00000000000..24b903efc5d
Binary files /dev/null and b/tests/fixtures/ipp/get-printer-attributes.bin differ
diff --git a/tests/fixtures/myq/devices.json b/tests/fixtures/myq/devices.json
new file mode 100644
index 00000000000..f7c65c6bb20
--- /dev/null
+++ b/tests/fixtures/myq/devices.json
@@ -0,0 +1,133 @@
+{
+ "count" : 4,
+ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices",
+ "items" : [
+ {
+ "device_type" : "ethernetgateway",
+ "created_date" : "2020-02-10T22:54:58.423",
+ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial",
+ "device_family" : "gateway",
+ "name" : "Happy place",
+ "device_platform" : "myq",
+ "state" : {
+ "homekit_enabled" : false,
+ "pending_bootload_abandoned" : false,
+ "online" : true,
+ "last_status" : "2020-03-30T02:49:46.4121303Z",
+ "physical_devices" : [],
+ "firmware_version" : "1.6",
+ "learn_mode" : false,
+ "learn" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial/learn",
+ "homekit_capable" : false,
+ "updated_date" : "2020-03-30T02:49:46.4171299Z"
+ },
+ "serial_number" : "gateway_serial"
+ },
+ {
+ "serial_number" : "gate_serial",
+ "state" : {
+ "report_ajar" : false,
+ "aux_relay_delay" : "00:00:00",
+ "is_unattended_close_allowed" : true,
+ "door_ajar_interval" : "00:00:00",
+ "aux_relay_behavior" : "None",
+ "last_status" : "2020-03-30T02:47:40.2794038Z",
+ "online" : true,
+ "rex_fires_door" : false,
+ "close" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/close",
+ "invalid_shutout_period" : "00:00:00",
+ "invalid_credential_window" : "00:00:00",
+ "use_aux_relay" : false,
+ "command_channel_report_status" : false,
+ "last_update" : "2020-03-28T23:07:39.5611776Z",
+ "door_state" : "closed",
+ "max_invalid_attempts" : 0,
+ "open" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/open",
+ "passthrough_interval" : "00:00:00",
+ "control_from_browser" : false,
+ "report_forced" : false,
+ "is_unattended_open_allowed" : true
+ },
+ "parent_device_id" : "gateway_serial",
+ "name" : "Gate",
+ "device_platform" : "myq",
+ "device_family" : "garagedoor",
+ "parent_device" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial",
+ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial",
+ "device_type" : "gate",
+ "created_date" : "2020-02-10T22:54:58.423"
+ },
+ {
+ "parent_device" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial",
+ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial",
+ "device_type" : "wifigaragedooropener",
+ "created_date" : "2020-02-10T22:55:25.863",
+ "device_platform" : "myq",
+ "name" : "Large Garage Door",
+ "device_family" : "garagedoor",
+ "serial_number" : "large_garage_serial",
+ "state" : {
+ "report_forced" : false,
+ "is_unattended_open_allowed" : true,
+ "passthrough_interval" : "00:00:00",
+ "control_from_browser" : false,
+ "attached_work_light_error_present" : false,
+ "max_invalid_attempts" : 0,
+ "open" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/open",
+ "command_channel_report_status" : false,
+ "last_update" : "2020-03-28T23:58:55.5906643Z",
+ "door_state" : "closed",
+ "invalid_shutout_period" : "00:00:00",
+ "use_aux_relay" : false,
+ "invalid_credential_window" : "00:00:00",
+ "rex_fires_door" : false,
+ "close" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/close",
+ "online" : true,
+ "last_status" : "2020-03-30T02:49:46.4121303Z",
+ "aux_relay_behavior" : "None",
+ "door_ajar_interval" : "00:00:00",
+ "gdo_lock_connected" : false,
+ "report_ajar" : false,
+ "aux_relay_delay" : "00:00:00",
+ "is_unattended_close_allowed" : true
+ },
+ "parent_device_id" : "gateway_serial"
+ },
+ {
+ "serial_number" : "small_garage_serial",
+ "state" : {
+ "last_status" : "2020-03-30T02:48:45.7501595Z",
+ "online" : true,
+ "report_ajar" : false,
+ "aux_relay_delay" : "00:00:00",
+ "is_unattended_close_allowed" : true,
+ "gdo_lock_connected" : false,
+ "door_ajar_interval" : "00:00:00",
+ "aux_relay_behavior" : "None",
+ "attached_work_light_error_present" : false,
+ "control_from_browser" : false,
+ "passthrough_interval" : "00:00:00",
+ "is_unattended_open_allowed" : true,
+ "report_forced" : false,
+ "close" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/close",
+ "rex_fires_door" : false,
+ "invalid_credential_window" : "00:00:00",
+ "use_aux_relay" : false,
+ "invalid_shutout_period" : "00:00:00",
+ "door_state" : "closed",
+ "last_update" : "2020-03-26T15:45:31.4713796Z",
+ "command_channel_report_status" : false,
+ "open" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/open",
+ "max_invalid_attempts" : 0
+ },
+ "parent_device_id" : "gateway_serial",
+ "device_platform" : "myq",
+ "name" : "Small Garage Door",
+ "device_family" : "garagedoor",
+ "parent_device" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial",
+ "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial",
+ "device_type" : "wifigaragedooropener",
+ "created_date" : "2020-02-10T23:11:47.487"
+ }
+ ]
+}
diff --git a/tests/fixtures/nexia/mobile_houses_123456.json b/tests/fixtures/nexia/mobile_houses_123456.json
new file mode 100644
index 00000000000..2bf3aa123b0
--- /dev/null
+++ b/tests/fixtures/nexia/mobile_houses_123456.json
@@ -0,0 +1,8036 @@
+{
+ "success": true,
+ "error": null,
+ "result": {
+ "id": 123456,
+ "name": "Hidden",
+ "third_party_integrations": [],
+ "latitude": 12.7633,
+ "longitude": -12.3633,
+ "dealer_opt_in": true,
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/houses/123456"
+ },
+ "edit": [{
+ "href": "https://www.mynexia.com/mobile/houses/123456/edit",
+ "method": "GET"
+ }],
+ "child": [{
+ "href": "https://www.mynexia.com/mobile/houses/123456/devices",
+ "type": "application/vnd.nexia.collection+json",
+ "data": {
+ "items": [{
+ "id": 2059661,
+ "name": "Downstairs East Wing",
+ "name_editable": true,
+ "features": [{
+ "name": "advanced_info",
+ "items": [{
+ "type": "label_value",
+ "label": "Model",
+ "value": "XL1050"
+ }, {
+ "type": "label_value",
+ "label": "AUID",
+ "value": "000000"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Number",
+ "value": "1581321824"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Date",
+ "value": "2020-02-10 08:03:44 UTC"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Version",
+ "value": "5.9.1"
+ }, {
+ "type": "label_value",
+ "label": "Zoning Enabled",
+ "value": "yes"
+ }]
+ }, {
+ "name": "thermostat",
+ "temperature": 71,
+ "status": "System Idle",
+ "status_icon": null,
+ "actions": {},
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "group",
+ "members": [{
+ "type": "xxl_zone",
+ "id": 83261002,
+ "name": "Living East",
+ "current_zone_mode": "AUTO",
+ "temperature": 71,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-71"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 71,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261005,
+ "name": "Kitchen",
+ "current_zone_mode": "AUTO",
+ "temperature": 77,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-77"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 77,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261008,
+ "name": "Down Bedroom",
+ "current_zone_mode": "AUTO",
+ "temperature": 72,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-72"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 72,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261011,
+ "name": "Tech Room",
+ "current_zone_mode": "AUTO",
+ "temperature": 78,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-78"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 78,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011"
+ }
+ }
+ }]
+ }, {
+ "name": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "options": [{
+ "id": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "value": "thermostat_fan_mode",
+ "header": true
+ }, {
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "value": "auto",
+ "display_value": "Auto",
+ "status_icon": {
+ "name": "thermostat_fan_off",
+ "modifiers": []
+ },
+ "actions": {
+ "update_thermostat_fan_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_compressor_speed",
+ "compressor_speed": 0.0
+ }, {
+ "name": "runtime_history",
+ "actions": {
+ "get_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2059661?report_type=daily"
+ },
+ "get_monthly_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2059661?report_type=monthly"
+ }
+ }
+ }],
+ "icon": [{
+ "name": "thermostat",
+ "modifiers": ["temperature-71"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-77"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-72"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-78"]
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=2059661"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953"
+ },
+ "pending_request": {
+ "polling_path": "https://www.mynexia.com/backstage/announcements/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63"
+ }
+ },
+ "last_updated_at": "2020-03-11T15:15:53.000-05:00",
+ "settings": [{
+ "type": "fan_mode",
+ "title": "Fan Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "labels": ["Auto", "On", "Circulate"],
+ "values": ["auto", "on", "circulate"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode"
+ }
+ }
+ }, {
+ "type": "fan_speed",
+ "title": "Fan Speed",
+ "current_value": 0.35,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }, {
+ "value": 0.7,
+ "label": "70%"
+ }, {
+ "value": 0.75,
+ "label": "75%"
+ }, {
+ "value": 0.8,
+ "label": "80%"
+ }, {
+ "value": 0.85,
+ "label": "85%"
+ }, {
+ "value": 0.9,
+ "label": "90%"
+ }, {
+ "value": 0.95,
+ "label": "95%"
+ }, {
+ "value": 1.0,
+ "label": "100%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%", "100%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_speed"
+ }
+ }
+ }, {
+ "type": "fan_circulation_time",
+ "title": "Fan Circulation Time",
+ "current_value": 30,
+ "options": [{
+ "value": 10,
+ "label": "10 minutes"
+ }, {
+ "value": 15,
+ "label": "15 minutes"
+ }, {
+ "value": 20,
+ "label": "20 minutes"
+ }, {
+ "value": 25,
+ "label": "25 minutes"
+ }, {
+ "value": 30,
+ "label": "30 minutes"
+ }, {
+ "value": 35,
+ "label": "35 minutes"
+ }, {
+ "value": 40,
+ "label": "40 minutes"
+ }, {
+ "value": 45,
+ "label": "45 minutes"
+ }, {
+ "value": 50,
+ "label": "50 minutes"
+ }, {
+ "value": 55,
+ "label": "55 minutes"
+ }],
+ "labels": ["10 minutes", "15 minutes", "20 minutes", "25 minutes", "30 minutes", "35 minutes", "40 minutes", "45 minutes", "50 minutes", "55 minutes"],
+ "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_circulation_time"
+ }
+ }
+ }, {
+ "type": "air_cleaner_mode",
+ "title": "Air Cleaner Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "quick",
+ "label": "Quick"
+ }, {
+ "value": "allergy",
+ "label": "Allergy"
+ }],
+ "labels": ["Auto", "Quick", "Allergy"],
+ "values": ["auto", "quick", "allergy"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/air_cleaner_mode"
+ }
+ }
+ }, {
+ "type": "dehumidify",
+ "title": "Cooling Dehumidify Set Point",
+ "current_value": 0.5,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/dehumidify"
+ }
+ }
+ }, {
+ "type": "scale",
+ "title": "Temperature Scale",
+ "current_value": "f",
+ "options": [{
+ "value": "f",
+ "label": "F"
+ }, {
+ "value": "c",
+ "label": "C"
+ }],
+ "labels": ["F", "C"],
+ "values": ["f", "c"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661/scale"
+ }
+ }
+ }],
+ "status_secondary": null,
+ "status_tertiary": null,
+ "type": "xxl_thermostat",
+ "has_outdoor_temperature": true,
+ "outdoor_temperature": "88",
+ "has_indoor_humidity": true,
+ "connected": true,
+ "indoor_humidity": "36",
+ "system_status": "System Idle",
+ "delta": 3,
+ "zones": [{
+ "type": "xxl_zone",
+ "id": 83261002,
+ "name": "Living East",
+ "current_zone_mode": "AUTO",
+ "temperature": 71,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-71"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 71,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261002"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261005,
+ "name": "Kitchen",
+ "current_zone_mode": "AUTO",
+ "temperature": 77,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-77"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 77,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261005"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261008,
+ "name": "Down Bedroom",
+ "current_zone_mode": "AUTO",
+ "temperature": 72,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-72"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 72,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261008"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261011,
+ "name": "Tech Room",
+ "current_zone_mode": "AUTO",
+ "temperature": 78,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-78"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 78,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261011"
+ }
+ }
+ }]
+ }, {
+ "id": 2059676,
+ "name": "Downstairs West Wing",
+ "name_editable": true,
+ "features": [{
+ "name": "advanced_info",
+ "items": [{
+ "type": "label_value",
+ "label": "Model",
+ "value": "XL1050"
+ }, {
+ "type": "label_value",
+ "label": "AUID",
+ "value": "02853E08"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Number",
+ "value": "1581321824"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Date",
+ "value": "2020-02-10 08:03:44 UTC"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Version",
+ "value": "5.9.1"
+ }, {
+ "type": "label_value",
+ "label": "Zoning Enabled",
+ "value": "yes"
+ }]
+ }, {
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "System Idle",
+ "status_icon": null,
+ "actions": {},
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "group",
+ "members": [{
+ "type": "xxl_zone",
+ "id": 83261015,
+ "name": "Living West",
+ "current_zone_mode": "AUTO",
+ "temperature": 75,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261018,
+ "name": "David Office",
+ "current_zone_mode": "AUTO",
+ "temperature": 75,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018"
+ }
+ }
+ }]
+ }, {
+ "name": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "options": [{
+ "id": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "value": "thermostat_fan_mode",
+ "header": true
+ }, {
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "value": "auto",
+ "display_value": "Auto",
+ "status_icon": {
+ "name": "thermostat_fan_off",
+ "modifiers": []
+ },
+ "actions": {
+ "update_thermostat_fan_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_compressor_speed",
+ "compressor_speed": 0.0
+ }, {
+ "name": "runtime_history",
+ "actions": {
+ "get_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2059676?report_type=daily"
+ },
+ "get_monthly_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2059676?report_type=monthly"
+ }
+ }
+ }],
+ "icon": [{
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=2059676"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c"
+ },
+ "pending_request": {
+ "polling_path": "https://www.mynexia.com/backstage/announcements/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb"
+ }
+ },
+ "last_updated_at": "2020-03-11T15:15:53.000-05:00",
+ "settings": [{
+ "type": "fan_mode",
+ "title": "Fan Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "labels": ["Auto", "On", "Circulate"],
+ "values": ["auto", "on", "circulate"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode"
+ }
+ }
+ }, {
+ "type": "fan_speed",
+ "title": "Fan Speed",
+ "current_value": 0.35,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }, {
+ "value": 0.7,
+ "label": "70%"
+ }, {
+ "value": 0.75,
+ "label": "75%"
+ }, {
+ "value": 0.8,
+ "label": "80%"
+ }, {
+ "value": 0.85,
+ "label": "85%"
+ }, {
+ "value": 0.9,
+ "label": "90%"
+ }, {
+ "value": 0.95,
+ "label": "95%"
+ }, {
+ "value": 1.0,
+ "label": "100%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%", "100%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_speed"
+ }
+ }
+ }, {
+ "type": "fan_circulation_time",
+ "title": "Fan Circulation Time",
+ "current_value": 30,
+ "options": [{
+ "value": 10,
+ "label": "10 minutes"
+ }, {
+ "value": 15,
+ "label": "15 minutes"
+ }, {
+ "value": 20,
+ "label": "20 minutes"
+ }, {
+ "value": 25,
+ "label": "25 minutes"
+ }, {
+ "value": 30,
+ "label": "30 minutes"
+ }, {
+ "value": 35,
+ "label": "35 minutes"
+ }, {
+ "value": 40,
+ "label": "40 minutes"
+ }, {
+ "value": 45,
+ "label": "45 minutes"
+ }, {
+ "value": 50,
+ "label": "50 minutes"
+ }, {
+ "value": 55,
+ "label": "55 minutes"
+ }],
+ "labels": ["10 minutes", "15 minutes", "20 minutes", "25 minutes", "30 minutes", "35 minutes", "40 minutes", "45 minutes", "50 minutes", "55 minutes"],
+ "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_circulation_time"
+ }
+ }
+ }, {
+ "type": "air_cleaner_mode",
+ "title": "Air Cleaner Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "quick",
+ "label": "Quick"
+ }, {
+ "value": "allergy",
+ "label": "Allergy"
+ }],
+ "labels": ["Auto", "Quick", "Allergy"],
+ "values": ["auto", "quick", "allergy"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/air_cleaner_mode"
+ }
+ }
+ }, {
+ "type": "dehumidify",
+ "title": "Cooling Dehumidify Set Point",
+ "current_value": 0.45,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/dehumidify"
+ }
+ }
+ }, {
+ "type": "scale",
+ "title": "Temperature Scale",
+ "current_value": "f",
+ "options": [{
+ "value": "f",
+ "label": "F"
+ }, {
+ "value": "c",
+ "label": "C"
+ }],
+ "labels": ["F", "C"],
+ "values": ["f", "c"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676/scale"
+ }
+ }
+ }],
+ "status_secondary": null,
+ "status_tertiary": null,
+ "type": "xxl_thermostat",
+ "has_outdoor_temperature": true,
+ "outdoor_temperature": "88",
+ "has_indoor_humidity": true,
+ "connected": true,
+ "indoor_humidity": "52",
+ "system_status": "System Idle",
+ "delta": 3,
+ "zones": [{
+ "type": "xxl_zone",
+ "id": 83261015,
+ "name": "Living West",
+ "current_zone_mode": "AUTO",
+ "temperature": 75,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261015"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83261018,
+ "name": "David Office",
+ "current_zone_mode": "AUTO",
+ "temperature": 75,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83261018"
+ }
+ }
+ }]
+ }, {
+ "id": 2293892,
+ "name": "Master Suite",
+ "name_editable": true,
+ "features": [{
+ "name": "advanced_info",
+ "items": [{
+ "type": "label_value",
+ "label": "Model",
+ "value": "XL1050"
+ }, {
+ "type": "label_value",
+ "label": "AUID",
+ "value": "0281B02C"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Number",
+ "value": "1581321824"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Date",
+ "value": "2020-02-10 08:03:44 UTC"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Version",
+ "value": "5.9.1"
+ }, {
+ "type": "label_value",
+ "label": "Zoning Enabled",
+ "value": "yes"
+ }]
+ }, {
+ "name": "thermostat",
+ "temperature": 73,
+ "status": "Cooling",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {},
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "group",
+ "members": [{
+ "type": "xxl_zone",
+ "id": 83394133,
+ "name": "Bath Closet",
+ "current_zone_mode": "AUTO",
+ "temperature": 73,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Relieving Air",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Relieving Air",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-73"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 73,
+ "status": "Relieving Air",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394130,
+ "name": "Master",
+ "current_zone_mode": "AUTO",
+ "temperature": 74,
+ "setpoints": {
+ "heat": 63,
+ "cool": 71
+ },
+ "operating_state": "Damper Open",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 71,
+ "zone_status": "Damper Open",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 74,
+ "status": "Damper Open",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 71,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394136,
+ "name": "Nick Office",
+ "current_zone_mode": "AUTO",
+ "temperature": 73,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Relieving Air",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Relieving Air",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-73"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 73,
+ "status": "Relieving Air",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394127,
+ "name": "Snooze Room",
+ "current_zone_mode": "AUTO",
+ "temperature": 72,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Damper Closed",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Damper Closed",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-72"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 72,
+ "status": "Damper Closed",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394139,
+ "name": "Safe Room",
+ "current_zone_mode": "AUTO",
+ "temperature": 74,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Damper Closed",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Damper Closed",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 74,
+ "status": "Damper Closed",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139"
+ }
+ }
+ }]
+ }, {
+ "name": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "options": [{
+ "id": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "value": "thermostat_fan_mode",
+ "header": true
+ }, {
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "value": "auto",
+ "display_value": "Auto",
+ "status_icon": {
+ "name": "thermostat_fan_on",
+ "modifiers": []
+ },
+ "actions": {
+ "update_thermostat_fan_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_compressor_speed",
+ "compressor_speed": 0.69
+ }, {
+ "name": "runtime_history",
+ "actions": {
+ "get_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2293892?report_type=daily"
+ },
+ "get_monthly_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2293892?report_type=monthly"
+ }
+ }
+ }],
+ "icon": [{
+ "name": "thermostat",
+ "modifiers": ["temperature-73"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-73"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-72"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=2293892"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0"
+ },
+ "pending_request": {
+ "polling_path": "https://www.mynexia.com/backstage/announcements/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e"
+ }
+ },
+ "last_updated_at": "2020-03-11T15:15:53.000-05:00",
+ "settings": [{
+ "type": "fan_mode",
+ "title": "Fan Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "labels": ["Auto", "On", "Circulate"],
+ "values": ["auto", "on", "circulate"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode"
+ }
+ }
+ }, {
+ "type": "fan_speed",
+ "title": "Fan Speed",
+ "current_value": 0.35,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }, {
+ "value": 0.7,
+ "label": "70%"
+ }, {
+ "value": 0.75,
+ "label": "75%"
+ }, {
+ "value": 0.8,
+ "label": "80%"
+ }, {
+ "value": 0.85,
+ "label": "85%"
+ }, {
+ "value": 0.9,
+ "label": "90%"
+ }, {
+ "value": 0.95,
+ "label": "95%"
+ }, {
+ "value": 1.0,
+ "label": "100%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%", "100%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_speed"
+ }
+ }
+ }, {
+ "type": "fan_circulation_time",
+ "title": "Fan Circulation Time",
+ "current_value": 30,
+ "options": [{
+ "value": 10,
+ "label": "10 minutes"
+ }, {
+ "value": 15,
+ "label": "15 minutes"
+ }, {
+ "value": 20,
+ "label": "20 minutes"
+ }, {
+ "value": 25,
+ "label": "25 minutes"
+ }, {
+ "value": 30,
+ "label": "30 minutes"
+ }, {
+ "value": 35,
+ "label": "35 minutes"
+ }, {
+ "value": 40,
+ "label": "40 minutes"
+ }, {
+ "value": 45,
+ "label": "45 minutes"
+ }, {
+ "value": 50,
+ "label": "50 minutes"
+ }, {
+ "value": 55,
+ "label": "55 minutes"
+ }],
+ "labels": ["10 minutes", "15 minutes", "20 minutes", "25 minutes", "30 minutes", "35 minutes", "40 minutes", "45 minutes", "50 minutes", "55 minutes"],
+ "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_circulation_time"
+ }
+ }
+ }, {
+ "type": "air_cleaner_mode",
+ "title": "Air Cleaner Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "quick",
+ "label": "Quick"
+ }, {
+ "value": "allergy",
+ "label": "Allergy"
+ }],
+ "labels": ["Auto", "Quick", "Allergy"],
+ "values": ["auto", "quick", "allergy"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/air_cleaner_mode"
+ }
+ }
+ }, {
+ "type": "dehumidify",
+ "title": "Cooling Dehumidify Set Point",
+ "current_value": 0.45,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/dehumidify"
+ }
+ }
+ }, {
+ "type": "scale",
+ "title": "Temperature Scale",
+ "current_value": "f",
+ "options": [{
+ "value": "f",
+ "label": "F"
+ }, {
+ "value": "c",
+ "label": "C"
+ }],
+ "labels": ["F", "C"],
+ "values": ["f", "c"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/scale"
+ }
+ }
+ }],
+ "status_secondary": null,
+ "status_tertiary": null,
+ "type": "xxl_thermostat",
+ "has_outdoor_temperature": true,
+ "outdoor_temperature": "87",
+ "has_indoor_humidity": true,
+ "connected": true,
+ "indoor_humidity": "52",
+ "system_status": "Cooling",
+ "delta": 3,
+ "zones": [{
+ "type": "xxl_zone",
+ "id": 83394133,
+ "name": "Bath Closet",
+ "current_zone_mode": "AUTO",
+ "temperature": 73,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Relieving Air",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Relieving Air",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-73"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 73,
+ "status": "Relieving Air",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394133"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394130,
+ "name": "Master",
+ "current_zone_mode": "AUTO",
+ "temperature": 74,
+ "setpoints": {
+ "heat": 63,
+ "cool": 71
+ },
+ "operating_state": "Damper Open",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 71,
+ "zone_status": "Damper Open",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 74,
+ "status": "Damper Open",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 71,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394130"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394136,
+ "name": "Nick Office",
+ "current_zone_mode": "AUTO",
+ "temperature": 73,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Relieving Air",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Relieving Air",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-73"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 73,
+ "status": "Relieving Air",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394136"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394127,
+ "name": "Snooze Room",
+ "current_zone_mode": "AUTO",
+ "temperature": 72,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Damper Closed",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Damper Closed",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-72"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 72,
+ "status": "Damper Closed",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394127"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83394139,
+ "name": "Safe Room",
+ "current_zone_mode": "AUTO",
+ "temperature": 74,
+ "setpoints": {
+ "heat": 63,
+ "cool": 79
+ },
+ "operating_state": "Damper Closed",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 79,
+ "zone_status": "Damper Closed",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 74,
+ "status": "Damper Closed",
+ "status_icon": {
+ "name": "cooling",
+ "modifiers": []
+ },
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 79,
+ "system_status": "Cooling"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83394139"
+ }
+ }
+ }]
+ }, {
+ "id": 2059652,
+ "name": "Upstairs West Wing",
+ "name_editable": true,
+ "features": [{
+ "name": "advanced_info",
+ "items": [{
+ "type": "label_value",
+ "label": "Model",
+ "value": "XL1050"
+ }, {
+ "type": "label_value",
+ "label": "AUID",
+ "value": "02853DF0"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Number",
+ "value": "1581321824"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Build Date",
+ "value": "2020-02-10 08:03:44 UTC"
+ }, {
+ "type": "label_value",
+ "label": "Firmware Version",
+ "value": "5.9.1"
+ }, {
+ "type": "label_value",
+ "label": "Zoning Enabled",
+ "value": "yes"
+ }]
+ }, {
+ "name": "thermostat",
+ "temperature": 77,
+ "status": "System Idle",
+ "status_icon": null,
+ "actions": {},
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "group",
+ "members": [{
+ "type": "xxl_zone",
+ "id": 83260991,
+ "name": "Hallway",
+ "current_zone_mode": "OFF",
+ "temperature": 77,
+ "setpoints": {
+ "heat": 63,
+ "cool": 80
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 80,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "OFF",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-77"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 77,
+ "status": "",
+ "status_icon": null,
+ "actions": {},
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "OFF",
+ "display_value": "Off",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83260994,
+ "name": "Mid Bedroom",
+ "current_zone_mode": "AUTO",
+ "temperature": 74,
+ "setpoints": {
+ "heat": 63,
+ "cool": 81
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 81,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 74,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 81,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83260997,
+ "name": "West Bedroom",
+ "current_zone_mode": "AUTO",
+ "temperature": 75,
+ "setpoints": {
+ "heat": 63,
+ "cool": 81
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 81,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 81,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997"
+ }
+ }
+ }]
+ }, {
+ "name": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "options": [{
+ "id": "thermostat_fan_mode",
+ "label": "Fan Mode",
+ "value": "thermostat_fan_mode",
+ "header": true
+ }, {
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "value": "auto",
+ "display_value": "Auto",
+ "status_icon": {
+ "name": "thermostat_fan_off",
+ "modifiers": []
+ },
+ "actions": {
+ "update_thermostat_fan_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_compressor_speed",
+ "compressor_speed": 0.0
+ }, {
+ "name": "runtime_history",
+ "actions": {
+ "get_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2059652?report_type=daily"
+ },
+ "get_monthly_runtime_history": {
+ "method": "GET",
+ "href": "https://www.mynexia.com/mobile/runtime_history/2059652?report_type=monthly"
+ }
+ }
+ }],
+ "icon": [{
+ "name": "thermostat",
+ "modifiers": ["temperature-77"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ }, {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=2059652"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb"
+ },
+ "pending_request": {
+ "polling_path": "https://www.mynexia.com/backstage/announcements/c6627726f6339d104ee66897028d6a2ea38215675b336650"
+ }
+ },
+ "last_updated_at": "2020-03-11T15:15:53.000-05:00",
+ "settings": [{
+ "type": "fan_mode",
+ "title": "Fan Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "on",
+ "label": "On"
+ }, {
+ "value": "circulate",
+ "label": "Circulate"
+ }],
+ "labels": ["Auto", "On", "Circulate"],
+ "values": ["auto", "on", "circulate"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode"
+ }
+ }
+ }, {
+ "type": "fan_speed",
+ "title": "Fan Speed",
+ "current_value": 0.35,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }, {
+ "value": 0.7,
+ "label": "70%"
+ }, {
+ "value": 0.75,
+ "label": "75%"
+ }, {
+ "value": 0.8,
+ "label": "80%"
+ }, {
+ "value": 0.85,
+ "label": "85%"
+ }, {
+ "value": 0.9,
+ "label": "90%"
+ }, {
+ "value": 0.95,
+ "label": "95%"
+ }, {
+ "value": 1.0,
+ "label": "100%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%", "100%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_speed"
+ }
+ }
+ }, {
+ "type": "fan_circulation_time",
+ "title": "Fan Circulation Time",
+ "current_value": 30,
+ "options": [{
+ "value": 10,
+ "label": "10 minutes"
+ }, {
+ "value": 15,
+ "label": "15 minutes"
+ }, {
+ "value": 20,
+ "label": "20 minutes"
+ }, {
+ "value": 25,
+ "label": "25 minutes"
+ }, {
+ "value": 30,
+ "label": "30 minutes"
+ }, {
+ "value": 35,
+ "label": "35 minutes"
+ }, {
+ "value": 40,
+ "label": "40 minutes"
+ }, {
+ "value": 45,
+ "label": "45 minutes"
+ }, {
+ "value": 50,
+ "label": "50 minutes"
+ }, {
+ "value": 55,
+ "label": "55 minutes"
+ }],
+ "labels": ["10 minutes", "15 minutes", "20 minutes", "25 minutes", "30 minutes", "35 minutes", "40 minutes", "45 minutes", "50 minutes", "55 minutes"],
+ "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_circulation_time"
+ }
+ }
+ }, {
+ "type": "air_cleaner_mode",
+ "title": "Air Cleaner Mode",
+ "current_value": "auto",
+ "options": [{
+ "value": "auto",
+ "label": "Auto"
+ }, {
+ "value": "quick",
+ "label": "Quick"
+ }, {
+ "value": "allergy",
+ "label": "Allergy"
+ }],
+ "labels": ["Auto", "Quick", "Allergy"],
+ "values": ["auto", "quick", "allergy"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/air_cleaner_mode"
+ }
+ }
+ }, {
+ "type": "dehumidify",
+ "title": "Cooling Dehumidify Set Point",
+ "current_value": 0.5,
+ "options": [{
+ "value": 0.35,
+ "label": "35%"
+ }, {
+ "value": 0.4,
+ "label": "40%"
+ }, {
+ "value": 0.45,
+ "label": "45%"
+ }, {
+ "value": 0.5,
+ "label": "50%"
+ }, {
+ "value": 0.55,
+ "label": "55%"
+ }, {
+ "value": 0.6,
+ "label": "60%"
+ }, {
+ "value": 0.65,
+ "label": "65%"
+ }],
+ "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"],
+ "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/dehumidify"
+ }
+ }
+ }, {
+ "type": "scale",
+ "title": "Temperature Scale",
+ "current_value": "f",
+ "options": [{
+ "value": "f",
+ "label": "F"
+ }, {
+ "value": "c",
+ "label": "C"
+ }],
+ "labels": ["F", "C"],
+ "values": ["f", "c"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652/scale"
+ }
+ }
+ }],
+ "status_secondary": null,
+ "status_tertiary": null,
+ "type": "xxl_thermostat",
+ "has_outdoor_temperature": true,
+ "outdoor_temperature": "87",
+ "has_indoor_humidity": true,
+ "connected": true,
+ "indoor_humidity": "37",
+ "system_status": "System Idle",
+ "delta": 3,
+ "zones": [{
+ "type": "xxl_zone",
+ "id": 83260991,
+ "name": "Hallway",
+ "current_zone_mode": "OFF",
+ "temperature": 77,
+ "setpoints": {
+ "heat": 63,
+ "cool": 80
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 80,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "OFF",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-77"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 77,
+ "status": "",
+ "status_icon": null,
+ "actions": {},
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "OFF",
+ "display_value": "Off",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260991"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83260994,
+ "name": "Mid Bedroom",
+ "current_zone_mode": "AUTO",
+ "temperature": 74,
+ "setpoints": {
+ "heat": 63,
+ "cool": 81
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 81,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-74"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 74,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 81,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260994"
+ }
+ }
+ }, {
+ "type": "xxl_zone",
+ "id": 83260997,
+ "name": "West Bedroom",
+ "current_zone_mode": "AUTO",
+ "temperature": 75,
+ "setpoints": {
+ "heat": 63,
+ "cool": 81
+ },
+ "operating_state": "",
+ "heating_setpoint": 63,
+ "cooling_setpoint": 81,
+ "zone_status": "",
+ "settings": [{
+ "type": "preset_selected",
+ "title": "Preset",
+ "current_value": 0,
+ "options": [{
+ "value": 0,
+ "label": "None"
+ }, {
+ "value": 1,
+ "label": "Home"
+ }, {
+ "value": 2,
+ "label": "Away"
+ }, {
+ "value": 3,
+ "label": "Sleep"
+ }],
+ "labels": ["None", "Home", "Away", "Sleep"],
+ "values": [0, 1, 2, 3],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected"
+ }
+ }
+ }, {
+ "type": "zone_mode",
+ "title": "Zone Mode",
+ "current_value": "AUTO",
+ "options": [{
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "labels": ["Auto", "Cooling", "Heating", "Off"],
+ "values": ["AUTO", "COOL", "HEAT", "OFF"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode"
+ }
+ }
+ }, {
+ "type": "run_mode",
+ "title": "Run Mode",
+ "current_value": "permanent_hold",
+ "options": [{
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "labels": ["Permanent Hold", "Run Schedule"],
+ "values": ["permanent_hold", "run_schedule"],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode"
+ }
+ }
+ }, {
+ "type": "scheduling_enabled",
+ "title": "Scheduling",
+ "current_value": true,
+ "options": [{
+ "value": true,
+ "label": "ON"
+ }, {
+ "value": false,
+ "label": "OFF"
+ }],
+ "labels": ["ON", "OFF"],
+ "values": [true, false],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled"
+ }
+ }
+ }],
+ "icon": {
+ "name": "thermostat",
+ "modifiers": ["temperature-75"]
+ },
+ "features": [{
+ "name": "thermostat",
+ "temperature": 75,
+ "status": "",
+ "status_icon": null,
+ "actions": {
+ "set_heat_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints"
+ },
+ "set_cool_setpoint": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints"
+ }
+ },
+ "setpoint_delta": 3,
+ "scale": "f",
+ "setpoint_increment": 1.0,
+ "setpoint_heat_min": 55,
+ "setpoint_heat_max": 90,
+ "setpoint_cool_min": 60,
+ "setpoint_cool_max": 99,
+ "setpoint_heat": 63,
+ "setpoint_cool": 81,
+ "system_status": "System Idle"
+ }, {
+ "name": "connection",
+ "signal_strength": "unknown",
+ "is_connected": true
+ }, {
+ "name": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "AUTO",
+ "display_value": "Auto",
+ "options": [{
+ "id": "thermostat_mode",
+ "label": "Zone Mode",
+ "value": "thermostat_mode",
+ "header": true
+ }, {
+ "value": "AUTO",
+ "label": "Auto"
+ }, {
+ "value": "COOL",
+ "label": "Cooling"
+ }, {
+ "value": "HEAT",
+ "label": "Heating"
+ }, {
+ "value": "OFF",
+ "label": "Off"
+ }],
+ "actions": {
+ "update_thermostat_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode"
+ }
+ }
+ }, {
+ "name": "thermostat_run_mode",
+ "label": "Run Mode",
+ "options": [{
+ "id": "thermostat_run_mode",
+ "label": "Run Mode",
+ "value": "thermostat_run_mode",
+ "header": true
+ }, {
+ "id": "info_text",
+ "label": "Follow or override the schedule.",
+ "value": "info_text",
+ "info": true
+ }, {
+ "value": "permanent_hold",
+ "label": "Permanent Hold"
+ }, {
+ "value": "run_schedule",
+ "label": "Run Schedule"
+ }],
+ "value": "permanent_hold",
+ "display_value": "Hold",
+ "actions": {
+ "update_thermostat_run_mode": {
+ "method": "POST",
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode"
+ }
+ }
+ }, {
+ "name": "schedule",
+ "enabled": true,
+ "max_period_name_length": 10,
+ "setpoint_increment": 1,
+ "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997\u0026house_id=123456",
+ "actions": {
+ "get_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997",
+ "method": "POST"
+ },
+ "set_active_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997",
+ "method": "POST"
+ },
+ "get_default_schedule": {
+ "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997",
+ "method": "GET"
+ },
+ "enable_scheduling": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled",
+ "method": "POST",
+ "data": {
+ "value": true
+ }
+ }
+ },
+ "can_add_remove_periods": true,
+ "max_periods_per_day": 4
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/xxl_zones/83260997"
+ }
+ }
+ }]
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/devices"
+ },
+ "template": {
+ "data": {
+ "title": null,
+ "fields": [],
+ "_links": {
+ "child-schema": [{
+ "data": {
+ "label": "Connect New Device",
+ "icon": {
+ "name": "new_device",
+ "modifiers": []
+ },
+ "_links": {
+ "next": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/enrollables_schema"
+ }
+ }
+ }
+ }, {
+ "data": {
+ "label": "Create Group",
+ "icon": {
+ "name": "create_group",
+ "modifiers": []
+ },
+ "_links": {
+ "next": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/groups/new"
+ }
+ }
+ }
+ }]
+ }
+ }
+ }
+ },
+ "item_type": "application/vnd.nexia.device+json"
+ }
+ }, {
+ "href": "https://www.mynexia.com/mobile/houses/123456/automations",
+ "type": "application/vnd.nexia.collection+json",
+ "data": {
+ "items": [{
+ "id": 3467876,
+ "name": "Away for 12 Hours",
+ "enabled": true,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs East Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Activate the mode named 'Away 12' AND Master Suite will permanently hold the heat to 62.0 and cool to 83.0",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "plane",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3467876"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467876",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467876"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5"
+ }
+ }
+ }, {
+ "id": 3467870,
+ "name": "Away For 24 Hours",
+ "enabled": true,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Activate the mode named 'Away 24' AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "plane",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3467870"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467870",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467870"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15"
+ }
+ }
+ }, {
+ "id": 3452469,
+ "name": "Away Short",
+ "enabled": false,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 63.0 and cool to 80.0 AND Downstairs East Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Downstairs West Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Upstairs West Wing will permanently hold the heat to 63.0 and cool to 81.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Activate the mode named 'Away Short' AND Master Suite will permanently hold the heat to 63.0 and cool to 79.0 AND Master Suite will change Fan Mode to Auto",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "key",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3452469"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452469",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452469"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c"
+ }
+ }
+ }, {
+ "id": 3452472,
+ "name": "Home",
+ "enabled": true,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home' AND Master Suite will Run Schedule",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "at_home",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3452472"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452472",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452472"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4"
+ }
+ }
+ }, {
+ "id": 3454776,
+ "name": "IFTTT Power Spike",
+ "enabled": true,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0 AND Master Suite will change Fan Mode to Auto",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3454776"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454776",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454776"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a"
+ }
+ }
+ }, {
+ "id": 3454774,
+ "name": "IFTTT return to schedule",
+ "enabled": false,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Master Suite will Run Schedule",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3454774"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454774",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454774"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733"
+ }
+ }
+ }, {
+ "id": 3486078,
+ "name": "Power Outage",
+ "enabled": true,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs East Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Activate the mode named 'Power Outage'",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "climate",
+ "modifiers": []
+ }, {
+ "name": "bell",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3486078"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486078",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486078"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0"
+ }
+ }
+ }, {
+ "id": 3486091,
+ "name": "Power Restored",
+ "enabled": true,
+ "settings": [],
+ "triggers": [],
+ "description": "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home'",
+ "icon": [{
+ "name": "gears",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "settings",
+ "modifiers": []
+ }, {
+ "name": "at_home",
+ "modifiers": []
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/automations/3486091"
+ },
+ "edit": {
+ "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486091",
+ "method": "POST"
+ },
+ "nexia:history": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486091"
+ },
+ "filter_events": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a"
+ }
+ }
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/automations"
+ },
+ "template": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/automation_edit_buffers",
+ "method": "POST"
+ }
+ },
+ "item_type": "application/vnd.nexia.automation+json"
+ }
+ }, {
+ "href": "https://www.mynexia.com/mobile/houses/123456/modes",
+ "type": "application/vnd.nexia.collection+json",
+ "data": {
+ "items": [{
+ "id": 3047801,
+ "name": "Home",
+ "current_mode": false,
+ "icon": "home.png",
+ "settings": [],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/modes/3047801"
+ }
+ }
+ }, {
+ "id": 3174574,
+ "name": "Away Short",
+ "current_mode": true,
+ "icon": "key.png",
+ "settings": [],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/modes/3174574"
+ }
+ }
+ }, {
+ "id": 3174576,
+ "name": "Away 12",
+ "current_mode": false,
+ "icon": "picture.png",
+ "settings": [],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/modes/3174576"
+ }
+ }
+ }, {
+ "id": 3174577,
+ "name": "Away 24",
+ "current_mode": false,
+ "icon": "picture.png",
+ "settings": [],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/modes/3174577"
+ }
+ }
+ }, {
+ "id": 3197871,
+ "name": "Power Outage",
+ "current_mode": false,
+ "icon": "bell.png",
+ "settings": [],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/modes/3197871"
+ }
+ }
+ }],
+ "_links": {
+ "self": {
+ "href": "https://www.mynexia.com/mobile/houses/123456/modes"
+ }
+ },
+ "item_type": "application/vnd.nexia.mode+json"
+ }
+ }, {
+ "href": "https://www.mynexia.com/mobile/houses/123456/events/collection",
+ "type": "application/vnd.nexia.collection+json",
+ "data": {
+ "item_type": "application/vnd.nexia.event+json"
+ }
+ }, {
+ "href": "https://www.mynexia.com/mobile/houses/123456/videos/collection",
+ "type": "application/vnd.nexia.collection+json",
+ "data": {
+ "item_type": "application/vnd.nexia.video+json"
+ }
+ }]
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/nexia/session_123456.json b/tests/fixtures/nexia/session_123456.json
new file mode 100644
index 00000000000..3991a7d565f
--- /dev/null
+++ b/tests/fixtures/nexia/session_123456.json
@@ -0,0 +1,25 @@
+{
+ "success" : true,
+ "result" : {
+ "is_activated_by_activation_code" : 0,
+ "can_receive_notifications" : true,
+ "can_manage_locks" : true,
+ "can_control_automations" : true,
+ "_links" : {
+ "child" : [
+ {
+ "data" : {
+ "name" : "House",
+ "postal_code" : "12345",
+ "id" : 123456
+ }
+ }
+ ],
+ "self" : {
+ "href" : "https://www.mynexia.com/mobile/session"
+ }
+ },
+ "can_view_videos" : true
+ },
+ "error" : null
+}
diff --git a/tests/fixtures/nexia/sign_in.json b/tests/fixtures/nexia/sign_in.json
new file mode 100644
index 00000000000..aac2fb1ae62
--- /dev/null
+++ b/tests/fixtures/nexia/sign_in.json
@@ -0,0 +1,10 @@
+{
+ "success": true,
+ "error": null,
+ "result": {
+ "mobile_id": 1,
+ "api_key": "mock",
+ "setup_step": "done",
+ "locale": "en_us"
+ }
+}
diff --git a/tests/fixtures/powerwall/device_type.json b/tests/fixtures/powerwall/device_type.json
new file mode 100644
index 00000000000..a94c047219e
--- /dev/null
+++ b/tests/fixtures/powerwall/device_type.json
@@ -0,0 +1 @@
+{"device_type":"hec"}
diff --git a/tests/fixtures/powerwall/meters.json b/tests/fixtures/powerwall/meters.json
new file mode 100644
index 00000000000..8eb69593d59
--- /dev/null
+++ b/tests/fixtures/powerwall/meters.json
@@ -0,0 +1,62 @@
+{
+ "battery" : {
+ "instant_power" : -8550,
+ "i_b_current" : 0,
+ "instant_average_voltage" : 240.56,
+ "i_a_current" : 0,
+ "frequency" : 60.014,
+ "instant_reactive_power" : 50,
+ "energy_imported" : 4216170,
+ "instant_total_current" : 185.5,
+ "timeout" : 1500000000,
+ "energy_exported" : 3620010,
+ "instant_apparent_power" : 8550.14619758048,
+ "last_communication_time" : "2020-03-15T15:58:53.855997624-05:00",
+ "i_c_current" : 0
+ },
+ "load" : {
+ "i_b_current" : 0,
+ "instant_average_voltage" : 120.650001525879,
+ "instant_power" : 1971.46005249023,
+ "instant_reactive_power" : -2119.58996582031,
+ "i_a_current" : 0,
+ "frequency" : 60,
+ "last_communication_time" : "2020-03-15T15:58:53.853784964-05:00",
+ "instant_apparent_power" : 2894.70488336392,
+ "i_c_current" : 0,
+ "instant_total_current" : 0,
+ "energy_imported" : 4692987.91889705,
+ "timeout" : 1500000000,
+ "energy_exported" : 1056797.48917483
+ },
+ "solar" : {
+ "i_a_current" : 0,
+ "frequency" : 60,
+ "instant_reactive_power" : -15.2600002288818,
+ "instant_power" : 10489.6596679688,
+ "i_b_current" : 0,
+ "instant_average_voltage" : 120.685001373291,
+ "timeout" : 1500000000,
+ "energy_exported" : 9864205.82222448,
+ "energy_imported" : 28177.5358355867,
+ "instant_total_current" : 0,
+ "i_c_current" : 0,
+ "instant_apparent_power" : 10489.6707678276,
+ "last_communication_time" : "2020-03-15T15:58:53.853898963-05:00"
+ },
+ "site" : {
+ "instant_total_current" : 0.263575500367178,
+ "energy_imported" : 4824191.60668611,
+ "energy_exported" : 10429451.9916853,
+ "timeout" : 1500000000,
+ "last_communication_time" : "2020-03-15T15:58:53.853784964-05:00",
+ "instant_apparent_power" : 2154.56465790676,
+ "i_c_current" : 0,
+ "instant_average_voltage" : 120.650001525879,
+ "i_b_current" : 0,
+ "instant_power" : 31.8003845214844,
+ "instant_reactive_power" : -2154.32996559143,
+ "i_a_current" : 0,
+ "frequency" : 60
+ }
+}
diff --git a/tests/fixtures/powerwall/site_info.json b/tests/fixtures/powerwall/site_info.json
new file mode 100644
index 00000000000..3bd6ee59e40
--- /dev/null
+++ b/tests/fixtures/powerwall/site_info.json
@@ -0,0 +1,20 @@
+{
+ "state" : "Somewhere",
+ "utility" : "Wom Energy",
+ "distributor" : "*",
+ "max_system_energy_kWh" : 0,
+ "nominal_system_power_kW" : 25,
+ "grid_voltage_setting" : 240,
+ "retailer" : "*",
+ "grid_code" : "60Hz_240V_s_IEEE1547a_2014",
+ "timezone" : "America/Chicago",
+ "nominal_system_energy_kWh" : 13.5,
+ "region" : "IEEE1547a:2014",
+ "min_site_meter_power_kW" : -1000000000,
+ "site_name" : "MySite",
+ "country" : "United States",
+ "max_site_meter_power_kW" : 1000000000,
+ "grid_phase_setting" : "Split",
+ "max_system_power_kW" : 0,
+ "grid_freq_setting" : 60
+}
diff --git a/tests/fixtures/powerwall/sitemaster.json b/tests/fixtures/powerwall/sitemaster.json
new file mode 100644
index 00000000000..a2d6c0dd965
--- /dev/null
+++ b/tests/fixtures/powerwall/sitemaster.json
@@ -0,0 +1 @@
+{"connected_to_tesla": true, "running": true, "status": "StatusUp"}
diff --git a/tests/fixtures/powerwall/status.json b/tests/fixtures/powerwall/status.json
new file mode 100644
index 00000000000..41e0288b18d
--- /dev/null
+++ b/tests/fixtures/powerwall/status.json
@@ -0,0 +1 @@
+{"start_time":"2020-03-10 11:57:25 +0800","up_time_seconds":"217h40m57.470801079s","is_new":false,"version":"1.45.1","git_hash":"13bf684a633175f884079ec79f42997080d90310"}
diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_26.json b/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_26.json
new file mode 100644
index 00000000000..dd8c73352d9
--- /dev/null
+++ b/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_26.json
@@ -0,0 +1,820 @@
+{
+ "PVPC": [
+ {
+ "Dia": "26/10/2019",
+ "Hora": "00-01",
+ "GEN": "114,20",
+ "NOC": "65,17",
+ "VHC": "69,02",
+ "COFGEN": "0,000087148314000000",
+ "COFNOC": "0,000135978057000000",
+ "COFVHC": "0,000151138804000000",
+ "PMHGEN": "59,56",
+ "PMHNOC": "57,22",
+ "PMHVHC": "59,81",
+ "SAHGEN": "1,96",
+ "SAHNOC": "1,89",
+ "SAHVHC": "1,97",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,14",
+ "INTGEN": "0,93",
+ "INTNOC": "0,90",
+ "INTVHC": "0,94",
+ "PCAPGEN": "5,54",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "1,31",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,01",
+ "CCVNOC": "1,86",
+ "CCVVHC": "1,95"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "01-02",
+ "GEN": "111,01",
+ "NOC": "62,10",
+ "VHC": "59,03",
+ "COFGEN": "0,000072922194000000",
+ "COFNOC": "0,000124822445000000",
+ "COFVHC": "0,000160597191000000",
+ "PMHGEN": "56,23",
+ "PMHNOC": "54,03",
+ "PMHVHC": "52,62",
+ "SAHGEN": "2,14",
+ "SAHNOC": "2,05",
+ "SAHVHC": "2,00",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,56",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,96",
+ "CCVNOC": "1,82",
+ "CCVVHC": "1,77"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "02-03",
+ "GEN": "105,17",
+ "NOC": "56,48",
+ "VHC": "53,56",
+ "COFGEN": "0,000064100056000000",
+ "COFNOC": "0,000117356595000000",
+ "COFVHC": "0,000158787037000000",
+ "PMHGEN": "50,26",
+ "PMHNOC": "48,29",
+ "PMHVHC": "47,03",
+ "SAHGEN": "2,35",
+ "SAHNOC": "2,26",
+ "SAHVHC": "2,20",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,55",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,87",
+ "CCVNOC": "1,73",
+ "CCVVHC": "1,68"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "03-04",
+ "GEN": "102,45",
+ "NOC": "53,87",
+ "VHC": "51,02",
+ "COFGEN": "0,000059549798000000",
+ "COFNOC": "0,000113408113000000",
+ "COFVHC": "0,000152391581000000",
+ "PMHGEN": "47,42",
+ "PMHNOC": "45,57",
+ "PMHVHC": "44,38",
+ "SAHGEN": "2,51",
+ "SAHNOC": "2,41",
+ "SAHVHC": "2,35",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,56",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,83",
+ "CCVNOC": "1,69",
+ "CCVVHC": "1,65"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "04-05",
+ "GEN": "102,15",
+ "NOC": "53,58",
+ "VHC": "50,73",
+ "COFGEN": "0,000057296575000000",
+ "COFNOC": "0,000111308472000000",
+ "COFVHC": "0,000145270809000000",
+ "PMHGEN": "47,05",
+ "PMHNOC": "45,21",
+ "PMHVHC": "44,03",
+ "SAHGEN": "2,58",
+ "SAHNOC": "2,48",
+ "SAHVHC": "2,41",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,56",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,83",
+ "CCVNOC": "1,69",
+ "CCVVHC": "1,64"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "05-06",
+ "GEN": "101,62",
+ "NOC": "53,13",
+ "VHC": "50,34",
+ "COFGEN": "0,000057285870000000",
+ "COFNOC": "0,000111061995000000",
+ "COFVHC": "0,000141535570000000",
+ "PMHGEN": "46,55",
+ "PMHNOC": "44,76",
+ "PMHVHC": "43,63",
+ "SAHGEN": "2,60",
+ "SAHNOC": "2,50",
+ "SAHVHC": "2,43",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,90",
+ "INTVHC": "0,87",
+ "PCAPGEN": "5,54",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,82",
+ "CCVNOC": "1,68",
+ "CCVVHC": "1,64"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "06-07",
+ "GEN": "102,36",
+ "NOC": "53,90",
+ "VHC": "51,08",
+ "COFGEN": "0,000060011439000000",
+ "COFNOC": "0,000113191071000000",
+ "COFVHC": "0,000139395926000000",
+ "PMHGEN": "46,58",
+ "PMHNOC": "44,82",
+ "PMHVHC": "43,69",
+ "SAHGEN": "3,32",
+ "SAHNOC": "3,20",
+ "SAHVHC": "3,12",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,89",
+ "INTVHC": "0,87",
+ "PCAPGEN": "5,51",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,82",
+ "CCVNOC": "1,69",
+ "CCVVHC": "1,64"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "07-08",
+ "GEN": "106,73",
+ "NOC": "58,10",
+ "VHC": "61,55",
+ "COFGEN": "0,000067624746000000",
+ "COFNOC": "0,000113073036000000",
+ "COFVHC": "0,000130165590000000",
+ "PMHGEN": "50,24",
+ "PMHNOC": "48,34",
+ "PMHVHC": "50,45",
+ "SAHGEN": "3,98",
+ "SAHNOC": "3,83",
+ "SAHVHC": "4,00",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,89",
+ "INTVHC": "0,93",
+ "PCAPGEN": "5,50",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "1,30",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,89",
+ "CCVNOC": "1,75",
+ "CCVVHC": "1,83"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "08-09",
+ "GEN": "107,75",
+ "NOC": "59,43",
+ "VHC": "62,66",
+ "COFGEN": "0,000083194704000000",
+ "COFNOC": "0,000083589950000000",
+ "COFVHC": "0,000069841029000000",
+ "PMHGEN": "51,74",
+ "PMHNOC": "50,02",
+ "PMHVHC": "51,97",
+ "SAHGEN": "3,62",
+ "SAHNOC": "3,50",
+ "SAHVHC": "3,63",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,27",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,89",
+ "CCVNOC": "1,76",
+ "CCVVHC": "1,83"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "09-10",
+ "GEN": "110,38",
+ "NOC": "62,09",
+ "VHC": "65,34",
+ "COFGEN": "0,000105869478000000",
+ "COFNOC": "0,000077963480000000",
+ "COFVHC": "0,000057355982000000",
+ "PMHGEN": "55,41",
+ "PMHNOC": "53,64",
+ "PMHVHC": "55,65",
+ "SAHGEN": "2,60",
+ "SAHNOC": "2,52",
+ "SAHVHC": "2,61",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,87",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,36",
+ "PCAPNOC": "0,90",
+ "PCAPVHC": "1,26",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,92",
+ "CCVNOC": "1,79",
+ "CCVVHC": "1,86"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "10-11",
+ "GEN": "108,10",
+ "NOC": "60,00",
+ "VHC": "63,02",
+ "COFGEN": "0,000121833263000000",
+ "COFNOC": "0,000085468800000000",
+ "COFVHC": "0,000063770407000000",
+ "PMHGEN": "53,39",
+ "PMHNOC": "51,77",
+ "PMHVHC": "53,58",
+ "SAHGEN": "2,42",
+ "SAHNOC": "2,34",
+ "SAHVHC": "2,42",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,87",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,32",
+ "PCAPNOC": "0,90",
+ "PCAPVHC": "1,25",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,88",
+ "CCVNOC": "1,76",
+ "CCVVHC": "1,82"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "11-12",
+ "GEN": "104,11",
+ "NOC": "56,20",
+ "VHC": "59,04",
+ "COFGEN": "0,000125947995000000",
+ "COFNOC": "0,000085228595000000",
+ "COFVHC": "0,000064070840000000",
+ "PMHGEN": "50,02",
+ "PMHNOC": "48,54",
+ "PMHVHC": "50,20",
+ "SAHGEN": "1,89",
+ "SAHNOC": "1,83",
+ "SAHVHC": "1,90",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,89",
+ "INTNOC": "0,87",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,31",
+ "PCAPNOC": "0,90",
+ "PCAPVHC": "1,25",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,81",
+ "CCVNOC": "1,70",
+ "CCVVHC": "1,76"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "12-13",
+ "GEN": "103,61",
+ "NOC": "55,65",
+ "VHC": "58,52",
+ "COFGEN": "0,000128302145000000",
+ "COFNOC": "0,000082279443000000",
+ "COFVHC": "0,000063904657000000",
+ "PMHGEN": "49,50",
+ "PMHNOC": "47,99",
+ "PMHVHC": "49,67",
+ "SAHGEN": "1,90",
+ "SAHNOC": "1,84",
+ "SAHVHC": "1,90",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,87",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,32",
+ "PCAPNOC": "0,90",
+ "PCAPVHC": "1,25",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,81",
+ "CCVNOC": "1,69",
+ "CCVVHC": "1,75"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "13-14",
+ "GEN": "104,03",
+ "NOC": "122,60",
+ "VHC": "122,60",
+ "COFGEN": "0,000134270665000000",
+ "COFNOC": "0,000080726428000000",
+ "COFVHC": "0,000063976543000000",
+ "PMHGEN": "49,98",
+ "PMHNOC": "50,33",
+ "PMHVHC": "50,33",
+ "SAHGEN": "1,85",
+ "SAHNOC": "1,87",
+ "SAHVHC": "1,87",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,89",
+ "INTNOC": "0,90",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,30",
+ "PCAPNOC": "5,50",
+ "PCAPVHC": "5,50",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,81",
+ "CCVNOC": "1,83",
+ "CCVVHC": "1,83"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "14-15",
+ "GEN": "103,44",
+ "NOC": "122,00",
+ "VHC": "122,00",
+ "COFGEN": "0,000130580837000000",
+ "COFNOC": "0,000079392022000000",
+ "COFVHC": "0,000064422150000000",
+ "PMHGEN": "49,25",
+ "PMHNOC": "49,60",
+ "PMHVHC": "49,60",
+ "SAHGEN": "1,97",
+ "SAHNOC": "1,98",
+ "SAHVHC": "1,98",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,90",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,32",
+ "PCAPNOC": "5,52",
+ "PCAPVHC": "5,52",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,81",
+ "CCVNOC": "1,82",
+ "CCVVHC": "1,82"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "15-16",
+ "GEN": "100,57",
+ "NOC": "119,16",
+ "VHC": "119,16",
+ "COFGEN": "0,000114850139000000",
+ "COFNOC": "0,000070924506000000",
+ "COFVHC": "0,000056150579000000",
+ "PMHGEN": "46,19",
+ "PMHNOC": "46,55",
+ "PMHVHC": "46,55",
+ "SAHGEN": "2,15",
+ "SAHNOC": "2,17",
+ "SAHVHC": "2,17",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,36",
+ "PCAPNOC": "5,57",
+ "PCAPVHC": "5,57",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,77",
+ "CCVNOC": "1,79",
+ "CCVVHC": "1,79"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "16-17",
+ "GEN": "99,90",
+ "NOC": "118,48",
+ "VHC": "118,48",
+ "COFGEN": "0,000105915899000000",
+ "COFNOC": "0,000065274280000000",
+ "COFVHC": "0,000051268616000000",
+ "PMHGEN": "45,44",
+ "PMHNOC": "45,80",
+ "PMHVHC": "45,80",
+ "SAHGEN": "2,25",
+ "SAHNOC": "2,27",
+ "SAHVHC": "2,27",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,35",
+ "PCAPNOC": "5,56",
+ "PCAPVHC": "5,56",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,76",
+ "CCVNOC": "1,78",
+ "CCVVHC": "1,78"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "17-18",
+ "GEN": "102,97",
+ "NOC": "121,53",
+ "VHC": "121,53",
+ "COFGEN": "0,000104178581000000",
+ "COFNOC": "0,000063611672000000",
+ "COFVHC": "0,000049947652000000",
+ "PMHGEN": "48,62",
+ "PMHNOC": "48,96",
+ "PMHVHC": "48,96",
+ "SAHGEN": "2,14",
+ "SAHNOC": "2,16",
+ "SAHVHC": "2,16",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,90",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,33",
+ "PCAPNOC": "5,53",
+ "PCAPVHC": "5,53",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,80",
+ "CCVNOC": "1,82",
+ "CCVVHC": "1,82"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "18-19",
+ "GEN": "107,71",
+ "NOC": "126,30",
+ "VHC": "126,30",
+ "COFGEN": "0,000106669089000000",
+ "COFNOC": "0,000070000350000000",
+ "COFVHC": "0,000061100876000000",
+ "PMHGEN": "53,37",
+ "PMHNOC": "53,74",
+ "PMHVHC": "53,74",
+ "SAHGEN": "2,05",
+ "SAHNOC": "2,06",
+ "SAHVHC": "2,06",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,90",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,33",
+ "PCAPNOC": "5,53",
+ "PCAPVHC": "5,53",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,87",
+ "CCVNOC": "1,89",
+ "CCVVHC": "1,89"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "19-20",
+ "GEN": "118,75",
+ "NOC": "137,49",
+ "VHC": "137,49",
+ "COFGEN": "0,000115010612000000",
+ "COFNOC": "0,000095780287000000",
+ "COFVHC": "0,000092687680000000",
+ "PMHGEN": "64,21",
+ "PMHNOC": "64,71",
+ "PMHVHC": "64,71",
+ "SAHGEN": "2,07",
+ "SAHNOC": "2,08",
+ "SAHVHC": "2,08",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,35",
+ "PCAPNOC": "5,55",
+ "PCAPVHC": "5,55",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,04",
+ "CCVNOC": "2,06",
+ "CCVVHC": "2,06"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "20-21",
+ "GEN": "124,00",
+ "NOC": "142,78",
+ "VHC": "142,78",
+ "COFGEN": "0,000129085428000000",
+ "COFNOC": "0,000144302922000000",
+ "COFVHC": "0,000185612441000000",
+ "PMHGEN": "69,13",
+ "PMHNOC": "69,67",
+ "PMHVHC": "69,67",
+ "SAHGEN": "2,30",
+ "SAHNOC": "2,32",
+ "SAHVHC": "2,32",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,36",
+ "PCAPNOC": "5,56",
+ "PCAPVHC": "5,56",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,12",
+ "CCVNOC": "2,14",
+ "CCVVHC": "2,14"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "21-22",
+ "GEN": "124,16",
+ "NOC": "143,00",
+ "VHC": "143,00",
+ "COFGEN": "0,000133109692000000",
+ "COFNOC": "0,000151101318000000",
+ "COFVHC": "0,000197574745000000",
+ "PMHGEN": "68,50",
+ "PMHNOC": "69,09",
+ "PMHVHC": "69,09",
+ "SAHGEN": "3,05",
+ "SAHNOC": "3,07",
+ "SAHVHC": "3,07",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,38",
+ "PCAPNOC": "5,60",
+ "PCAPVHC": "5,60",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,13",
+ "CCVNOC": "2,15",
+ "CCVVHC": "2,15"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "22-23",
+ "GEN": "120,30",
+ "NOC": "139,04",
+ "VHC": "139,04",
+ "COFGEN": "0,000120157209000000",
+ "COFNOC": "0,000148137882000000",
+ "COFVHC": "0,000194906294000000",
+ "PMHGEN": "64,33",
+ "PMHNOC": "64,82",
+ "PMHVHC": "64,82",
+ "SAHGEN": "3,38",
+ "SAHNOC": "3,41",
+ "SAHVHC": "3,41",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,92",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,42",
+ "PCAPNOC": "5,63",
+ "PCAPVHC": "5,63",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,08",
+ "CCVNOC": "2,10",
+ "CCVVHC": "2,10"
+ },
+ {
+ "Dia": "26/10/2019",
+ "Hora": "23-24",
+ "GEN": "118,05",
+ "NOC": "69,05",
+ "VHC": "72,93",
+ "COFGEN": "0,000103870556000000",
+ "COFNOC": "0,000146233245000000",
+ "COFVHC": "0,000182184931000000",
+ "PMHGEN": "61,54",
+ "PMHNOC": "59,25",
+ "PMHVHC": "61,80",
+ "SAHGEN": "3,85",
+ "SAHNOC": "3,71",
+ "SAHVHC": "3,87",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,92",
+ "INTNOC": "0,89",
+ "INTVHC": "0,93",
+ "PCAPGEN": "5,49",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "1,29",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,05",
+ "CCVNOC": "1,91",
+ "CCVVHC": "2,00"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_27.json b/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_27.json
new file mode 100644
index 00000000000..66afc5a91d3
--- /dev/null
+++ b/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_27.json
@@ -0,0 +1,854 @@
+{
+ "PVPC": [
+ {
+ "Dia": "27/10/2019",
+ "Hora": "00-01",
+ "GEN": "115,15",
+ "NOC": "65,95",
+ "VHC": "69,94",
+ "COFGEN": "0,000083408754000000",
+ "COFNOC": "0,000125204015000000",
+ "COFVHC": "0,000143740251000000",
+ "PMHGEN": "59,13",
+ "PMHNOC": "56,72",
+ "PMHVHC": "59,37",
+ "SAHGEN": "3,28",
+ "SAHNOC": "3,14",
+ "SAHVHC": "3,29",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,14",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,94",
+ "PCAPGEN": "5,58",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "1,32",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,03",
+ "CCVNOC": "1,88",
+ "CCVVHC": "1,97"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "01-02",
+ "GEN": "109,63",
+ "NOC": "60,60",
+ "VHC": "57,48",
+ "COFGEN": "0,000069962863000000",
+ "COFNOC": "0,000114629494000000",
+ "COFVHC": "0,000147622130000000",
+ "PMHGEN": "53,21",
+ "PMHNOC": "51,01",
+ "PMHVHC": "49,61",
+ "SAHGEN": "3,72",
+ "SAHNOC": "3,57",
+ "SAHVHC": "3,47",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,95",
+ "INTNOC": "0,91",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,61",
+ "PCAPNOC": "0,94",
+ "PCAPVHC": "0,73",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,95",
+ "CCVNOC": "1,80",
+ "CCVVHC": "1,75"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "02-03",
+ "GEN": "108,41",
+ "NOC": "59,38",
+ "VHC": "56,29",
+ "COFGEN": "0,000065978330000000",
+ "COFNOC": "0,000111216294000000",
+ "COFVHC": "0,000145651145000000",
+ "PMHGEN": "52,09",
+ "PMHNOC": "49,90",
+ "PMHVHC": "48,53",
+ "SAHGEN": "3,62",
+ "SAHNOC": "3,47",
+ "SAHVHC": "3,37",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,95",
+ "INTNOC": "0,91",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,63",
+ "PCAPNOC": "0,94",
+ "PCAPVHC": "0,73",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,93",
+ "CCVNOC": "1,79",
+ "CCVVHC": "1,73"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "03-04",
+ "GEN": "108,22",
+ "NOC": "59,31",
+ "VHC": "56,27",
+ "COFGEN": "0,000061999708000000",
+ "COFNOC": "0,000107809474000000",
+ "COFVHC": "0,000143671560000000",
+ "PMHGEN": "51,88",
+ "PMHNOC": "49,78",
+ "PMHVHC": "48,45",
+ "SAHGEN": "3,68",
+ "SAHNOC": "3,53",
+ "SAHVHC": "3,44",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,59",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,73",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,93",
+ "CCVNOC": "1,78",
+ "CCVVHC": "1,73"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "04-05",
+ "GEN": "107,03",
+ "NOC": "58,16",
+ "VHC": "55,10",
+ "COFGEN": "0,000057358428000000",
+ "COFNOC": "0,000103595831000000",
+ "COFVHC": "0,000139122535000000",
+ "PMHGEN": "50,53",
+ "PMHNOC": "48,48",
+ "PMHVHC": "47,15",
+ "SAHGEN": "3,85",
+ "SAHNOC": "3,69",
+ "SAHVHC": "3,59",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,91",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,60",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,73",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,91",
+ "CCVNOC": "1,76",
+ "CCVVHC": "1,71"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "05-06",
+ "GEN": "104,79",
+ "NOC": "56,01",
+ "VHC": "53,06",
+ "COFGEN": "0,000055060063000000",
+ "COFNOC": "0,000101732765000000",
+ "COFVHC": "0,000134441142000000",
+ "PMHGEN": "48,28",
+ "PMHNOC": "46,32",
+ "PMHVHC": "45,08",
+ "SAHGEN": "3,91",
+ "SAHNOC": "3,75",
+ "SAHVHC": "3,65",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,59",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,73",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,87",
+ "CCVNOC": "1,73",
+ "CCVVHC": "1,68"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "06-07",
+ "GEN": "104,56",
+ "NOC": "55,85",
+ "VHC": "52,94",
+ "COFGEN": "0,000054511300000000",
+ "COFNOC": "0,000101250808000000",
+ "COFVHC": "0,000131206727000000",
+ "PMHGEN": "48,10",
+ "PMHNOC": "46,18",
+ "PMHVHC": "44,98",
+ "SAHGEN": "3,90",
+ "SAHNOC": "3,74",
+ "SAHVHC": "3,65",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,57",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,87",
+ "CCVNOC": "1,73",
+ "CCVVHC": "1,68"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "07-08",
+ "GEN": "107,72",
+ "NOC": "58,93",
+ "VHC": "55,95",
+ "COFGEN": "0,000056191283000000",
+ "COFNOC": "0,000102978398000000",
+ "COFVHC": "0,000130073563000000",
+ "PMHGEN": "50,23",
+ "PMHNOC": "48,26",
+ "PMHVHC": "47,01",
+ "SAHGEN": "4,89",
+ "SAHNOC": "4,70",
+ "SAHVHC": "4,57",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,56",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,91",
+ "CCVNOC": "1,77",
+ "CCVVHC": "1,72"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "08-09",
+ "GEN": "107,80",
+ "NOC": "59,29",
+ "VHC": "62,70",
+ "COFGEN": "0,000060083432000000",
+ "COFNOC": "0,000100348617000000",
+ "COFVHC": "0,000118460190000000",
+ "PMHGEN": "50,94",
+ "PMHNOC": "49,13",
+ "PMHVHC": "51,20",
+ "SAHGEN": "4,38",
+ "SAHNOC": "4,23",
+ "SAHVHC": "4,40",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,92",
+ "INTNOC": "0,89",
+ "INTVHC": "0,93",
+ "PCAPGEN": "5,47",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "1,29",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,90",
+ "CCVNOC": "1,76",
+ "CCVVHC": "1,84"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "09-10",
+ "GEN": "106,74",
+ "NOC": "58,40",
+ "VHC": "61,63",
+ "COFGEN": "0,000070236674000000",
+ "COFNOC": "0,000071273888000000",
+ "COFVHC": "0,000062511624000000",
+ "PMHGEN": "50,00",
+ "PMHNOC": "48,29",
+ "PMHVHC": "50,22",
+ "SAHGEN": "4,34",
+ "SAHNOC": "4,20",
+ "SAHVHC": "4,36",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,42",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,28",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,87",
+ "CCVNOC": "1,74",
+ "CCVVHC": "1,82"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "10-11",
+ "GEN": "106,13",
+ "NOC": "57,81",
+ "VHC": "61,02",
+ "COFGEN": "0,000089379429000000",
+ "COFNOC": "0,000066131351000000",
+ "COFVHC": "0,000053107930000000",
+ "PMHGEN": "50,32",
+ "PMHNOC": "48,60",
+ "PMHVHC": "50,54",
+ "SAHGEN": "3,43",
+ "SAHNOC": "3,31",
+ "SAHVHC": "3,44",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,42",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,28",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,86",
+ "CCVNOC": "1,73",
+ "CCVVHC": "1,81"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "11-12",
+ "GEN": "105,00",
+ "NOC": "56,78",
+ "VHC": "59,91",
+ "COFGEN": "0,000106229062000000",
+ "COFNOC": "0,000075658481000000",
+ "COFVHC": "0,000058816566000000",
+ "PMHGEN": "50,34",
+ "PMHNOC": "48,65",
+ "PMHVHC": "50,56",
+ "SAHGEN": "2,33",
+ "SAHNOC": "2,25",
+ "SAHVHC": "2,34",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,39",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,27",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,84",
+ "CCVNOC": "1,72",
+ "CCVVHC": "1,79"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "12-13",
+ "GEN": "105,07",
+ "NOC": "56,79",
+ "VHC": "59,92",
+ "COFGEN": "0,000113739886000000",
+ "COFNOC": "0,000079251893000000",
+ "COFVHC": "0,000061868784000000",
+ "PMHGEN": "50,41",
+ "PMHNOC": "48,69",
+ "PMHVHC": "50,59",
+ "SAHGEN": "2,31",
+ "SAHNOC": "2,23",
+ "SAHVHC": "2,32",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,27",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,85",
+ "CCVNOC": "1,72",
+ "CCVVHC": "1,79"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "13-14",
+ "GEN": "104,67",
+ "NOC": "123,29",
+ "VHC": "59,59",
+ "COFGEN": "0,000116885572000000",
+ "COFNOC": "0,000077561607000000",
+ "COFVHC": "0,000061189779000000",
+ "PMHGEN": "50,08",
+ "PMHNOC": "50,47",
+ "PMHVHC": "50,30",
+ "SAHGEN": "2,29",
+ "SAHNOC": "2,31",
+ "SAHVHC": "2,30",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,37",
+ "PCAPNOC": "5,57",
+ "PCAPVHC": "1,27",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,83",
+ "CCVNOC": "1,85",
+ "CCVVHC": "1,78"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "14-15",
+ "GEN": "107,41",
+ "NOC": "126,05",
+ "VHC": "126,05",
+ "COFGEN": "0,000122253070000000",
+ "COFNOC": "0,000076034460000000",
+ "COFVHC": "0,000059795888000000",
+ "PMHGEN": "52,87",
+ "PMHNOC": "53,28",
+ "PMHVHC": "53,28",
+ "SAHGEN": "2,20",
+ "SAHNOC": "2,22",
+ "SAHVHC": "2,22",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,37",
+ "PCAPNOC": "5,57",
+ "PCAPVHC": "5,57",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,87",
+ "CCVNOC": "1,89",
+ "CCVVHC": "1,89"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "15-16",
+ "GEN": "108,36",
+ "NOC": "127,06",
+ "VHC": "127,06",
+ "COFGEN": "0,000120316270000000",
+ "COFNOC": "0,000073732639000000",
+ "COFVHC": "0,000059483320000000",
+ "PMHGEN": "53,68",
+ "PMHNOC": "54,14",
+ "PMHVHC": "54,14",
+ "SAHGEN": "2,29",
+ "SAHNOC": "2,31",
+ "SAHVHC": "2,31",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,92",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,39",
+ "PCAPNOC": "5,60",
+ "PCAPVHC": "5,60",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,89",
+ "CCVNOC": "1,91",
+ "CCVVHC": "1,91"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "16-17",
+ "GEN": "106,15",
+ "NOC": "124,78",
+ "VHC": "124,78",
+ "COFGEN": "0,000106276301000000",
+ "COFNOC": "0,000065442255000000",
+ "COFVHC": "0,000053614900000000",
+ "PMHGEN": "51,38",
+ "PMHNOC": "51,78",
+ "PMHVHC": "51,78",
+ "SAHGEN": "2,40",
+ "SAHNOC": "2,42",
+ "SAHVHC": "2,42",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,92",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "5,61",
+ "PCAPVHC": "5,61",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,86",
+ "CCVNOC": "1,88",
+ "CCVVHC": "1,88"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "17-18",
+ "GEN": "105,09",
+ "NOC": "123,72",
+ "VHC": "123,72",
+ "COFGEN": "0,000098092024000000",
+ "COFNOC": "0,000060340481000000",
+ "COFVHC": "0,000050280869000000",
+ "PMHGEN": "51,35",
+ "PMHNOC": "51,75",
+ "PMHVHC": "51,75",
+ "SAHGEN": "1,40",
+ "SAHNOC": "1,41",
+ "SAHVHC": "1,41",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,92",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "5,61",
+ "PCAPVHC": "5,61",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,85",
+ "CCVNOC": "1,86",
+ "CCVVHC": "1,86"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "18-19",
+ "GEN": "108,12",
+ "NOC": "126,77",
+ "VHC": "126,77",
+ "COFGEN": "0,000095857172000000",
+ "COFNOC": "0,000058545227000000",
+ "COFVHC": "0,000049936767000000",
+ "PMHGEN": "54,41",
+ "PMHNOC": "54,83",
+ "PMHVHC": "54,83",
+ "SAHGEN": "1,35",
+ "SAHNOC": "1,36",
+ "SAHVHC": "1,36",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,38",
+ "PCAPNOC": "5,59",
+ "PCAPVHC": "5,59",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,89",
+ "CCVNOC": "1,91",
+ "CCVVHC": "1,91"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "19-20",
+ "GEN": "112,76",
+ "NOC": "131,51",
+ "VHC": "131,51",
+ "COFGEN": "0,000099686581000000",
+ "COFNOC": "0,000063674261000000",
+ "COFVHC": "0,000057884599000000",
+ "PMHGEN": "58,53",
+ "PMHNOC": "59,03",
+ "PMHVHC": "59,03",
+ "SAHGEN": "1,77",
+ "SAHNOC": "1,79",
+ "SAHVHC": "1,79",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,92",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "5,62",
+ "PCAPVHC": "5,62",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,96",
+ "CCVNOC": "1,98",
+ "CCVVHC": "1,98"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "20-21",
+ "GEN": "118,69",
+ "NOC": "137,48",
+ "VHC": "137,48",
+ "COFGEN": "0,000111025948000000",
+ "COFNOC": "0,000087846097000000",
+ "COFVHC": "0,000084304207000000",
+ "PMHGEN": "64,79",
+ "PMHNOC": "65,35",
+ "PMHVHC": "65,35",
+ "SAHGEN": "1,34",
+ "SAHNOC": "1,35",
+ "SAHVHC": "1,35",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,92",
+ "INTVHC": "0,92",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "5,62",
+ "PCAPVHC": "5,62",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,05",
+ "CCVNOC": "2,07",
+ "CCVVHC": "2,07"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "21-22",
+ "GEN": "121,19",
+ "NOC": "139,94",
+ "VHC": "139,94",
+ "COFGEN": "0,000129356812000000",
+ "COFNOC": "0,000137580750000000",
+ "COFVHC": "0,000175068439000000",
+ "PMHGEN": "66,00",
+ "PMHNOC": "66,51",
+ "PMHVHC": "66,51",
+ "SAHGEN": "2,64",
+ "SAHNOC": "2,66",
+ "SAHVHC": "2,66",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,38",
+ "PCAPNOC": "5,58",
+ "PCAPVHC": "5,58",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,08",
+ "CCVNOC": "2,10",
+ "CCVVHC": "2,10"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "22-23",
+ "GEN": "120,21",
+ "NOC": "138,96",
+ "VHC": "138,96",
+ "COFGEN": "0,000132818174000000",
+ "COFNOC": "0,000143862321000000",
+ "COFVHC": "0,000185393247000000",
+ "PMHGEN": "65,72",
+ "PMHNOC": "66,23",
+ "PMHVHC": "66,23",
+ "SAHGEN": "1,94",
+ "SAHNOC": "1,96",
+ "SAHVHC": "1,96",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,91",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,38",
+ "PCAPNOC": "5,59",
+ "PCAPVHC": "5,59",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,07",
+ "CCVNOC": "2,09",
+ "CCVVHC": "2,09"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "23-24",
+ "GEN": "117,85",
+ "NOC": "68,93",
+ "VHC": "136,63",
+ "COFGEN": "0,000117725347000000",
+ "COFNOC": "0,000138623638000000",
+ "COFVHC": "0,000180725170000000",
+ "PMHGEN": "62,92",
+ "PMHNOC": "60,64",
+ "PMHVHC": "63,46",
+ "SAHGEN": "2,28",
+ "SAHNOC": "2,20",
+ "SAHVHC": "2,30",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,92",
+ "INTNOC": "0,89",
+ "INTVHC": "0,93",
+ "PCAPGEN": "5,48",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "5,69",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,05",
+ "CCVNOC": "1,91",
+ "CCVVHC": "2,07"
+ },
+ {
+ "Dia": "27/10/2019",
+ "Hora": "24-25",
+ "GEN": "118,42",
+ "NOC": "69,35",
+ "VHC": "73,34",
+ "COFGEN": "0,000097485259000000",
+ "COFNOC": "0,000133828173000000",
+ "COFVHC": "0,000166082424000000",
+ "PMHGEN": "63,21",
+ "PMHNOC": "60,82",
+ "PMHVHC": "63,52",
+ "SAHGEN": "2,51",
+ "SAHNOC": "2,41",
+ "SAHVHC": "2,52",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,90",
+ "INTVHC": "0,94",
+ "PCAPGEN": "5,52",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "1,30",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,07",
+ "CCVNOC": "1,92",
+ "CCVVHC": "2,01"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_29.json b/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_29.json
new file mode 100644
index 00000000000..631e77f5e76
--- /dev/null
+++ b/tests/fixtures/pvpc_hourly_pricing/PVPC_CURV_DD_2019_10_29.json
@@ -0,0 +1,820 @@
+{
+ "PVPC": [
+ {
+ "Dia": "29/10/2019",
+ "Hora": "00-01",
+ "GEN": "117,17",
+ "NOC": "68,21",
+ "VHC": "72,10",
+ "COFGEN": "0,000077051997000000",
+ "COFNOC": "0,000124733002000000",
+ "COFVHC": "0,000143780107000000",
+ "PMHGEN": "62,55",
+ "PMHNOC": "60,23",
+ "PMHVHC": "62,86",
+ "SAHGEN": "1,96",
+ "SAHNOC": "1,89",
+ "SAHVHC": "1,97",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,89",
+ "INTVHC": "0,93",
+ "PCAPGEN": "5,50",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "1,30",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,04",
+ "CCVNOC": "1,90",
+ "CCVVHC": "1,99"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "01-02",
+ "GEN": "115,34",
+ "NOC": "66,27",
+ "VHC": "63,14",
+ "COFGEN": "0,000063669626000000",
+ "COFNOC": "0,000113703738000000",
+ "COFVHC": "0,000153709241000000",
+ "PMHGEN": "60,54",
+ "PMHNOC": "58,17",
+ "PMHVHC": "56,70",
+ "SAHGEN": "2,11",
+ "SAHNOC": "2,02",
+ "SAHVHC": "1,97",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,90",
+ "INTVHC": "0,87",
+ "PCAPGEN": "5,54",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "2,02",
+ "CCVNOC": "1,88",
+ "CCVVHC": "1,83"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "02-03",
+ "GEN": "112,37",
+ "NOC": "63,40",
+ "VHC": "60,25",
+ "COFGEN": "0,000057299719000000",
+ "COFNOC": "0,000107847932000000",
+ "COFVHC": "0,000151346355000000",
+ "PMHGEN": "57,42",
+ "PMHNOC": "55,17",
+ "PMHVHC": "53,69",
+ "SAHGEN": "2,27",
+ "SAHNOC": "2,18",
+ "SAHVHC": "2,12",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,57",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,98",
+ "CCVNOC": "1,84",
+ "CCVVHC": "1,79"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "03-04",
+ "GEN": "111,32",
+ "NOC": "62,39",
+ "VHC": "59,27",
+ "COFGEN": "0,000054631496000000",
+ "COFNOC": "0,000105135123000000",
+ "COFVHC": "0,000145712713000000",
+ "PMHGEN": "55,92",
+ "PMHNOC": "53,73",
+ "PMHVHC": "52,29",
+ "SAHGEN": "2,74",
+ "SAHNOC": "2,63",
+ "SAHVHC": "2,56",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,57",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,97",
+ "CCVNOC": "1,82",
+ "CCVVHC": "1,77"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "04-05",
+ "GEN": "111,08",
+ "NOC": "62,17",
+ "VHC": "59,04",
+ "COFGEN": "0,000053587732000000",
+ "COFNOC": "0,000103791403000000",
+ "COFVHC": "0,000139739507000000",
+ "PMHGEN": "55,64",
+ "PMHNOC": "53,46",
+ "PMHVHC": "52,03",
+ "SAHGEN": "2,79",
+ "SAHNOC": "2,68",
+ "SAHVHC": "2,61",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,14",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,94",
+ "INTNOC": "0,90",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,56",
+ "PCAPNOC": "0,93",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,96",
+ "CCVNOC": "1,82",
+ "CCVVHC": "1,77"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "05-06",
+ "GEN": "113,57",
+ "NOC": "64,62",
+ "VHC": "61,53",
+ "COFGEN": "0,000054978965000000",
+ "COFNOC": "0,000104220226000000",
+ "COFVHC": "0,000135044065000000",
+ "PMHGEN": "58,23",
+ "PMHNOC": "55,99",
+ "PMHVHC": "54,57",
+ "SAHGEN": "2,69",
+ "SAHNOC": "2,58",
+ "SAHVHC": "2,52",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,93",
+ "INTNOC": "0,90",
+ "INTVHC": "0,87",
+ "PCAPGEN": "5,53",
+ "PCAPNOC": "0,92",
+ "PCAPVHC": "0,72",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "2,00",
+ "CCVNOC": "1,85",
+ "CCVVHC": "1,80"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "06-07",
+ "GEN": "113,48",
+ "NOC": "64,78",
+ "VHC": "61,84",
+ "COFGEN": "0,000063808739000000",
+ "COFNOC": "0,000109956697000000",
+ "COFVHC": "0,000134904594000000",
+ "PMHGEN": "58,13",
+ "PMHNOC": "56,06",
+ "PMHVHC": "54,78",
+ "SAHGEN": "2,80",
+ "SAHNOC": "2,70",
+ "SAHVHC": "2,64",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,12",
+ "INTGEN": "0,92",
+ "INTNOC": "0,89",
+ "INTVHC": "0,87",
+ "PCAPGEN": "5,45",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "0,71",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "0,89",
+ "CCVGEN": "1,98",
+ "CCVNOC": "1,84",
+ "CCVVHC": "1,80"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "07-08",
+ "GEN": "118,40",
+ "NOC": "69,72",
+ "VHC": "73,36",
+ "COFGEN": "0,000086957107000000",
+ "COFNOC": "0,000119021762000000",
+ "COFVHC": "0,000131848949000000",
+ "PMHGEN": "63,73",
+ "PMHNOC": "61,60",
+ "PMHVHC": "64,00",
+ "SAHGEN": "2,12",
+ "SAHNOC": "2,05",
+ "SAHVHC": "2,13",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,27",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,05",
+ "CCVNOC": "1,91",
+ "CCVVHC": "1,99"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "08-09",
+ "GEN": "117,71",
+ "NOC": "69,47",
+ "VHC": "72,71",
+ "COFGEN": "0,000103659543000000",
+ "COFNOC": "0,000093080441000000",
+ "COFVHC": "0,000078478538000000",
+ "PMHGEN": "63,59",
+ "PMHNOC": "61,75",
+ "PMHVHC": "63,81",
+ "SAHGEN": "1,76",
+ "SAHNOC": "1,71",
+ "SAHVHC": "1,77",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,12",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,89",
+ "INTNOC": "0,86",
+ "INTVHC": "0,89",
+ "PCAPGEN": "5,27",
+ "PCAPNOC": "0,89",
+ "PCAPVHC": "1,24",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "2,01",
+ "CCVNOC": "1,89",
+ "CCVVHC": "1,96"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "09-10",
+ "GEN": "115,84",
+ "NOC": "67,79",
+ "VHC": "70,80",
+ "COFGEN": "0,000109607743000000",
+ "COFNOC": "0,000077907419000000",
+ "COFVHC": "0,000061476325000000",
+ "PMHGEN": "62,27",
+ "PMHNOC": "60,57",
+ "PMHVHC": "62,44",
+ "SAHGEN": "1,29",
+ "SAHNOC": "1,25",
+ "SAHVHC": "1,29",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,12",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,86",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,23",
+ "PCAPNOC": "0,88",
+ "PCAPVHC": "1,23",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,98",
+ "CCVNOC": "1,86",
+ "CCVVHC": "1,92"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "10-11",
+ "GEN": "114,70",
+ "NOC": "66,74",
+ "VHC": "69,67",
+ "COFGEN": "0,000115808394000000",
+ "COFNOC": "0,000078426619000000",
+ "COFVHC": "0,000062221967000000",
+ "PMHGEN": "61,05",
+ "PMHNOC": "59,42",
+ "PMHVHC": "61,21",
+ "SAHGEN": "1,41",
+ "SAHNOC": "1,37",
+ "SAHVHC": "1,41",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,12",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,86",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,22",
+ "PCAPNOC": "0,88",
+ "PCAPVHC": "1,23",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,96",
+ "CCVNOC": "1,84",
+ "CCVVHC": "1,90"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "11-12",
+ "GEN": "114,45",
+ "NOC": "66,51",
+ "VHC": "69,43",
+ "COFGEN": "0,000117753360000000",
+ "COFNOC": "0,000076432674000000",
+ "COFVHC": "0,000061112533000000",
+ "PMHGEN": "60,85",
+ "PMHNOC": "59,23",
+ "PMHVHC": "61,01",
+ "SAHGEN": "1,37",
+ "SAHNOC": "1,34",
+ "SAHVHC": "1,38",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,12",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,85",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,21",
+ "PCAPNOC": "0,88",
+ "PCAPVHC": "1,23",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,95",
+ "CCVNOC": "1,84",
+ "CCVVHC": "1,90"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "12-13",
+ "GEN": "114,46",
+ "NOC": "133,04",
+ "VHC": "69,45",
+ "COFGEN": "0,000121492044000000",
+ "COFNOC": "0,000074703573000000",
+ "COFVHC": "0,000061457855000000",
+ "PMHGEN": "60,95",
+ "PMHNOC": "61,33",
+ "PMHVHC": "61,11",
+ "SAHGEN": "1,30",
+ "SAHNOC": "1,31",
+ "SAHVHC": "1,31",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,88",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,20",
+ "PCAPNOC": "5,39",
+ "PCAPVHC": "1,22",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,95",
+ "CCVNOC": "1,97",
+ "CCVVHC": "1,90"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "13-14",
+ "GEN": "113,37",
+ "NOC": "131,94",
+ "VHC": "131,94",
+ "COFGEN": "0,000126490319000000",
+ "COFNOC": "0,000074777760000000",
+ "COFVHC": "0,000060760068000000",
+ "PMHGEN": "59,86",
+ "PMHNOC": "60,24",
+ "PMHVHC": "60,24",
+ "SAHGEN": "1,32",
+ "SAHNOC": "1,33",
+ "SAHVHC": "1,33",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,87",
+ "INTNOC": "0,88",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,19",
+ "PCAPNOC": "5,38",
+ "PCAPVHC": "5,38",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,93",
+ "CCVNOC": "1,95",
+ "CCVVHC": "1,95"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "14-15",
+ "GEN": "112,88",
+ "NOC": "131,46",
+ "VHC": "131,46",
+ "COFGEN": "0,000120771211000000",
+ "COFNOC": "0,000072095790000000",
+ "COFVHC": "0,000058765117000000",
+ "PMHGEN": "59,31",
+ "PMHNOC": "59,68",
+ "PMHVHC": "59,68",
+ "SAHGEN": "1,37",
+ "SAHNOC": "1,38",
+ "SAHVHC": "1,38",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,88",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,21",
+ "PCAPNOC": "5,40",
+ "PCAPVHC": "5,40",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,93",
+ "CCVNOC": "1,94",
+ "CCVVHC": "1,94"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "15-16",
+ "GEN": "115,75",
+ "NOC": "134,41",
+ "VHC": "134,41",
+ "COFGEN": "0,000110808247000000",
+ "COFNOC": "0,000066006577000000",
+ "COFVHC": "0,000053763013000000",
+ "PMHGEN": "62,14",
+ "PMHNOC": "62,58",
+ "PMHVHC": "62,58",
+ "SAHGEN": "1,34",
+ "SAHNOC": "1,35",
+ "SAHVHC": "1,35",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,89",
+ "INTVHC": "0,89",
+ "PCAPGEN": "5,23",
+ "PCAPNOC": "5,42",
+ "PCAPVHC": "5,42",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "1,98",
+ "CCVNOC": "1,99",
+ "CCVVHC": "1,99"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "16-17",
+ "GEN": "118,08",
+ "NOC": "136,75",
+ "VHC": "136,75",
+ "COFGEN": "0,000107924950000000",
+ "COFNOC": "0,000063090606000000",
+ "COFVHC": "0,000052115396000000",
+ "PMHGEN": "64,48",
+ "PMHNOC": "64,93",
+ "PMHVHC": "64,93",
+ "SAHGEN": "1,31",
+ "SAHNOC": "1,32",
+ "SAHVHC": "1,32",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,89",
+ "INTVHC": "0,89",
+ "PCAPGEN": "5,22",
+ "PCAPNOC": "5,42",
+ "PCAPVHC": "5,42",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,01",
+ "CCVNOC": "2,03",
+ "CCVVHC": "2,03"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "17-18",
+ "GEN": "121,32",
+ "NOC": "139,95",
+ "VHC": "139,95",
+ "COFGEN": "0,000111993776000000",
+ "COFNOC": "0,000063840323000000",
+ "COFVHC": "0,000053264660000000",
+ "PMHGEN": "67,88",
+ "PMHNOC": "68,30",
+ "PMHVHC": "68,30",
+ "SAHGEN": "1,10",
+ "SAHNOC": "1,11",
+ "SAHVHC": "1,11",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,88",
+ "INTVHC": "0,88",
+ "PCAPGEN": "5,22",
+ "PCAPNOC": "5,41",
+ "PCAPVHC": "5,41",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,06",
+ "CCVNOC": "2,07",
+ "CCVVHC": "2,07"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "18-19",
+ "GEN": "126,19",
+ "NOC": "144,85",
+ "VHC": "144,85",
+ "COFGEN": "0,000117118878000000",
+ "COFNOC": "0,000072058416000000",
+ "COFVHC": "0,000066417528000000",
+ "PMHGEN": "69,04",
+ "PMHNOC": "69,47",
+ "PMHVHC": "69,47",
+ "SAHGEN": "4,73",
+ "SAHNOC": "4,76",
+ "SAHVHC": "4,76",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,89",
+ "INTVHC": "0,89",
+ "PCAPGEN": "5,22",
+ "PCAPNOC": "5,42",
+ "PCAPVHC": "5,42",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,13",
+ "CCVNOC": "2,15",
+ "CCVVHC": "2,15"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "19-20",
+ "GEN": "125,34",
+ "NOC": "144,06",
+ "VHC": "144,06",
+ "COFGEN": "0,000128443388000000",
+ "COFNOC": "0,000098772457000000",
+ "COFVHC": "0,000100678475000000",
+ "PMHGEN": "68,61",
+ "PMHNOC": "69,09",
+ "PMHVHC": "69,09",
+ "SAHGEN": "4,31",
+ "SAHNOC": "4,34",
+ "SAHVHC": "4,34",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,88",
+ "INTNOC": "0,89",
+ "INTVHC": "0,89",
+ "PCAPGEN": "5,24",
+ "PCAPNOC": "5,43",
+ "PCAPVHC": "5,43",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,12",
+ "CCVNOC": "2,14",
+ "CCVVHC": "2,14"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "20-21",
+ "GEN": "120,62",
+ "NOC": "139,31",
+ "VHC": "139,31",
+ "COFGEN": "0,000144847952000000",
+ "COFNOC": "0,000148736569000000",
+ "COFVHC": "0,000192706770000000",
+ "PMHGEN": "67,11",
+ "PMHNOC": "67,58",
+ "PMHVHC": "67,58",
+ "SAHGEN": "1,12",
+ "SAHNOC": "1,12",
+ "SAHVHC": "1,12",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,89",
+ "INTNOC": "0,89",
+ "INTVHC": "0,89",
+ "PCAPGEN": "5,27",
+ "PCAPNOC": "5,47",
+ "PCAPVHC": "5,47",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,06",
+ "CCVNOC": "2,07",
+ "CCVVHC": "2,07"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "21-22",
+ "GEN": "120,67",
+ "NOC": "139,36",
+ "VHC": "139,36",
+ "COFGEN": "0,000143400205000000",
+ "COFNOC": "0,000153448551000000",
+ "COFVHC": "0,000201113372000000",
+ "PMHGEN": "66,43",
+ "PMHNOC": "66,90",
+ "PMHVHC": "66,90",
+ "SAHGEN": "1,80",
+ "SAHNOC": "1,81",
+ "SAHVHC": "1,81",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,89",
+ "INTNOC": "0,90",
+ "INTVHC": "0,90",
+ "PCAPGEN": "5,30",
+ "PCAPNOC": "5,50",
+ "PCAPVHC": "5,50",
+ "TEUGEN": "44,03",
+ "TEUNOC": "62,01",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,06",
+ "CCVNOC": "2,08",
+ "CCVVHC": "2,08"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "22-23",
+ "GEN": "117,80",
+ "NOC": "69,35",
+ "VHC": "136,53",
+ "COFGEN": "0,000122948482000000",
+ "COFNOC": "0,000146077289000000",
+ "COFVHC": "0,000194614149000000",
+ "PMHGEN": "63,25",
+ "PMHNOC": "61,28",
+ "PMHVHC": "63,75",
+ "SAHGEN": "2,10",
+ "SAHNOC": "2,03",
+ "SAHVHC": "2,11",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,90",
+ "INTNOC": "0,87",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,34",
+ "PCAPNOC": "0,90",
+ "PCAPVHC": "5,54",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "62,01",
+ "CCVGEN": "2,03",
+ "CCVNOC": "1,90",
+ "CCVVHC": "2,04"
+ },
+ {
+ "Dia": "29/10/2019",
+ "Hora": "23-24",
+ "GEN": "111,95",
+ "NOC": "63,48",
+ "VHC": "66,87",
+ "COFGEN": "0,000098841799000000",
+ "COFNOC": "0,000139677463000000",
+ "COFVHC": "0,000176886301000000",
+ "PMHGEN": "56,97",
+ "PMHNOC": "55,07",
+ "PMHVHC": "57,22",
+ "SAHGEN": "2,52",
+ "SAHNOC": "2,44",
+ "SAHVHC": "2,53",
+ "FOMGEN": "0,03",
+ "FOMNOC": "0,03",
+ "FOMVHC": "0,03",
+ "FOSGEN": "0,13",
+ "FOSNOC": "0,13",
+ "FOSVHC": "0,13",
+ "INTGEN": "0,91",
+ "INTNOC": "0,88",
+ "INTVHC": "0,91",
+ "PCAPGEN": "5,40",
+ "PCAPNOC": "0,91",
+ "PCAPVHC": "1,27",
+ "TEUGEN": "44,03",
+ "TEUNOC": "2,22",
+ "TEUVHC": "2,88",
+ "CCVGEN": "1,95",
+ "CCVNOC": "1,82",
+ "CCVVHC": "1,89"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/ac_issue_32294.heat_mode.json b/tests/fixtures/tado/ac_issue_32294.heat_mode.json
new file mode 100644
index 00000000000..098afd018aa
--- /dev/null
+++ b/tests/fixtures/tado/ac_issue_32294.heat_mode.json
@@ -0,0 +1,60 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 71.28,
+ "timestamp": "2020-02-29T22:51:05.016Z",
+ "celsius": 21.82,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-02-29T22:51:05.016Z",
+ "percentage": 40.4,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": null,
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-02-29T22:50:34.850Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-01T00:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": null,
+ "nextScheduleChange": {
+ "start": "2020-03-01T00:00:00Z",
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "HEAT",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 59.0,
+ "celsius": 15.0
+ }
+ }
+ },
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "HEAT",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 77.0,
+ "celsius": 25.0
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/devices.json b/tests/fixtures/tado/devices.json
new file mode 100644
index 00000000000..5fc43adc903
--- /dev/null
+++ b/tests/fixtures/tado/devices.json
@@ -0,0 +1,22 @@
+[
+ {
+ "deviceType" : "WR02",
+ "currentFwVersion" : "59.4",
+ "accessPointWiFi" : {
+ "ssid" : "tado8480"
+ },
+ "characteristics" : {
+ "capabilities" : [
+ "INSIDE_TEMPERATURE_MEASUREMENT",
+ "IDENTIFY"
+ ]
+ },
+ "serialNo" : "WR1",
+ "commandTableUploadState" : "FINISHED",
+ "connectionState" : {
+ "timestamp" : "2020-03-23T18:30:07.377Z",
+ "value" : true
+ },
+ "shortSerialNo" : "WR1"
+ }
+]
diff --git a/tests/fixtures/tado/hvac_action_heat.json b/tests/fixtures/tado/hvac_action_heat.json
new file mode 100644
index 00000000000..9cbf1fd5f82
--- /dev/null
+++ b/tests/fixtures/tado/hvac_action_heat.json
@@ -0,0 +1,67 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "ON",
+ "mode": "HEAT",
+ "temperature": {
+ "celsius": 16.11,
+ "fahrenheit": 61.00
+ },
+ "fanSpeed": "AUTO"
+ },
+ "overlayType": "MANUAL",
+ "overlay": {
+ "type": "MANUAL",
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "ON",
+ "mode": "HEAT",
+ "temperature": {
+ "celsius": 16.11,
+ "fahrenheit": 61.00
+ },
+ "fanSpeed": "AUTO"
+ },
+ "termination": {
+ "type": "TADO_MODE",
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null
+ }
+ },
+ "openWindow": null,
+ "nextScheduleChange": null,
+ "nextTimeBlock": {
+ "start": "2020-03-07T04:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-06T17:38:30.302Z",
+ "type": "POWER",
+ "value": "OFF"
+ }
+ },
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "celsius": 21.40,
+ "fahrenheit": 70.52,
+ "timestamp": "2020-03-06T18:06:09.546Z",
+ "type": "TEMPERATURE",
+ "precision": {
+ "celsius": 0.1,
+ "fahrenheit": 0.1
+ }
+ },
+ "humidity": {
+ "type": "PERCENTAGE",
+ "percentage": 50.40,
+ "timestamp": "2020-03-06T18:06:09.546Z"
+ }
+ }
+}
diff --git a/tests/fixtures/tado/me.json b/tests/fixtures/tado/me.json
new file mode 100644
index 00000000000..4707b3f04d4
--- /dev/null
+++ b/tests/fixtures/tado/me.json
@@ -0,0 +1,28 @@
+{
+ "id" : "5",
+ "mobileDevices" : [
+ {
+ "name" : "nick Android",
+ "deviceMetadata" : {
+ "platform" : "Android",
+ "locale" : "en",
+ "osVersion" : "10",
+ "model" : "OnePlus_GM1917"
+ },
+ "settings" : {
+ "geoTrackingEnabled" : false
+ },
+ "id" : 1
+ }
+ ],
+ "homes" : [
+ {
+ "name" : "home name",
+ "id" : 1
+ }
+ ],
+ "name" : "name",
+ "locale" : "en_US",
+ "email" : "user@domain.tld",
+ "username" : "user@domain.tld"
+}
diff --git a/tests/fixtures/tado/smartac3.auto_mode.json b/tests/fixtures/tado/smartac3.auto_mode.json
new file mode 100644
index 00000000000..254b409ddd9
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.auto_mode.json
@@ -0,0 +1,57 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 76.64,
+ "timestamp": "2020-03-05T03:55:38.160Z",
+ "celsius": 24.8,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T03:55:38.160Z",
+ "percentage": 62.5,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null,
+ "type": "TADO_MODE"
+ },
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "AUTO",
+ "power": "ON"
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T03:56:38.627Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "AUTO",
+ "power": "ON"
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.cool_mode.json b/tests/fixtures/tado/smartac3.cool_mode.json
new file mode 100644
index 00000000000..a7db2cc75bc
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.cool_mode.json
@@ -0,0 +1,67 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 76.57,
+ "timestamp": "2020-03-05T03:57:38.850Z",
+ "celsius": 24.76,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T03:57:38.850Z",
+ "percentage": 60.9,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null,
+ "type": "TADO_MODE"
+ },
+ "setting": {
+ "fanSpeed": "AUTO",
+ "type": "AIR_CONDITIONING",
+ "mode": "COOL",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 64.0,
+ "celsius": 17.78
+ }
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T04:01:07.162Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "fanSpeed": "AUTO",
+ "type": "AIR_CONDITIONING",
+ "mode": "COOL",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 64.0,
+ "celsius": 17.78
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.dry_mode.json b/tests/fixtures/tado/smartac3.dry_mode.json
new file mode 100644
index 00000000000..d04612d1105
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.dry_mode.json
@@ -0,0 +1,57 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 77.02,
+ "timestamp": "2020-03-05T04:02:07.396Z",
+ "celsius": 25.01,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T04:02:07.396Z",
+ "percentage": 62.0,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null,
+ "type": "TADO_MODE"
+ },
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "DRY",
+ "power": "ON"
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T04:02:40.867Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "DRY",
+ "power": "ON"
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.fan_mode.json b/tests/fixtures/tado/smartac3.fan_mode.json
new file mode 100644
index 00000000000..6907c31c517
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.fan_mode.json
@@ -0,0 +1,57 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 77.02,
+ "timestamp": "2020-03-05T04:02:07.396Z",
+ "celsius": 25.01,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T04:02:07.396Z",
+ "percentage": 62.0,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null,
+ "type": "TADO_MODE"
+ },
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "FAN",
+ "power": "ON"
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T04:03:44.328Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "mode": "FAN",
+ "power": "ON"
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.heat_mode.json b/tests/fixtures/tado/smartac3.heat_mode.json
new file mode 100644
index 00000000000..06b5a350d83
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.heat_mode.json
@@ -0,0 +1,67 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 76.57,
+ "timestamp": "2020-03-05T03:57:38.850Z",
+ "celsius": 24.76,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T03:57:38.850Z",
+ "percentage": 60.9,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null,
+ "type": "TADO_MODE"
+ },
+ "setting": {
+ "fanSpeed": "AUTO",
+ "type": "AIR_CONDITIONING",
+ "mode": "HEAT",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 61.0,
+ "celsius": 16.11
+ }
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T03:59:36.390Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "fanSpeed": "AUTO",
+ "type": "AIR_CONDITIONING",
+ "mode": "HEAT",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 61.0,
+ "celsius": 16.11
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.hvac_off.json b/tests/fixtures/tado/smartac3.hvac_off.json
new file mode 100644
index 00000000000..83e9d1a83d5
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.hvac_off.json
@@ -0,0 +1,55 @@
+{
+ "tadoMode": "AWAY",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 70.59,
+ "timestamp": "2020-03-05T01:21:44.089Z",
+ "celsius": 21.44,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T01:21:44.089Z",
+ "percentage": 48.2,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null,
+ "type": "MANUAL"
+ },
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "OFF"
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-02-29T05:34:10.318Z",
+ "type": "POWER",
+ "value": "OFF"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T04:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "OFF"
+ }
+}
diff --git a/tests/fixtures/tado/smartac3.manual_off.json b/tests/fixtures/tado/smartac3.manual_off.json
new file mode 100644
index 00000000000..a9538f30dbe
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.manual_off.json
@@ -0,0 +1,55 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 77.02,
+ "timestamp": "2020-03-05T04:02:07.396Z",
+ "celsius": 25.01,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T04:02:07.396Z",
+ "percentage": 62.0,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null,
+ "type": "MANUAL"
+ },
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "OFF"
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T04:05:08.804Z",
+ "type": "POWER",
+ "value": "OFF"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "OFF"
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.offline.json b/tests/fixtures/tado/smartac3.offline.json
new file mode 100644
index 00000000000..fda1e6468eb
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.offline.json
@@ -0,0 +1,71 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 77.09,
+ "timestamp": "2020-03-03T21:23:57.846Z",
+ "celsius": 25.05,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-03T21:23:57.846Z",
+ "percentage": 61.6,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "OFFLINE",
+ "reason": {
+ "code": "disconnectedDevice",
+ "title": "There is a disconnected device."
+ }
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": {
+ "termination": {
+ "typeSkillBasedApp": "TADO_MODE",
+ "projectedExpiry": null,
+ "type": "TADO_MODE"
+ },
+ "setting": {
+ "fanSpeed": "AUTO",
+ "type": "AIR_CONDITIONING",
+ "mode": "COOL",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 64.0,
+ "celsius": 17.78
+ }
+ },
+ "type": "MANUAL"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-02-29T18:42:26.683Z",
+ "type": "POWER",
+ "value": "OFF"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": "MANUAL",
+ "nextScheduleChange": null,
+ "setting": {
+ "fanSpeed": "AUTO",
+ "type": "AIR_CONDITIONING",
+ "mode": "COOL",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 64.0,
+ "celsius": 17.78
+ }
+ }
+}
diff --git a/tests/fixtures/tado/smartac3.smart_mode.json b/tests/fixtures/tado/smartac3.smart_mode.json
new file mode 100644
index 00000000000..357a1a96658
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.smart_mode.json
@@ -0,0 +1,50 @@
+{
+ "tadoMode": "HOME",
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "fahrenheit": 75.97,
+ "timestamp": "2020-03-05T03:50:24.769Z",
+ "celsius": 24.43,
+ "type": "TEMPERATURE",
+ "precision": {
+ "fahrenheit": 0.1,
+ "celsius": 0.1
+ }
+ },
+ "humidity": {
+ "timestamp": "2020-03-05T03:50:24.769Z",
+ "percentage": 60.0,
+ "type": "PERCENTAGE"
+ }
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "openWindow": null,
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "overlay": null,
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-05T03:52:22.253Z",
+ "type": "POWER",
+ "value": "OFF"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-05T08:00:00.000Z"
+ },
+ "preparation": null,
+ "overlayType": null,
+ "nextScheduleChange": null,
+ "setting": {
+ "fanSpeed": "MIDDLE",
+ "type": "AIR_CONDITIONING",
+ "mode": "COOL",
+ "power": "ON",
+ "temperature": {
+ "fahrenheit": 68.0,
+ "celsius": 20.0
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/smartac3.turning_off.json b/tests/fixtures/tado/smartac3.turning_off.json
new file mode 100644
index 00000000000..0c16f85811a
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.turning_off.json
@@ -0,0 +1,55 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "OFF"
+ },
+ "overlayType": "MANUAL",
+ "overlay": {
+ "type": "MANUAL",
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "OFF"
+ },
+ "termination": {
+ "type": "MANUAL",
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null
+ }
+ },
+ "openWindow": null,
+ "nextScheduleChange": null,
+ "nextTimeBlock": {
+ "start": "2020-03-07T04:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-06T19:05:21.835Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "celsius": 21.40,
+ "fahrenheit": 70.52,
+ "timestamp": "2020-03-06T19:06:13.185Z",
+ "type": "TEMPERATURE",
+ "precision": {
+ "celsius": 0.1,
+ "fahrenheit": 0.1
+ }
+ },
+ "humidity": {
+ "type": "PERCENTAGE",
+ "percentage": 49.20,
+ "timestamp": "2020-03-06T19:06:13.185Z"
+ }
+ }
+}
diff --git a/tests/fixtures/tado/smartac3.with_swing.json b/tests/fixtures/tado/smartac3.with_swing.json
new file mode 100644
index 00000000000..c72cc2ad50b
--- /dev/null
+++ b/tests/fixtures/tado/smartac3.with_swing.json
@@ -0,0 +1,64 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "ON",
+ "mode": "HEAT",
+ "temperature": {
+ "celsius": 20.00,
+ "fahrenheit": 68.00
+ },
+ "fanSpeed": "AUTO",
+ "swing": "ON"
+ },
+ "overlayType": null,
+ "overlay": null,
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-28T04:30:00Z",
+ "setting": {
+ "type": "AIR_CONDITIONING",
+ "power": "ON",
+ "mode": "HEAT",
+ "temperature": {
+ "celsius": 23.00,
+ "fahrenheit": 73.40
+ },
+ "fanSpeed": "AUTO",
+ "swing": "ON"
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-28T04:30:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {
+ "acPower": {
+ "timestamp": "2020-03-27T23:02:22.260Z",
+ "type": "POWER",
+ "value": "ON"
+ }
+ },
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "celsius": 20.88,
+ "fahrenheit": 69.58,
+ "timestamp": "2020-03-28T02:09:27.830Z",
+ "type": "TEMPERATURE",
+ "precision": {
+ "celsius": 0.1,
+ "fahrenheit": 0.1
+ }
+ },
+ "humidity": {
+ "type": "PERCENTAGE",
+ "percentage": 42.30,
+ "timestamp": "2020-03-28T02:09:27.830Z"
+ }
+ }
+}
diff --git a/tests/fixtures/tado/tadov2.heating.auto_mode.json b/tests/fixtures/tado/tadov2.heating.auto_mode.json
new file mode 100644
index 00000000000..34464051f1e
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.heating.auto_mode.json
@@ -0,0 +1,58 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "HEATING",
+ "power": "ON",
+ "temperature": {
+ "celsius": 20.00,
+ "fahrenheit": 68.00
+ }
+ },
+ "overlayType": null,
+ "overlay": null,
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-10T17:00:00Z",
+ "setting": {
+ "type": "HEATING",
+ "power": "ON",
+ "temperature": {
+ "celsius": 21.00,
+ "fahrenheit": 69.80
+ }
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-10T17:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {
+ "heatingPower": {
+ "type": "PERCENTAGE",
+ "percentage": 0.00,
+ "timestamp": "2020-03-10T07:47:45.978Z"
+ }
+ },
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "celsius": 20.65,
+ "fahrenheit": 69.17,
+ "timestamp": "2020-03-10T07:44:11.947Z",
+ "type": "TEMPERATURE",
+ "precision": {
+ "celsius": 0.1,
+ "fahrenheit": 0.1
+ }
+ },
+ "humidity": {
+ "type": "PERCENTAGE",
+ "percentage": 45.20,
+ "timestamp": "2020-03-10T07:44:11.947Z"
+ }
+ }
+}
diff --git a/tests/fixtures/tado/tadov2.heating.manual_mode.json b/tests/fixtures/tado/tadov2.heating.manual_mode.json
new file mode 100644
index 00000000000..a62499d7dd4
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.heating.manual_mode.json
@@ -0,0 +1,73 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "HEATING",
+ "power": "ON",
+ "temperature": {
+ "celsius": 20.50,
+ "fahrenheit": 68.90
+ }
+ },
+ "overlayType": "MANUAL",
+ "overlay": {
+ "type": "MANUAL",
+ "setting": {
+ "type": "HEATING",
+ "power": "ON",
+ "temperature": {
+ "celsius": 20.50,
+ "fahrenheit": 68.90
+ }
+ },
+ "termination": {
+ "type": "MANUAL",
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null
+ }
+ },
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-10T17:00:00Z",
+ "setting": {
+ "type": "HEATING",
+ "power": "ON",
+ "temperature": {
+ "celsius": 21.00,
+ "fahrenheit": 69.80
+ }
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-10T17:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {
+ "heatingPower": {
+ "type": "PERCENTAGE",
+ "percentage": 0.00,
+ "timestamp": "2020-03-10T07:47:45.978Z"
+ }
+ },
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "celsius": 20.65,
+ "fahrenheit": 69.17,
+ "timestamp": "2020-03-10T07:44:11.947Z",
+ "type": "TEMPERATURE",
+ "precision": {
+ "celsius": 0.1,
+ "fahrenheit": 0.1
+ }
+ },
+ "humidity": {
+ "type": "PERCENTAGE",
+ "percentage": 45.20,
+ "timestamp": "2020-03-10T07:44:11.947Z"
+ }
+ }
+}
diff --git a/tests/fixtures/tado/tadov2.heating.off_mode.json b/tests/fixtures/tado/tadov2.heating.off_mode.json
new file mode 100644
index 00000000000..e22805abc73
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.heating.off_mode.json
@@ -0,0 +1,67 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "HEATING",
+ "power": "OFF",
+ "temperature": null
+ },
+ "overlayType": "MANUAL",
+ "overlay": {
+ "type": "MANUAL",
+ "setting": {
+ "type": "HEATING",
+ "power": "OFF",
+ "temperature": null
+ },
+ "termination": {
+ "type": "MANUAL",
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null
+ }
+ },
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-10T17:00:00Z",
+ "setting": {
+ "type": "HEATING",
+ "power": "ON",
+ "temperature": {
+ "celsius": 21.00,
+ "fahrenheit": 69.80
+ }
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-10T17:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {
+ "heatingPower": {
+ "type": "PERCENTAGE",
+ "percentage": 0.00,
+ "timestamp": "2020-03-10T07:47:45.978Z"
+ }
+ },
+ "sensorDataPoints": {
+ "insideTemperature": {
+ "celsius": 20.65,
+ "fahrenheit": 69.17,
+ "timestamp": "2020-03-10T07:44:11.947Z",
+ "type": "TEMPERATURE",
+ "precision": {
+ "celsius": 0.1,
+ "fahrenheit": 0.1
+ }
+ },
+ "humidity": {
+ "type": "PERCENTAGE",
+ "percentage": 45.20,
+ "timestamp": "2020-03-10T07:44:11.947Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/tado/tadov2.water_heater.auto_mode.json b/tests/fixtures/tado/tadov2.water_heater.auto_mode.json
new file mode 100644
index 00000000000..7df4e3f5ea6
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.water_heater.auto_mode.json
@@ -0,0 +1,33 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "ON",
+ "temperature": {
+ "celsius": 65.00,
+ "fahrenheit": 149.00
+ }
+ },
+ "overlayType": null,
+ "overlay": null,
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-10T22:00:00Z",
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "OFF",
+ "temperature": null
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-10T22:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {},
+ "sensorDataPoints": {}
+}
diff --git a/tests/fixtures/tado/tadov2.water_heater.heating.json b/tests/fixtures/tado/tadov2.water_heater.heating.json
new file mode 100644
index 00000000000..8eecc79d63c
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.water_heater.heating.json
@@ -0,0 +1,51 @@
+{
+ "activityDataPoints" : {},
+ "preparation" : null,
+ "openWindow" : null,
+ "tadoMode" : "HOME",
+ "nextScheduleChange" : {
+ "setting" : {
+ "temperature" : {
+ "fahrenheit" : 149,
+ "celsius" : 65
+ },
+ "type" : "HOT_WATER",
+ "power" : "ON"
+ },
+ "start" : "2020-03-26T05:00:00Z"
+ },
+ "nextTimeBlock" : {
+ "start" : "2020-03-26T05:00:00.000Z"
+ },
+ "overlay" : {
+ "setting" : {
+ "temperature" : {
+ "celsius" : 30,
+ "fahrenheit" : 86
+ },
+ "type" : "HOT_WATER",
+ "power" : "ON"
+ },
+ "termination" : {
+ "type" : "TADO_MODE",
+ "projectedExpiry" : "2020-03-26T05:00:00Z",
+ "typeSkillBasedApp" : "TADO_MODE"
+ },
+ "type" : "MANUAL"
+ },
+ "geolocationOverride" : false,
+ "geolocationOverrideDisableTime" : null,
+ "sensorDataPoints" : {},
+ "overlayType" : "MANUAL",
+ "link" : {
+ "state" : "ONLINE"
+ },
+ "setting" : {
+ "type" : "HOT_WATER",
+ "temperature" : {
+ "fahrenheit" : 86,
+ "celsius" : 30
+ },
+ "power" : "ON"
+ }
+}
diff --git a/tests/fixtures/tado/tadov2.water_heater.manual_mode.json b/tests/fixtures/tado/tadov2.water_heater.manual_mode.json
new file mode 100644
index 00000000000..21972a55d6d
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.water_heater.manual_mode.json
@@ -0,0 +1,48 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "ON",
+ "temperature": {
+ "celsius": 55.00,
+ "fahrenheit": 131.00
+ }
+ },
+ "overlayType": "MANUAL",
+ "overlay": {
+ "type": "MANUAL",
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "ON",
+ "temperature": {
+ "celsius": 55.00,
+ "fahrenheit": 131.00
+ }
+ },
+ "termination": {
+ "type": "MANUAL",
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null
+ }
+ },
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-10T22:00:00Z",
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "OFF",
+ "temperature": null
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-10T22:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {},
+ "sensorDataPoints": {}
+}
diff --git a/tests/fixtures/tado/tadov2.water_heater.off_mode.json b/tests/fixtures/tado/tadov2.water_heater.off_mode.json
new file mode 100644
index 00000000000..12698db601b
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.water_heater.off_mode.json
@@ -0,0 +1,42 @@
+{
+ "tadoMode": "HOME",
+ "geolocationOverride": false,
+ "geolocationOverrideDisableTime": null,
+ "preparation": null,
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "OFF",
+ "temperature": null
+ },
+ "overlayType": "MANUAL",
+ "overlay": {
+ "type": "MANUAL",
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "OFF",
+ "temperature": null
+ },
+ "termination": {
+ "type": "MANUAL",
+ "typeSkillBasedApp": "MANUAL",
+ "projectedExpiry": null
+ }
+ },
+ "openWindow": null,
+ "nextScheduleChange": {
+ "start": "2020-03-10T22:00:00Z",
+ "setting": {
+ "type": "HOT_WATER",
+ "power": "OFF",
+ "temperature": null
+ }
+ },
+ "nextTimeBlock": {
+ "start": "2020-03-10T22:00:00.000Z"
+ },
+ "link": {
+ "state": "ONLINE"
+ },
+ "activityDataPoints": {},
+ "sensorDataPoints": {}
+}
diff --git a/tests/fixtures/tado/tadov2.zone_capabilities.json b/tests/fixtures/tado/tadov2.zone_capabilities.json
new file mode 100644
index 00000000000..a908b699e64
--- /dev/null
+++ b/tests/fixtures/tado/tadov2.zone_capabilities.json
@@ -0,0 +1,19 @@
+{
+ "type" : "HEATING",
+ "HEAT" : {
+ "temperatures" : {
+ "celsius" : {
+ "max" : 31,
+ "step" : 1,
+ "min" : 16
+ },
+ "fahrenheit" : {
+ "step" : 1,
+ "max" : 88,
+ "min" : 61
+ }
+ }
+ },
+ "AUTO" : {},
+ "FAN" : {}
+}
diff --git a/tests/fixtures/tado/token.json b/tests/fixtures/tado/token.json
new file mode 100644
index 00000000000..1e0089a1c9a
--- /dev/null
+++ b/tests/fixtures/tado/token.json
@@ -0,0 +1,8 @@
+{
+ "expires_in" : 599,
+ "scope" : "home.user",
+ "token_type" : "bearer",
+ "refresh_token" : "refresh",
+ "access_token" : "access",
+ "jti" : "jti"
+}
diff --git a/tests/fixtures/tado/water_heater_zone_capabilities.json b/tests/fixtures/tado/water_heater_zone_capabilities.json
new file mode 100644
index 00000000000..f3f0daa6c09
--- /dev/null
+++ b/tests/fixtures/tado/water_heater_zone_capabilities.json
@@ -0,0 +1,17 @@
+{
+ "canSetTemperature" : true,
+ "DRY" : {},
+ "type" : "HOT_WATER",
+ "temperatures" : {
+ "celsius" : {
+ "min" : 16,
+ "max" : 31,
+ "step" : 1
+ },
+ "fahrenheit" : {
+ "step" : 1,
+ "max" : 88,
+ "min" : 61
+ }
+ }
+}
diff --git a/tests/fixtures/tado/zone_capabilities.json b/tests/fixtures/tado/zone_capabilities.json
new file mode 100644
index 00000000000..8435094ecca
--- /dev/null
+++ b/tests/fixtures/tado/zone_capabilities.json
@@ -0,0 +1,46 @@
+{
+ "type" : "AIR_CONDITIONING",
+ "HEAT" : {
+ "fanSpeeds" : [
+ "AUTO",
+ "HIGH",
+ "MIDDLE",
+ "LOW"
+ ],
+ "temperatures" : {
+ "celsius" : {
+ "max" : 31,
+ "step" : 1,
+ "min" : 16
+ },
+ "fahrenheit" : {
+ "step" : 1,
+ "max" : 88,
+ "min" : 61
+ }
+ }
+ },
+ "AUTO" : {},
+ "DRY" : {},
+ "FAN" : {},
+ "COOL" : {
+ "temperatures" : {
+ "celsius" : {
+ "min" : 16,
+ "step" : 1,
+ "max" : 31
+ },
+ "fahrenheit" : {
+ "min" : 61,
+ "max" : 88,
+ "step" : 1
+ }
+ },
+ "fanSpeeds" : [
+ "AUTO",
+ "HIGH",
+ "MIDDLE",
+ "LOW"
+ ]
+ }
+}
diff --git a/tests/fixtures/tado/zone_state.json b/tests/fixtures/tado/zone_state.json
new file mode 100644
index 00000000000..c206dc9d081
--- /dev/null
+++ b/tests/fixtures/tado/zone_state.json
@@ -0,0 +1,55 @@
+{
+ "openWindow" : null,
+ "nextScheduleChange" : null,
+ "geolocationOverrideDisableTime" : null,
+ "sensorDataPoints" : {
+ "insideTemperature" : {
+ "celsius" : 22.43,
+ "type" : "TEMPERATURE",
+ "precision" : {
+ "fahrenheit" : 0.1,
+ "celsius" : 0.1
+ },
+ "timestamp" : "2020-03-23T18:30:07.377Z",
+ "fahrenheit" : 72.37
+ },
+ "humidity" : {
+ "timestamp" : "2020-03-23T18:30:07.377Z",
+ "percentage" : 60.2,
+ "type" : "PERCENTAGE"
+ }
+ },
+ "overlay" : {
+ "type" : "MANUAL",
+ "termination" : {
+ "projectedExpiry" : null,
+ "typeSkillBasedApp" : "MANUAL",
+ "type" : "MANUAL"
+ },
+ "setting" : {
+ "power" : "OFF",
+ "type" : "AIR_CONDITIONING"
+ }
+ },
+ "geolocationOverride" : false,
+ "overlayType" : "MANUAL",
+ "activityDataPoints" : {
+ "acPower" : {
+ "type" : "POWER",
+ "timestamp" : "2020-03-11T15:08:23.604Z",
+ "value" : "OFF"
+ }
+ },
+ "tadoMode" : "HOME",
+ "link" : {
+ "state" : "ONLINE"
+ },
+ "setting" : {
+ "power" : "OFF",
+ "type" : "AIR_CONDITIONING"
+ },
+ "nextTimeBlock" : {
+ "start" : "2020-03-24T03:00:00.000Z"
+ },
+ "preparation" : null
+}
diff --git a/tests/fixtures/tado/zone_with_swing_capabilities.json b/tests/fixtures/tado/zone_with_swing_capabilities.json
new file mode 100644
index 00000000000..fc954890e2a
--- /dev/null
+++ b/tests/fixtures/tado/zone_with_swing_capabilities.json
@@ -0,0 +1,46 @@
+{
+ "type": "AIR_CONDITIONING",
+ "AUTO": {
+ "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"],
+ "swings": ["OFF", "ON"]
+ },
+ "COOL": {
+ "temperatures": {
+ "celsius": {
+ "min": 18,
+ "max": 30,
+ "step": 1.0
+ },
+ "fahrenheit": {
+ "min": 64,
+ "max": 86,
+ "step": 1.0
+ }
+ },
+ "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"],
+ "swings": ["OFF", "ON"]
+ },
+ "DRY": {
+ "swings": ["OFF", "ON"]
+ },
+ "FAN": {
+ "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"],
+ "swings": ["OFF", "ON"]
+ },
+ "HEAT": {
+ "temperatures": {
+ "celsius": {
+ "min": 16,
+ "max": 30,
+ "step": 1.0
+ },
+ "fahrenheit": {
+ "min": 61,
+ "max": 86,
+ "step": 1.0
+ }
+ },
+ "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"],
+ "swings": ["OFF", "ON"]
+ }
+}
diff --git a/tests/fixtures/tado/zones.json b/tests/fixtures/tado/zones.json
new file mode 100644
index 00000000000..d85bc9be3ae
--- /dev/null
+++ b/tests/fixtures/tado/zones.json
@@ -0,0 +1,227 @@
+[
+ {
+ "deviceTypes" : [
+ "WR02"
+ ],
+ "type" : "HEATING",
+ "reportAvailable" : false,
+ "dazzleMode" : {
+ "enabled" : true,
+ "supported" : true
+ },
+ "name" : "Baseboard Heater",
+ "supportsDazzle" : true,
+ "id" : 1,
+ "devices" : [
+ {
+ "duties" : [
+ "ZONE_UI",
+ "ZONE_DRIVER",
+ "ZONE_LEADER"
+ ],
+ "currentFwVersion" : "59.4",
+ "deviceType" : "WR02",
+ "serialNo" : "WR4",
+ "shortSerialNo" : "WR4",
+ "commandTableUploadState" : "FINISHED",
+ "connectionState" : {
+ "value" : true,
+ "timestamp" : "2020-03-23T18:30:07.377Z"
+ },
+ "accessPointWiFi" : {
+ "ssid" : "tado8480"
+ },
+ "characteristics" : {
+ "capabilities" : [
+ "INSIDE_TEMPERATURE_MEASUREMENT",
+ "IDENTIFY"
+ ]
+ }
+ }
+ ],
+ "dateCreated" : "2019-11-28T15:58:48.968Z",
+ "dazzleEnabled" : true
+ },
+ {
+ "type" : "HOT_WATER",
+ "reportAvailable" : false,
+ "deviceTypes" : [
+ "WR02"
+ ],
+ "devices" : [
+ {
+ "connectionState" : {
+ "value" : true,
+ "timestamp" : "2020-03-23T18:30:07.377Z"
+ },
+ "accessPointWiFi" : {
+ "ssid" : "tado8480"
+ },
+ "characteristics" : {
+ "capabilities" : [
+ "INSIDE_TEMPERATURE_MEASUREMENT",
+ "IDENTIFY"
+ ]
+ },
+ "duties" : [
+ "ZONE_UI",
+ "ZONE_DRIVER",
+ "ZONE_LEADER"
+ ],
+ "currentFwVersion" : "59.4",
+ "deviceType" : "WR02",
+ "serialNo" : "WR4",
+ "shortSerialNo" : "WR4",
+ "commandTableUploadState" : "FINISHED"
+ }
+ ],
+ "dazzleEnabled" : true,
+ "dateCreated" : "2019-11-28T15:58:48.968Z",
+ "name" : "Water Heater",
+ "dazzleMode" : {
+ "enabled" : true,
+ "supported" : true
+ },
+ "id" : 2,
+ "supportsDazzle" : true
+ },
+ {
+ "dazzleMode" : {
+ "supported" : true,
+ "enabled" : true
+ },
+ "name" : "Air Conditioning",
+ "id" : 3,
+ "supportsDazzle" : true,
+ "devices" : [
+ {
+ "deviceType" : "WR02",
+ "shortSerialNo" : "WR4",
+ "serialNo" : "WR4",
+ "commandTableUploadState" : "FINISHED",
+ "duties" : [
+ "ZONE_UI",
+ "ZONE_DRIVER",
+ "ZONE_LEADER"
+ ],
+ "currentFwVersion" : "59.4",
+ "characteristics" : {
+ "capabilities" : [
+ "INSIDE_TEMPERATURE_MEASUREMENT",
+ "IDENTIFY"
+ ]
+ },
+ "accessPointWiFi" : {
+ "ssid" : "tado8480"
+ },
+ "connectionState" : {
+ "timestamp" : "2020-03-23T18:30:07.377Z",
+ "value" : true
+ }
+ }
+ ],
+ "dazzleEnabled" : true,
+ "dateCreated" : "2019-11-28T15:58:48.968Z",
+ "openWindowDetection" : {
+ "timeoutInSeconds" : 900,
+ "enabled" : true,
+ "supported" : true
+ },
+ "deviceTypes" : [
+ "WR02"
+ ],
+ "reportAvailable" : false,
+ "type" : "AIR_CONDITIONING"
+ },
+ {
+ "type" : "HOT_WATER",
+ "reportAvailable" : false,
+ "deviceTypes" : [
+ "WR02"
+ ],
+ "devices" : [
+ {
+ "connectionState" : {
+ "value" : true,
+ "timestamp" : "2020-03-23T18:30:07.377Z"
+ },
+ "accessPointWiFi" : {
+ "ssid" : "tado8480"
+ },
+ "characteristics" : {
+ "capabilities" : [
+ "INSIDE_TEMPERATURE_MEASUREMENT",
+ "IDENTIFY"
+ ]
+ },
+ "duties" : [
+ "ZONE_UI",
+ "ZONE_DRIVER",
+ "ZONE_LEADER"
+ ],
+ "currentFwVersion" : "59.4",
+ "deviceType" : "WR02",
+ "serialNo" : "WR4",
+ "shortSerialNo" : "WR4",
+ "commandTableUploadState" : "FINISHED"
+ }
+ ],
+ "dazzleEnabled" : true,
+ "dateCreated" : "2019-11-28T15:58:48.968Z",
+ "name" : "Second Water Heater",
+ "dazzleMode" : {
+ "enabled" : true,
+ "supported" : true
+ },
+ "id" : 4,
+ "supportsDazzle" : true
+ },
+ {
+ "dazzleMode" : {
+ "supported" : true,
+ "enabled" : true
+ },
+ "name" : "Air Conditioning with swing",
+ "id" : 5,
+ "supportsDazzle" : true,
+ "devices" : [
+ {
+ "deviceType" : "WR02",
+ "shortSerialNo" : "WR4",
+ "serialNo" : "WR4",
+ "commandTableUploadState" : "FINISHED",
+ "duties" : [
+ "ZONE_UI",
+ "ZONE_DRIVER",
+ "ZONE_LEADER"
+ ],
+ "currentFwVersion" : "59.4",
+ "characteristics" : {
+ "capabilities" : [
+ "INSIDE_TEMPERATURE_MEASUREMENT",
+ "IDENTIFY"
+ ]
+ },
+ "accessPointWiFi" : {
+ "ssid" : "tado8480"
+ },
+ "connectionState" : {
+ "timestamp" : "2020-03-23T18:30:07.377Z",
+ "value" : true
+ }
+ }
+ ],
+ "dazzleEnabled" : true,
+ "dateCreated" : "2019-11-28T15:58:48.968Z",
+ "openWindowDetection" : {
+ "timeoutInSeconds" : 900,
+ "enabled" : true,
+ "supported" : true
+ },
+ "deviceTypes" : [
+ "WR02"
+ ],
+ "reportAvailable" : false,
+ "type" : "AIR_CONDITIONING"
+ }
+]
diff --git a/tests/fixtures/wled/rgb.json b/tests/fixtures/wled/rgb.json
index 70a54f06644..41d2c69d63c 100644
--- a/tests/fixtures/wled/rgb.json
+++ b/tests/fixtures/wled/rgb.json
@@ -62,6 +62,12 @@
"live": false,
"fxcount": 81,
"palcount": 50,
+ "wifi": {
+ "bssid": "AA:AA:AA:AA:AA:BB",
+ "rssi": -62,
+ "signal": 76,
+ "channel": 11
+ },
"arch": "esp8266",
"core": "2_4_2",
"freeheap": 14600,
diff --git a/tests/fixtures/wled/rgbw.json b/tests/fixtures/wled/rgbw.json
index 0d51dfedd2d..ce7033c5888 100644
--- a/tests/fixtures/wled/rgbw.json
+++ b/tests/fixtures/wled/rgbw.json
@@ -48,6 +48,12 @@
"live": false,
"fxcount": 83,
"palcount": 50,
+ "wifi": {
+ "bssid": "AA:AA:AA:AA:AA:BB",
+ "rssi": -62,
+ "signal": 76,
+ "channel": 11
+ },
"arch": "esp8266",
"core": "2_5_2",
"freeheap": 20136,
diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py
index 4cf266e88a2..539d9ad1651 100644
--- a/tests/helpers/test_dispatcher.py
+++ b/tests/helpers/test_dispatcher.py
@@ -1,5 +1,6 @@
"""Test dispatcher helpers."""
import asyncio
+from functools import partial
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
@@ -147,9 +148,13 @@ async def test_callback_exception_gets_logged(hass, caplog):
"""Record calls."""
raise Exception("This is a bad message callback")
- async_dispatcher_connect(hass, "test", bad_handler)
+ # wrap in partial to test message logging.
+ async_dispatcher_connect(hass, "test", partial(bad_handler))
dispatcher_send(hass, "test", "bad")
await hass.async_block_till_done()
await hass.async_block_till_done()
- assert "Exception in bad_handler when dispatching 'test': ('bad',)" in caplog.text
+ assert (
+ f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)"
+ in caplog.text
+ )
diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py
index d9cbbb31561..df247d82d5c 100644
--- a/tests/helpers/test_entity_platform.py
+++ b/tests/helpers/test_entity_platform.py
@@ -8,6 +8,7 @@ import asynctest
import pytest
from homeassistant.const import UNIT_PERCENTAGE
+from homeassistant.core import callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry
from homeassistant.helpers.entity import async_generate_entity_id
@@ -167,7 +168,7 @@ async def test_adding_entities_with_generator_and_thread_callback(hass):
def create_entity(number):
"""Create entity helper."""
- entity = MockEntity()
+ entity = MockEntity(unique_id=f"unique{number}")
entity.entity_id = async_generate_entity_id(DOMAIN + ".{}", "Number", hass=hass)
return entity
@@ -847,3 +848,37 @@ async def test_platform_with_no_setup(hass, caplog):
"The mock-platform platform for the mock-integration integration does not support platform setup."
in caplog.text
)
+
+
+async def test_platforms_sharing_services(hass):
+ """Test platforms share services."""
+ entity_platform1 = MockEntityPlatform(
+ hass, domain="mock_integration", platform_name="mock_platform", platform=None
+ )
+ entity1 = MockEntity(entity_id="mock_integration.entity_1")
+ await entity_platform1.async_add_entities([entity1])
+
+ entity_platform2 = MockEntityPlatform(
+ hass, domain="mock_integration", platform_name="mock_platform", platform=None
+ )
+ entity2 = MockEntity(entity_id="mock_integration.entity_2")
+ await entity_platform2.async_add_entities([entity2])
+
+ entities = []
+
+ @callback
+ def handle_service(entity, data):
+ entities.append(entity)
+
+ entity_platform1.async_register_entity_service("hello", {}, handle_service)
+ entity_platform2.async_register_entity_service(
+ "hello", {}, Mock(side_effect=AssertionError("Should not be called"))
+ )
+
+ await hass.services.async_call(
+ "mock_platform", "hello", {"entity_id": "all"}, blocking=True
+ )
+
+ assert len(entities) == 2
+ assert entity1 in entities
+ assert entity2 in entities
diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py
index 5f0281d3f95..9d7e7751c10 100644
--- a/tests/helpers/test_script.py
+++ b/tests/helpers/test_script.py
@@ -1,6 +1,7 @@
"""The tests for the Script component."""
# pylint: disable=protected-access
import asyncio
+from contextlib import contextmanager
from datetime import timedelta
import logging
from unittest import mock
@@ -15,63 +16,106 @@ import homeassistant.components.scene as scene
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
from homeassistant.core import Context, callback
from homeassistant.helpers import config_validation as cv, script
+from homeassistant.helpers.event import async_call_later
import homeassistant.util.dt as dt_util
-from tests.common import async_fire_time_changed
+from tests.common import (
+ async_capture_events,
+ async_fire_time_changed,
+ async_mock_service,
+)
ENTITY_ID = "script.test"
-_ALL_RUN_MODES = [None, "background", "blocking"]
+_BASIC_SCRIPT_MODES = ("legacy", "parallel")
-async def test_firing_event_basic(hass):
+@pytest.fixture
+def mock_timeout(hass, monkeypatch):
+ """Mock async_timeout.timeout."""
+
+ class MockTimeout:
+ def __init__(self, timeout):
+ self._timeout = timeout
+ self._loop = asyncio.get_event_loop()
+ self._task = None
+ self._cancelled = False
+ self._unsub = None
+
+ async def __aenter__(self):
+ if self._timeout is None:
+ return self
+ self._task = asyncio.Task.current_task()
+ if self._timeout <= 0:
+ self._loop.call_soon(self._cancel_task)
+ return self
+ # Wait for a time_changed event instead of real time passing.
+ self._unsub = async_call_later(hass, self._timeout, self._cancel_task)
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ if exc_type is asyncio.CancelledError and self._cancelled:
+ self._unsub = None
+ self._task = None
+ raise asyncio.TimeoutError
+ if self._timeout is not None and self._unsub:
+ self._unsub()
+ self._unsub = None
+ self._task = None
+ return None
+
+ @callback
+ def _cancel_task(self, now=None):
+ if self._task is not None:
+ self._task.cancel()
+ self._cancelled = True
+
+ monkeypatch.setattr(script, "timeout", MockTimeout)
+
+
+def async_watch_for_action(script_obj, message):
+ """Watch for message in last_action."""
+ flag = asyncio.Event()
+
+ @callback
+ def check_action():
+ if script_obj.last_action and message in script_obj.last_action:
+ flag.set()
+
+ script_obj.change_listener = check_action
+ return flag
+
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_firing_event_basic(hass, script_mode):
"""Test the firing of events."""
event = "test_event"
context = Context()
+ events = async_capture_events(hass, event)
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+ sequence = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- hass.bus.async_listen(event, record_event)
+ assert script_obj.is_legacy == (script_mode == "legacy")
+ assert script_obj.can_cancel == (script_mode != "legacy")
- schema = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
+ await script_obj.async_run(context=context)
+ await hass.async_block_till_done()
- # For this one test we'll make sure "legacy" works the same as None.
- for run_mode in _ALL_RUN_MODES + ["legacy"]:
- events = []
-
- 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(events) == 1
- assert events[0].context is context
- assert events[0].data.get("hello") == "world"
- assert not script_obj.can_cancel
+ assert len(events) == 1
+ assert events[0].context is context
+ assert events[0].data.get("hello") == "world"
+ assert script_obj.can_cancel == (script_mode != "legacy")
-async def test_firing_event_template(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_firing_event_template(hass, script_mode):
"""Test the firing of events."""
event = "test_event"
context = Context()
+ events = async_capture_events(hass, event)
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- schema = cv.SCRIPT_SCHEMA(
+ sequence = cv.SCRIPT_SCHEMA(
{
"event": event,
"event_data_template": {
@@ -84,152 +128,47 @@ async def test_firing_event_template(hass):
},
}
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- for run_mode in _ALL_RUN_MODES:
- events = []
+ assert script_obj.can_cancel == (script_mode != "legacy")
- if run_mode is None:
- script_obj = script.Script(hass, schema)
- else:
- script_obj = script.Script(hass, schema, run_mode=run_mode)
+ await script_obj.async_run({"is_world": "yes"}, context=context)
+ await hass.async_block_till_done()
- 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"],
- }
+ 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_basic(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_calling_service_basic(hass, script_mode):
"""Test the calling of a service."""
context = Context()
+ calls = async_mock_service(hass, "test", "script")
- @callback
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
+ sequence = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- hass.services.async_register("test", "script", record_call)
+ assert script_obj.can_cancel == (script_mode != "legacy")
- schema = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}})
+ await script_obj.async_run(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(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_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."""
- context = Context()
-
- @callback
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
-
- hass.services.async_register(scene.DOMAIN, SERVICE_TURN_ON, record_call)
-
- schema = cv.SCRIPT_SCHEMA({"scene": "scene.hello"})
-
- 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(ATTR_ENTITY_ID) == "scene.hello"
-
-
-async def test_calling_service_template(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_calling_service_template(hass, script_mode):
"""Test the calling of a service."""
context = Context()
+ calls = async_mock_service(hass, "test", "script")
- @callback
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
-
- hass.services.async_register("test", "script", record_call)
-
- schema = cv.SCRIPT_SCHEMA(
+ sequence = cv.SCRIPT_SCHEMA(
{
"service_template": """
{% if True %}
@@ -248,32 +187,30 @@ async def test_calling_service_template(hass):
},
}
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- for run_mode in _ALL_RUN_MODES:
- calls = []
+ assert script_obj.can_cancel == (script_mode != "legacy")
- if run_mode is None:
- script_obj = script.Script(hass, schema)
- else:
- script_obj = script.Script(hass, schema, run_mode=run_mode)
+ await script_obj.async_run({"is_world": "yes"}, context=context)
+ await hass.async_block_till_done()
- 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):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_multiple_runs_no_wait(hass, script_mode):
"""Test multiple runs with no wait in script."""
logger = logging.getLogger("TEST")
+ calls = []
+ heard_event = asyncio.Event()
async def async_simulate_long_service(service):
"""Simulate a service that takes a not insignificant time."""
+ fire = service.data.get("fire")
+ listen = service.data.get("listen")
+ service_done = asyncio.Event()
@callback
def service_done_cb(event):
@@ -281,29 +218,20 @@ async def test_multiple_runs_no_wait(hass):
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(
+ sequence = cv.SCRIPT_SCHEMA(
[
{
"service": "test.script",
@@ -315,209 +243,199 @@ async def test_multiple_runs_no_wait(hass):
},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- for run_mode in _ALL_RUN_MODES:
- calls = []
- heard_event.clear()
+ # 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.
- 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(
+ unsub = hass.bus.async_listen("1", heard_event_cb)
+ logger.debug("starting 1st script")
+ hass.async_create_task(
+ 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)
+ )
+ await asyncio.wait_for(heard_event.wait(), 1)
+ unsub()
- 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()
- 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
+ assert len(calls) == 4
-async def test_delay_basic(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_activating_scene(hass, script_mode):
+ """Test the activation of a scene."""
+ context = Context()
+ calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON)
+
+ sequence = cv.SCRIPT_SCHEMA({"scene": "scene.hello"})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+
+ assert script_obj.can_cancel == (script_mode != "legacy")
+
+ 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"
+
+
+@pytest.mark.parametrize("count", [1, 3])
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_stop_no_wait(hass, caplog, script_mode, count):
+ """Test stopping script."""
+ service_started_sem = asyncio.Semaphore(0)
+ finish_service_event = asyncio.Event()
+ event = "test_event"
+ events = async_capture_events(hass, event)
+
+ async def async_simulate_long_service(service):
+ """Simulate a service that takes a not insignificant time."""
+ service_started_sem.release()
+ await finish_service_event.wait()
+
+ hass.services.async_register("test", "script", async_simulate_long_service)
+
+ sequence = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+
+ # Get script started specified number of times and wait until the test.script
+ # service has started for each run.
+ tasks = []
+ for _ in range(count):
+ hass.async_create_task(script_obj.async_run())
+ tasks.append(hass.async_create_task(service_started_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
+ were_no_events = len(events) == 0
+
+ # Begin the process of stopping the script (which should stop all runs), and then
+ # let the service calls complete.
+ hass.async_create_task(script_obj.async_stop())
+ finish_service_event.set()
+
+ await hass.async_block_till_done()
+
+ assert script_was_runing
+ assert were_no_events
+ assert not script_obj.is_running
+ assert len(events) == (count if script_mode == "legacy" else 0)
+
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_delay_basic(hass, mock_timeout, script_mode):
"""Test the delay."""
delay_alias = "delay step"
- delay_started_flag = asyncio.Event()
+ sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 5}, "alias": delay_alias})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ delay_started_flag = async_watch_for_action(script_obj, delay_alias)
- @callback
- def delay_started_cb():
- delay_started_flag.set()
+ assert script_obj.can_cancel
- delay = timedelta(milliseconds=10)
- schema = cv.SCRIPT_SCHEMA({"delay": delay, "alias": delay_alias})
+ try:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
- for run_mode in _ALL_RUN_MODES:
- delay_started_flag.clear()
+ assert script_obj.is_running
+ assert script_obj.last_action == delay_alias
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
- 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
+ assert not script_obj.is_running
+ assert script_obj.last_action is None
-async def test_multiple_runs_delay(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_multiple_runs_delay(hass, mock_timeout, script_mode):
"""Test multiple runs with delay in script."""
event = "test_event"
- delay_started_flag = asyncio.Event()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- @callback
- def delay_started_cb():
- delay_started_flag.set()
-
- delay = timedelta(milliseconds=10)
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ delay = timedelta(seconds=5)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"event": event, "event_data": {"value": 1}},
{"delay": delay},
{"event": event, "event_data": {"value": 2}},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ delay_started_flag = async_watch_for_action(script_obj, "delay")
- for run_mode in _ALL_RUN_MODES:
- events = []
- delay_started_flag.clear()
+ try:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 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.
+ 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.
+ if script_mode == "legacy":
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()
-
- @callback
- def delay_started_cb():
- delay_started_flag.set()
-
- schema = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 1 }}"})
-
- 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())
+ script_obj.sequence[1]["alias"] = "delay run 2"
+ delay_started_flag = async_watch_for_action(script_obj, "delay run 2")
+ 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
+ async_fire_time_changed(hass, dt_util.utcnow() + delay)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ if script_mode == "legacy":
+ assert len(events) == 2
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
+ 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_invalid(hass, caplog):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_delay_template_ok(hass, mock_timeout, script_mode):
+ """Test the delay as a template."""
+ sequence = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 5 }}"})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ delay_started_flag = async_watch_for_action(script_obj, "delay")
+
+ assert script_obj.can_cancel
+
+ try:
+ 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:
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_delay_template_invalid(hass, caplog, script_mode):
"""Test the delay as a template that fails."""
event = "test_event"
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"event": event},
{"delay": "{{ invalid_delay }}"},
@@ -525,83 +443,50 @@ async def test_delay_template_invalid(hass, caplog):
{"event": event},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ start_idx = len(caplog.records)
- for run_mode in _ALL_RUN_MODES:
- events = []
+ await script_obj.async_run()
+ 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)
- start_idx = len(caplog.records)
+ assert any(
+ rec.levelname == "ERROR" and "Error rendering" in rec.message
+ for rec in caplog.records[start_idx:]
+ )
- await script_obj.async_run()
+ assert not script_obj.is_running
+ assert len(events) == 1
+
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_delay_template_complex_ok(hass, mock_timeout, script_mode):
+ """Test the delay with a working complex template."""
+ sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": "{{ 5 }}"}})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ delay_started_flag = async_watch_for_action(script_obj, "delay")
+
+ assert script_obj.can_cancel
+
+ try:
+ 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:
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
await hass.async_block_till_done()
- 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_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):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_delay_template_complex_invalid(hass, caplog, script_mode):
"""Test the delay with a complex template that fails."""
event = "test_event"
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"event": event},
{"delay": {"seconds": "{{ invalid_delay }}"}},
@@ -609,543 +494,260 @@ async def test_delay_template_complex_invalid(hass, caplog):
{"event": event},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ start_idx = len(caplog.records)
- for run_mode in _ALL_RUN_MODES:
- events = []
+ await script_obj.async_run()
+ 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)
- start_idx = len(caplog.records)
+ assert any(
+ rec.levelname == "ERROR" and "Error rendering" in rec.message
+ for rec in caplog.records[start_idx:]
+ )
- await script_obj.async_run()
- await hass.async_block_till_done()
+ 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:]
- )
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_cancel_delay(hass, script_mode):
+ """Test the cancelling while the delay is present."""
+ event = "test_event"
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA([{"delay": {"seconds": 5}}, {"event": event}])
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ delay_started_flag = async_watch_for_action(script_obj, "delay")
+
+ try:
+ 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()
assert not script_obj.is_running
- assert len(events) == 1
+
+ # Make sure the script is really stopped.
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 0
-async def test_cancel_delay(hass):
- """Test the cancelling while the delay is present."""
- delay_started_flag = asyncio.Event()
- event = "test_event"
-
- @callback
- def delay_started_cb():
- delay_started_flag.set()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- delay = timedelta(milliseconds=10)
- schema = cv.SCRIPT_SCHEMA([{"delay": delay}, {"event": event}])
-
- for run_mode in _ALL_RUN_MODES:
- delay_started_flag.clear()
- events = []
-
- 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()
-
- assert not script_obj.is_running
-
- # 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()
-
- assert not script_obj.is_running
- assert len(events) == 0
-
-
-async def test_wait_template_basic(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_wait_template_basic(hass, script_mode):
"""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(
+ sequence = cv.SCRIPT_SCHEMA(
{
"wait_template": "{{ states.switch.test.state == 'off' }}",
"alias": wait_alias,
}
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ wait_started_flag = async_watch_for_action(script_obj, wait_alias)
- for run_mode in _ALL_RUN_MODES:
- wait_started_flag.clear()
+ assert script_obj.can_cancel
+
+ try:
hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 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
- )
+ 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 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
+ assert not script_obj.is_running
+ assert script_obj.last_action is None
-async def test_multiple_runs_wait_template(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_multiple_runs_wait_template(hass, script_mode):
"""Test multiple runs with wait_template in script."""
event = "test_event"
- wait_started_flag = asyncio.Event()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- @callback
- def wait_started_cb():
- wait_started_flag.set()
-
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"event": event, "event_data": {"value": 1}},
{"wait_template": "{{ states.switch.test.state == 'off' }}"},
{"event": event, "event_data": {"value": 2}},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
- for run_mode in _ALL_RUN_MODES:
- events = []
- wait_started_flag.clear()
+ try:
hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
- if run_mode is None:
- script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ 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 script_mode == "legacy":
+ await script_obj.async_run()
else:
- script_obj = script.Script(
- hass, schema, change_listener=wait_started_cb, run_mode=run_mode
- )
+ hass.async_create_task(script_obj.async_run())
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
- 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
+ assert not script_obj.is_running
+ if script_mode == "legacy":
+ assert len(events) == 2
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
+ 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):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_cancel_wait_template(hass, script_mode):
"""Test the cancelling while wait_template is present."""
- wait_started_flag = asyncio.Event()
event = "test_event"
-
- @callback
- def wait_started_cb():
- wait_started_flag.set()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"wait_template": "{{ states.switch.test.state == 'off' }}"},
{"event": event},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
- for run_mode in _ALL_RUN_MODES:
- wait_started_flag.clear()
- events = []
+ try:
hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 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
- )
+ 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()
- 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 not script_obj.is_running
- 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()
+ # Make sure the script is really stopped.
- assert not script_obj.is_running
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
- # Make sure the script is really stopped.
-
- 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_not_schedule(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_wait_template_not_schedule(hass, script_mode):
"""Test the wait template with correct condition."""
event = "test_event"
-
- @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(
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"event": event},
{"wait_template": "{{ states.switch.test.state == 'on' }}"},
{"event": event},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- for run_mode in _ALL_RUN_MODES:
- events = []
+ hass.states.async_set("switch.test", "on")
+ await script_obj.async_run()
+ 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 not script_obj.is_running
+ assert len(events) == 2
- await script_obj.async_run()
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+@pytest.mark.parametrize(
+ "continue_on_timeout,n_events", [(False, 0), (True, 1), (None, 1)]
+)
+async def test_wait_template_timeout(
+ hass, mock_timeout, continue_on_timeout, n_events, script_mode
+):
+ """Test the wait template, halt on timeout."""
+ event = "test_event"
+ events = async_capture_events(hass, event)
+ sequence = [
+ {"wait_template": "{{ states.switch.test.state == 'off' }}", "timeout": 5},
+ {"event": event},
+ ]
+ if continue_on_timeout is not None:
+ sequence[0]["continue_on_timeout"] = continue_on_timeout
+ sequence = cv.SCRIPT_SCHEMA(sequence)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
+
+ try:
+ hass.states.async_set("switch.test", "on")
+ 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:
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
await hass.async_block_till_done()
assert not script_obj.is_running
- assert len(events) == 2
+ assert len(events) == n_events
-async def test_wait_template_timeout_halt(hass):
- """Test the wait template, halt on timeout."""
- event = "test_event"
- wait_started_flag = asyncio.Event()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- @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": False,
- "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) == 0
-
-
-async def test_wait_template_timeout_continue(hass):
- """Test the wait template with continuing the script."""
- event = "test_event"
- wait_started_flag = asyncio.Event()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- @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"
- wait_started_flag = asyncio.Event()
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- @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' }}",
- "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_variables(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_wait_template_variables(hass, script_mode):
"""Test the wait template with variables."""
- wait_started_flag = asyncio.Event()
+ sequence = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
- @callback
- def wait_started_cb():
- wait_started_flag.set()
+ assert script_obj.can_cancel
- schema = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"})
-
- for run_mode in _ALL_RUN_MODES:
- wait_started_flag.clear()
+ try:
hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run({"data": "switch.test"}))
+ await asyncio.wait_for(wait_started_flag.wait(), 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
- )
+ 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 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
+ assert not script_obj.is_running
-async def test_condition_basic(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_condition_basic(hass, script_mode):
"""Test if we can use conditions in a script."""
event = "test_event"
- events = []
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
-
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
[
{"event": event},
{
@@ -1155,208 +757,127 @@ async def test_condition_basic(hass):
{"event": event},
]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- 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
-
- 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)
+ assert script_obj.can_cancel == (script_mode != "legacy")
hass.states.async_set("test.entity", "hello")
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "condition": "template",
- "value_template": '{{ states.test.entity.state == "hello" }}',
- },
- {"event": event},
- ]
- ),
+ 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
+
+
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+@asynctest.patch("homeassistant.helpers.script.condition.async_from_config")
+async def test_condition_created_once(async_from_config, hass, script_mode):
+ """Test that the conditions do not get created multiple times."""
+ sequence = cv.SCRIPT_SCHEMA(
+ {
+ "condition": "template",
+ "value_template": '{{ states.test.entity.state == "hello" }}',
+ }
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
+ async_from_config.reset_mock()
+
+ hass.states.async_set("test.entity", "hello")
await script_obj.async_run()
await script_obj.async_run()
await hass.async_block_till_done()
- assert async_from_config.call_count == 1
+
+ async_from_config.assert_called_once()
assert len(script_obj._config_cache) == 1
-async def test_condition_all_cached(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_condition_all_cached(hass, script_mode):
"""Test that multiple conditions get cached."""
- event = "test_event"
- events = []
-
- @callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
+ sequence = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "condition": "template",
+ "value_template": '{{ states.test.entity.state == "hello" }}',
+ },
+ {
+ "condition": "template",
+ "value_template": '{{ states.test.entity.state != "hello" }}',
+ },
+ ]
+ )
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
hass.states.async_set("test.entity", "hello")
-
- 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},
- ]
- ),
- )
-
await script_obj.async_run()
await hass.async_block_till_done()
+
assert len(script_obj._config_cache) == 2
-async def test_last_triggered(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_last_triggered(hass, script_mode):
"""Test the last_triggered."""
event = "test_event"
+ sequence = cv.SCRIPT_SCHEMA({"event": event})
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- schema = cv.SCRIPT_SCHEMA({"event": event})
+ assert script_obj.last_triggered is None
- 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)
+ 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 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
+ assert script_obj.last_triggered == time
-async def test_propagate_error_service_not_found(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_propagate_error_service_not_found(hass, script_mode):
"""Test that a script aborts when a service is not found."""
event = "test_event"
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- @callback
- def record_event(event):
- events.append(event)
+ with pytest.raises(exceptions.ServiceNotFound):
+ await script_obj.async_run()
- hass.bus.async_listen(event, record_event)
-
- 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(exceptions.ServiceNotFound):
- await script_obj.async_run()
-
- assert len(events) == 0
- assert not script_obj.is_running
+ assert len(events) == 0
+ assert not script_obj.is_running
-async def test_propagate_error_invalid_service_data(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_propagate_error_invalid_service_data(hass, script_mode):
"""Test that a script aborts when we send invalid service data."""
event = "test_event"
-
- @callback
- def record_event(event):
- events.append(event)
-
- 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, schema=vol.Schema({"text": str})
- )
-
- schema = cv.SCRIPT_SCHEMA(
+ events = async_capture_events(hass, event)
+ calls = async_mock_service(hass, "test", "script", vol.Schema({"text": str}))
+ sequence = cv.SCRIPT_SCHEMA(
[{"service": "test.script", "data": {"text": 1}}, {"event": event}]
)
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- run_modes = _ALL_RUN_MODES
- if "background" in run_modes:
- run_modes.remove("background")
- for run_mode in run_modes:
- events = []
- calls = []
+ with pytest.raises(vol.Invalid):
+ await script_obj.async_run()
- 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(vol.Invalid):
- await script_obj.async_run()
-
- assert len(events) == 0
- assert len(calls) == 0
- assert not script_obj.is_running
+ assert len(events) == 0
+ assert len(calls) == 0
+ assert not script_obj.is_running
-async def test_propagate_error_service_exception(hass):
+@pytest.mark.parametrize("script_mode", _BASIC_SCRIPT_MODES)
+async def test_propagate_error_service_exception(hass, script_mode):
"""Test that a script aborts when a service throws an exception."""
event = "test_event"
-
- @callback
- def record_event(event):
- events.append(event)
-
- hass.bus.async_listen(event, record_event)
+ events = async_capture_events(hass, event)
@callback
def record_call(service):
@@ -1365,24 +886,14 @@ async def test_propagate_error_service_exception(hass):
hass.services.async_register("test", "script", record_call)
- schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
+ sequence = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
+ script_obj = script.Script(hass, sequence, script_mode=script_mode)
- run_modes = _ALL_RUN_MODES
- if "background" in run_modes:
- run_modes.remove("background")
- for run_mode in run_modes:
- events = []
+ with pytest.raises(ValueError):
+ await script_obj.async_run()
- 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
+ assert len(events) == 0
+ assert not script_obj.is_running
async def test_referenced_entities():
@@ -1441,68 +952,37 @@ async def test_referenced_devices():
assert script_obj.referenced_devices is script_obj.referenced_devices
-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
- )
+@contextmanager
+def does_not_raise():
+ """Indicate no exception is expected."""
+ yield
-async def test_if_running_ignore(hass, caplog):
- """Test overlapping runs with if_running='ignore'."""
- # TODO: REMOVE
- if _ALL_RUN_MODES == [None]:
- return
-
+@pytest.mark.parametrize(
+ "script_mode,expectation,messages",
+ [
+ ("ignore", does_not_raise(), ["Skipping"]),
+ ("error", pytest.raises(exceptions.HomeAssistantError), []),
+ ],
+)
+async def test_script_mode_1(hass, caplog, script_mode, expectation, messages):
+ """Test overlapping runs with script_mode='ignore'."""
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(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, "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"),
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
)
+ logger = logging.getLogger("TEST")
+ script_obj = script.Script(hass, sequence, script_mode=script_mode, logger=logger)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
- await script_obj.async_run()
+ hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run())
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1510,85 +990,19 @@ async def test_if_running_ignore(hass, caplog):
assert events[0].data["value"] == 1
# Start second run of script while first run is suspended in wait_template.
- # This should ignore second run.
- 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 not script_obj.is_running
- assert len(events) == 2
- assert events[1].data["value"] == 2
-
-
-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(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, "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"),
- )
-
- try:
- await script_obj.async_run()
- await asyncio.wait_for(wait_started_flag.wait(), 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.
-
- with pytest.raises(exceptions.HomeAssistantError):
+ with expectation:
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
+ assert all(
+ any(
+ rec.levelname == "INFO"
+ and rec.name == "TEST"
+ and message in rec.message
+ for rec in caplog.records
+ )
+ for message in messages
)
except (AssertionError, asyncio.TimeoutError):
await script_obj.async_stop()
@@ -1602,46 +1016,28 @@ async def test_if_running_error(hass, caplog):
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
-
+@pytest.mark.parametrize(
+ "script_mode,messages,last_events",
+ [("restart", ["Restarting"], [2]), ("parallel", [], [2, 2])],
+)
+async def test_script_mode_2(hass, caplog, script_mode, messages, last_events):
+ """Test overlapping runs with script_mode='restart'."""
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(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, "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"),
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
)
+ logger = logging.getLogger("TEST")
+ script_obj = script.Script(hass, sequence, script_mode=script_mode, logger=logger)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
- await script_obj.async_run()
+ hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run())
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1652,17 +1048,20 @@ async def test_if_running_restart(hass, caplog):
# This should stop first run then start a new run.
wait_started_flag.clear()
- await script_obj.async_run()
+ 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) == 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
+ assert all(
+ any(
+ rec.levelname == "INFO"
+ and rec.name == "TEST"
+ and message in rec.message
+ for rec in caplog.records
+ )
+ for message in messages
)
except (AssertionError, asyncio.TimeoutError):
await script_obj.async_stop()
@@ -1672,50 +1071,30 @@ async def test_if_running_restart(hass, caplog):
await hass.async_block_till_done()
assert not script_obj.is_running
- assert len(events) == 3
- assert events[2].data["value"] == 2
+ assert len(events) == 2 + len(last_events)
+ for idx, value in enumerate(last_events, start=2):
+ assert events[idx].data["value"] == value
-async def test_if_running_parallel(hass):
- """Test overlapping runs with if_running='parallel'."""
- # TODO: REMOVE
- if _ALL_RUN_MODES == [None]:
- return
-
+async def test_script_mode_queue(hass):
+ """Test overlapping runs with script_mode='queue'."""
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(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, "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"),
+ events = async_capture_events(hass, event)
+ sequence = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ {"wait_template": "{{ states.switch.test.state == 'on' }}"},
+ ]
)
+ logger = logging.getLogger("TEST")
+ script_obj = script.Script(hass, sequence, script_mode="queue", logger=logger)
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
- await script_obj.async_run()
+ hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run())
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1723,25 +1102,41 @@ async def test_if_running_parallel(hass):
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.
+ # This second run should not start until the first run has finished.
+
+ hass.async_create_task(script_obj.async_run())
+
+ await asyncio.sleep(0)
+ assert script_obj.is_running
+ assert len(events) == 1
wait_started_flag.clear()
- await script_obj.async_run()
+ hass.states.async_set("switch.test", "off")
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 events[1].data["value"] == 2
+
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ await asyncio.sleep(0)
+ assert script_obj.is_running
+ assert len(events) == 3
+ assert events[2].data["value"] == 1
except (AssertionError, asyncio.TimeoutError):
await script_obj.async_stop()
raise
else:
hass.states.async_set("switch.test", "off")
+ await asyncio.sleep(0)
+ hass.states.async_set("switch.test", "on")
await hass.async_block_till_done()
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_storage.py b/tests/helpers/test_storage.py
index 8c8d370e4b4..dcadd4d4369 100644
--- a/tests/helpers/test_storage.py
+++ b/tests/helpers/test_storage.py
@@ -6,7 +6,7 @@ from unittest.mock import Mock, patch
import pytest
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE
from homeassistant.helpers import storage
from homeassistant.util import dt
@@ -85,7 +85,7 @@ async def test_saving_on_stop(hass, hass_storage):
store.async_delay_save(lambda: MOCK_DATA, 1)
assert store.key not in hass_storage
- hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
await hass.async_block_till_done()
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py
index 115e00168fc..c17c79ccbc8 100644
--- a/tests/helpers/test_update_coordinator.py
+++ b/tests/helpers/test_update_coordinator.py
@@ -104,6 +104,16 @@ async def test_refresh_fail_unknown(crd, caplog):
assert "Unexpected error fetching test data" in caplog.text
+async def test_refresh_no_update_method(crd):
+ """Test raising error is no update method is provided."""
+ await crd.async_refresh()
+
+ crd.update_method = None
+
+ with pytest.raises(NotImplementedError):
+ await crd.async_refresh()
+
+
async def test_update_interval(hass, crd):
"""Test update interval works."""
# Test we don't update without subscriber
@@ -132,3 +142,13 @@ async def test_update_interval(hass, crd):
# Test we stop updating after we lose last subscriber
assert crd.data == 2
+
+
+async def test_refresh_recover(crd, caplog):
+ """Test recovery of freshing data."""
+ crd.last_update_success = False
+
+ await crd.async_refresh()
+
+ assert crd.last_update_success is True
+ assert "Fetching test data recovered" in caplog.text
diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py
new file mode 100644
index 00000000000..47242be8a5a
--- /dev/null
+++ b/tests/ignore_uncaught_exceptions.py
@@ -0,0 +1,89 @@
+"""List of modules that have uncaught exceptions today. Will be shrunk over time."""
+IGNORE_UNCAUGHT_EXCEPTIONS = [
+ ("tests.components.cast.test_media_player", "test_start_discovery_called_once"),
+ ("tests.components.cast.test_media_player", "test_entry_setup_single_config"),
+ ("tests.components.cast.test_media_player", "test_entry_setup_list_config"),
+ ("tests.components.cast.test_media_player", "test_entry_setup_platform_not_ready"),
+ ("tests.components.demo.test_init", "test_setting_up_demo"),
+ ("tests.components.discovery.test_init", "test_discover_config_flow"),
+ ("tests.components.dsmr.test_sensor", "test_default_setup"),
+ ("tests.components.dsmr.test_sensor", "test_v4_meter"),
+ ("tests.components.dsmr.test_sensor", "test_v5_meter"),
+ ("tests.components.dsmr.test_sensor", "test_belgian_meter"),
+ ("tests.components.dsmr.test_sensor", "test_belgian_meter_low"),
+ ("tests.components.dsmr.test_sensor", "test_tcp"),
+ ("tests.components.dsmr.test_sensor", "test_connection_errors_retry"),
+ ("tests.components.dyson.test_air_quality", "test_purecool_aiq_attributes"),
+ ("tests.components.dyson.test_air_quality", "test_purecool_aiq_update_state"),
+ (
+ "tests.components.dyson.test_air_quality",
+ "test_purecool_component_setup_only_once",
+ ),
+ ("tests.components.dyson.test_air_quality", "test_purecool_aiq_without_discovery"),
+ (
+ "tests.components.dyson.test_air_quality",
+ "test_purecool_aiq_empty_environment_state",
+ ),
+ (
+ "tests.components.dyson.test_climate",
+ "test_setup_component_with_parent_discovery",
+ ),
+ ("tests.components.dyson.test_fan", "test_purecoollink_attributes"),
+ ("tests.components.dyson.test_fan", "test_purecool_turn_on"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_speed"),
+ ("tests.components.dyson.test_fan", "test_purecool_turn_off"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_dyson_speed"),
+ ("tests.components.dyson.test_fan", "test_purecool_oscillate"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_night_mode"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_auto_mode"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_angle"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_flow_direction_front"),
+ ("tests.components.dyson.test_fan", "test_purecool_set_timer"),
+ ("tests.components.dyson.test_fan", "test_purecool_update_state"),
+ ("tests.components.dyson.test_fan", "test_purecool_update_state_filter_inv"),
+ ("tests.components.dyson.test_fan", "test_purecool_component_setup_only_once"),
+ ("tests.components.dyson.test_sensor", "test_purecool_component_setup_only_once"),
+ ("tests.components.ios.test_init", "test_creating_entry_sets_up_sensor"),
+ ("tests.components.ios.test_init", "test_not_configuring_ios_not_creates_entry"),
+ ("tests.components.local_file.test_camera", "test_file_not_readable"),
+ (
+ "tests.components.mqtt.test_init",
+ "test_setup_uses_certificate_on_certificate_set_to_auto",
+ ),
+ (
+ "tests.components.mqtt.test_init",
+ "test_setup_does_not_use_certificate_on_mqtts_port",
+ ),
+ (
+ "tests.components.mqtt.test_init",
+ "test_setup_without_tls_config_uses_tlsv1_under_python36",
+ ),
+ (
+ "tests.components.mqtt.test_init",
+ "test_setup_with_tls_config_uses_tls_version1_2",
+ ),
+ (
+ "tests.components.mqtt.test_init",
+ "test_setup_with_tls_config_of_v1_under_python36_only_uses_v1",
+ ),
+ ("tests.components.qwikswitch.test_init", "test_binary_sensor_device"),
+ ("tests.components.qwikswitch.test_init", "test_sensor_device"),
+ ("tests.components.rflink.test_init", "test_send_command_invalid_arguments"),
+ ("tests.components.samsungtv.test_media_player", "test_update_connection_failure"),
+ (
+ "tests.components.tplink.test_init",
+ "test_configuring_devices_from_multiple_sources",
+ ),
+ ("tests.components.tradfri.test_light", "test_light"),
+ ("tests.components.tradfri.test_light", "test_light_observed"),
+ ("tests.components.tradfri.test_light", "test_light_available"),
+ ("tests.components.tradfri.test_light", "test_turn_on"),
+ ("tests.components.tradfri.test_light", "test_turn_off"),
+ ("tests.components.unifi_direct.test_device_tracker", "test_get_scanner"),
+ ("tests.components.upnp.test_init", "test_async_setup_entry_default"),
+ ("tests.components.upnp.test_init", "test_async_setup_entry_port_mapping"),
+ ("tests.components.yr.test_sensor", "test_default_setup"),
+ ("tests.components.yr.test_sensor", "test_custom_setup"),
+ ("tests.components.yr.test_sensor", "test_forecast_setup"),
+ ("tests.components.zwave.test_init", "test_power_schemes"),
+]
diff --git a/tests/test_core.py b/tests/test_core.py
index f5a6f4718cd..5e6bb090821 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -21,6 +21,7 @@ from homeassistant.const import (
EVENT_CALL_SERVICE,
EVENT_CORE_CONFIG_UPDATE,
EVENT_HOMEASSISTANT_CLOSE,
+ EVENT_HOMEASSISTANT_FINAL_WRITE,
EVENT_HOMEASSISTANT_STOP,
EVENT_SERVICE_REGISTERED,
EVENT_SERVICE_REMOVED,
@@ -151,10 +152,14 @@ def test_stage_shutdown():
"""Simulate a shutdown, test calling stuff."""
hass = get_test_home_assistant()
test_stop = []
+ test_final_write = []
test_close = []
test_all = []
hass.bus.listen(EVENT_HOMEASSISTANT_STOP, lambda event: test_stop.append(event))
+ hass.bus.listen(
+ EVENT_HOMEASSISTANT_FINAL_WRITE, lambda event: test_final_write.append(event)
+ )
hass.bus.listen(EVENT_HOMEASSISTANT_CLOSE, lambda event: test_close.append(event))
hass.bus.listen("*", lambda event: test_all.append(event))
@@ -162,7 +167,8 @@ def test_stage_shutdown():
assert len(test_stop) == 1
assert len(test_close) == 1
- assert len(test_all) == 1
+ assert len(test_final_write) == 1
+ assert len(test_all) == 2
class TestHomeAssistant(unittest.TestCase):
diff --git a/tests/util/test_network.py b/tests/util/test_network.py
new file mode 100644
index 00000000000..c4c33c8d187
--- /dev/null
+++ b/tests/util/test_network.py
@@ -0,0 +1,40 @@
+"""Test Home Assistant volume utility functions."""
+
+from ipaddress import ip_address
+
+import homeassistant.util.network as network_util
+
+
+def test_is_loopback():
+ """Test loopback addresses."""
+ assert network_util.is_loopback(ip_address("127.0.0.2"))
+ assert network_util.is_loopback(ip_address("127.0.0.1"))
+ assert network_util.is_loopback(ip_address("::1"))
+ assert network_util.is_loopback(ip_address("::ffff:127.0.0.0"))
+ assert network_util.is_loopback(ip_address("0:0:0:0:0:0:0:1"))
+ assert network_util.is_loopback(ip_address("0:0:0:0:0:ffff:7f00:1"))
+ assert not network_util.is_loopback(ip_address("104.26.5.238"))
+ assert not network_util.is_loopback(ip_address("2600:1404:400:1a4::356e"))
+
+
+def test_is_private():
+ """Test private addresses."""
+ assert network_util.is_private(ip_address("192.168.0.1"))
+ assert network_util.is_private(ip_address("172.16.12.0"))
+ assert network_util.is_private(ip_address("10.5.43.3"))
+ assert network_util.is_private(ip_address("fd12:3456:789a:1::1"))
+ assert not network_util.is_private(ip_address("127.0.0.1"))
+ assert not network_util.is_private(ip_address("::1"))
+
+
+def test_is_link_local():
+ """Test link local addresses."""
+ assert network_util.is_link_local(ip_address("169.254.12.3"))
+ assert not network_util.is_link_local(ip_address("127.0.0.1"))
+
+
+def test_is_local():
+ """Test local addresses."""
+ assert network_util.is_local(ip_address("192.168.0.1"))
+ assert network_util.is_local(ip_address("127.0.0.1"))
+ assert not network_util.is_local(ip_address("208.5.4.2"))